feat(admin): implement P4 management console

This commit is contained in:
xqzp2026
2026-09-03 18:01:00 +09:30
parent 691b3a3713
commit b1dd68a14c
27 changed files with 3236 additions and 5 deletions
+2
View File
@@ -1,4 +1,6 @@
target/
front/node_modules/
front/dist/
*.log
.idea/
*.iml
+11 -3
View File
@@ -2,7 +2,7 @@
面向 MCP 客户端(Kimi Code / Claude 等)的小趣业务数据服务:独立 Spring Boot 应用,只读查询现有三库(m0 权限库 / m1 商城库 / m2 业务库),以 MCP(Streamable HTTP)形式对外暴露工具。
设计方案见 `../xiaoqu-franchisee/doc/agent-mcp-service-design.md`。当前进度:**P3 首批工具完成**(P1 骨架 + P2 权限层 + agent.list.query / property.list.query 完整版,均经 curl 实测)。管理后台为 P4,未实现。
设计方案见 `../xiaoqu-franchisee/doc/agent-mcp-service-design.md`。当前进度:**P4 管理后台完成**(P1 骨架 + P2 权限层 + P3 首批工具 + P4 Vue 管理后台)。
## 技术栈与已验证版本组合
@@ -14,6 +14,7 @@
| MyBatis-Plus | 3.5.12(`mybatis-plus-spring-boot3-starter`) |
| MySQL 驱动 | mysql-connector-j(Boot 管理) |
| Redis | spring-boot-starter-data-redis(db5) |
| 管理后台 | Vue 3 + Element Plus + Vite 7 |
> ⚠️ 版本组合说明:Spring Boot 3.5.x + Spring AI 1.0.0 的 streamable 传输存在 NPE([spring-projects/spring-ai#6274](https://github.com/spring-projects/spring-ai/issues/6274)),本工程选用已修复的 **Spring AI 1.1.8**,2026-09-03 实测 STREAMABLE 握手(initialize → tools/list → tools/call)通过。
@@ -25,7 +26,9 @@ xiaoqu-agent-service/
├── pom.xml
├── sql/
│ ├── V1__init.sql # 自有库 xiaoqu_agent_svc_d 建表(手工执行,未接 flyway)
│ ├── V2__p4_admin.sql # 旧库升级管理员密码哈希字段
│ └── seed-dev.sql # dev 种子数据(运营作用域授权,不含固定 ak)
├── front/ # Vue 3 管理后台,Maven 打包时自动构建进 jar
├── src/main/java/me/iiv/agent/
│ ├── AgentServiceApplication.java
│ ├── config/ # M0/M1/M2/SvcDataSourceConfig(独立 SqlSessionFactory + MapperScan)、McpToolConfig
@@ -35,7 +38,7 @@ xiaoqu-agent-service/
│ ├── security/ # AkAuthFilter(/mcp ak→401/AuthContext)、AuthService(ak 解析+用户加载)、ToolGuard
│ ├── permission/ # PermissionService:行级数据权限引擎(移植 getByIsAgent)
│ ├── common/ # AuditService(audit_log 落库)
│ ├── admin/ # P4 占位包
│ ├── admin/ # 后台登录、会话/CSRF、Token/工具/作用域/审计 API
│ ├── entity/m0、m2、svc mapper/m0、m1、m2、svc # codegen 生成物(提交进仓库)
├── src/main/resources/
│ ├── application.yml # MCP server: protocol=STREAMABLE, type=SYNC,端点 /mcp
@@ -99,6 +102,10 @@ export M2_DB_PASSWORD=<password>
export SVC_DB_USERNAME=<svc_user>
export SVC_DB_PASSWORD=<password>
export REDIS_PASSWORD=<password>
# 仅首次、且 admin_user 为空时创建管理员;创建完成后可移除这三个变量
export ADMIN_BOOTSTRAP_ACCOUNT=<admin_account>
export ADMIN_BOOTSTRAP_PASSWORD=<strong_password>
export ADMIN_BOOTSTRAP_NAME=<display_name>
./mvnw clean package -DskipTests
java -jar target/xiaoqu-agent-service.jar
@@ -109,7 +116,8 @@ nohup java -jar target/xiaoqu-agent-service.jar > target/app.log 2>&1 &
```
- 不提供默认 profile、数据库账号或密码;缺失配置时不得启动。
- dev 端口 **8088**,MCP 端点 `http://localhost:8088/mcp`。
- dev 端口 **8088**,管理后台 `http://localhost:8088/`,MCP 端点 `http://localhost:8088/mcp`。
- 已存在 V1 数据库时,部署 P4 前先执行 `mysql ... < sql/V2__p4_admin.sql`。
### 部署脚本(推荐,本机 dev 部署)
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="小趣智能体服务管理后台" />
<title>小趣智能体服务</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "xiaoqu-agent-admin",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "node --test tests/*.test.mjs"
},
"dependencies": {
"element-plus": "2.11.3",
"vue": "3.5.21"
},
"devDependencies": {
"@vitejs/plugin-vue": "6.0.1",
"vite": "7.3.6"
}
}
+406
View File
@@ -0,0 +1,406 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api, setCsrf } from './api'
const menus = [
{ id: 'tokens', label: '用户与 Token', icon: '👥' },
{ id: 'tools', label: '工具管理', icon: '🔧' },
{ id: 'scopes', label: '作用域管理', icon: '🧩' },
{ id: 'audit', label: '审计日志', icon: '📋' },
]
const loggedIn = ref(false)
const busy = ref(false)
const current = ref('tokens')
const session = reactive({ name: '', csrfToken: '' })
const loginForm = reactive({ account: '', password: '' })
const users = ref([])
const userKeyword = ref('')
const selectedUser = ref(null)
const permission = ref(null)
const tokens = ref([])
const scopes = ref([])
const tools = ref([])
const audits = ref([])
const toolFilter = reactive({ category: '', kind: '', keyword: '' })
const auditFilter = reactive({ person: '', tokenPrefix: '', tool: '', result: '', client: '', from: '', to: '' })
const tokenDialog = ref(false)
const tokenForm = reactive({ id: null, memo: '', scopeIds: [], expireTime: null })
const issuedPlaintext = ref('')
const scopeDialog = ref(false)
const scopeForm = reactive({ id: null, name: '', code: '', memo: '' })
const scopeToolsDialog = ref(false)
const activeScope = ref(null)
const selectedToolIds = ref([])
const title = computed(() => menus.find(item => item.id === current.value)?.label || '')
const categories = computed(() => [...new Set(tools.value.map(tool => tool.category))])
const groupedTools = computed(() => tools.value.reduce((groups, tool) => {
const category = tool.category || '其他'
;(groups[category] ||= []).push(tool)
return groups
}, {}))
function qs(values) {
const params = new URLSearchParams()
Object.entries(values).forEach(([key, value]) => { if (value !== '' && value != null) params.set(key, value) })
return params.toString() ? `?${params}` : ''
}
async function run(task) {
busy.value = true
try { return await task() }
catch (error) {
if (error.status === 401) { loggedIn.value = false; setCsrf('') }
ElMessage.error(error.message)
throw error
} finally { busy.value = false }
}
async function login() {
if (!loginForm.account.trim() || !loginForm.password) return ElMessage.warning('请输入账号和密码')
await run(async () => {
const data = await api('/auth/login', { method: 'POST', body: JSON.stringify(loginForm) })
Object.assign(session, data); setCsrf(data.csrfToken); loggedIn.value = true
loginForm.password = ''
await loadUsers()
}).catch(() => {})
}
async function logout() {
await run(() => api('/auth/logout', { method: 'POST' })).catch(() => {})
loggedIn.value = false; setCsrf('')
}
async function switchMenu(id) {
current.value = id
if (id === 'tokens') await loadUsers()
if (id === 'tools') await loadTools()
if (id === 'scopes') await loadScopes()
if (id === 'audit') await Promise.all([loadAudit(), api('/tools').then(data => { tools.value = data })])
}
async function loadUsers() {
users.value = await api(`/users${qs({ keyword: userKeyword.value })}`)
const existing = users.value.find(user => user.id === selectedUser.value?.id)
if (existing) selectedUser.value = existing
else if (users.value.length) await selectUser(users.value[0])
else { selectedUser.value = null; permission.value = null; tokens.value = [] }
}
async function selectUser(user) {
selectedUser.value = user
;[permission.value, tokens.value, scopes.value] = await Promise.all([
api(`/users/${user.id}/permission`), api(`/users/${user.id}/tokens`), api('/scopes'),
])
}
function openToken(token = null) {
Object.assign(tokenForm, token ? {
id: token.id, memo: token.memo || '', scopeIds: [...token.scopeIds], expireTime: token.expireTime || null,
} : { id: null, memo: '', scopeIds: [], expireTime: null })
tokenDialog.value = true
}
async function saveToken() {
if (!tokenForm.scopeIds.length) return ElMessage.warning('至少选择一个作用域')
await run(async () => {
const body = JSON.stringify({ memo: tokenForm.memo, scopeIds: tokenForm.scopeIds, expireTime: tokenForm.expireTime })
if (tokenForm.id) await api(`/tokens/${tokenForm.id}`, { method: 'PUT', body })
else {
const issued = await api(`/users/${selectedUser.value.id}/tokens`, { method: 'POST', body })
issuedPlaintext.value = issued.plaintext
}
tokenDialog.value = false
tokens.value = await api(`/users/${selectedUser.value.id}/tokens`)
await loadUsers()
ElMessage.success(tokenForm.id ? 'Token 已更新' : 'Token 已签发')
}).catch(() => {})
}
async function setTokenStatus(token) {
await run(async () => {
await api(`/tokens/${token.id}/status`, { method: 'PATCH', body: JSON.stringify({ status: token.status === 1 ? 0 : 1 }) })
tokens.value = await api(`/users/${selectedUser.value.id}/tokens`)
}).catch(() => {})
}
async function deleteToken(token) {
try { await ElMessageBox.confirm(`确认删除 ${token.keyPrefix}?此操作不可恢复。`, '删除 Token', { type: 'warning' }) }
catch { return }
await run(async () => {
await api(`/tokens/${token.id}`, { method: 'DELETE' })
tokens.value = await api(`/users/${selectedUser.value.id}/tokens`)
await loadUsers()
}).catch(() => {})
}
async function loadTools() {
tools.value = await api(`/tools${qs(toolFilter)}`)
}
async function setToolStatus(tool) {
await run(async () => {
await api(`/tools/${tool.id}/status`, { method: 'PATCH', body: JSON.stringify({ status: tool.status === 1 ? 0 : 1 }) })
await loadTools()
}).catch(() => {})
}
async function loadScopes() { scopes.value = await api('/scopes') }
function openScope(scope = null) {
Object.assign(scopeForm, scope ? { id: scope.id, name: scope.name, code: scope.code, memo: scope.memo || '' }
: { id: null, name: '', code: '', memo: '' })
scopeDialog.value = true
}
async function saveScope() {
if (!scopeForm.name.trim() || !scopeForm.code.trim()) return ElMessage.warning('请填写名称和编码')
await run(async () => {
const body = JSON.stringify({ name: scopeForm.name, code: scopeForm.code, memo: scopeForm.memo })
if (scopeForm.id) await api(`/scopes/${scopeForm.id}`, { method: 'PUT', body })
else await api('/scopes', { method: 'POST', body })
scopeDialog.value = false; await loadScopes(); ElMessage.success('作用域已保存')
}).catch(() => {})
}
async function setScopeStatus(scope) {
await run(async () => {
await api(`/scopes/${scope.id}/status`, { method: 'PATCH', body: JSON.stringify({ status: scope.status === 1 ? 0 : 1 }) })
await loadScopes()
}).catch(() => {})
}
async function deleteScope(scope) {
try { await ElMessageBox.confirm(`确认删除作用域“${scope.name}”?`, '删除作用域', { type: 'warning' }) }
catch { return }
await run(async () => {
await api(`/scopes/${scope.id}`, { method: 'DELETE' })
await loadScopes(); ElMessage.success('作用域已删除')
}).catch(() => {})
}
async function openScopeTools(scope) {
activeScope.value = scope
;[selectedToolIds.value, tools.value] = await Promise.all([api(`/scopes/${scope.id}/tools`), api('/tools')])
scopeToolsDialog.value = true
}
async function saveScopeTools() {
await run(async () => {
await api(`/scopes/${activeScope.value.id}/tools`, { method: 'PUT', body: JSON.stringify({ toolIds: selectedToolIds.value }) })
scopeToolsDialog.value = false; await loadScopes(); ElMessage.success('工具配置已保存')
}).catch(() => {})
}
async function loadAudit() { audits.value = await api(`/audit${qs(auditFilter)}`) }
function date(value) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' }
function expiry(value) { return value ? date(value) : '永久' }
function enterprise(value) { return ({ 1: '代理', 2: '物业' })[value] || `类型 ${value ?? '未配置'}` }
async function copyToken() { await navigator.clipboard.writeText(issuedPlaintext.value); ElMessage.success('已复制') }
onMounted(async () => {
try {
const data = await api('/auth/me')
Object.assign(session, data); setCsrf(data.csrfToken); loggedIn.value = true; await loadUsers()
} catch { loggedIn.value = false }
})
</script>
<template>
<div v-if="!loggedIn" class="login-page">
<section class="login-card" @keyup.enter="login">
<div class="brand-mark">趣</div>
<h1>小趣智能体服务</h1>
<p>管理后台</p>
<label class="field-label" for="account">管理员账号</label>
<el-input id="account" v-model="loginForm.account" size="large" autocomplete="username" aria-label="管理员账号"
placeholder="请输入管理员账号" data-testid="admin-input-account" />
<label class="field-label" for="password">密码</label>
<el-input id="password" v-model="loginForm.password" type="password" show-password size="large"
autocomplete="current-password" aria-label="密码" placeholder="请输入密码" data-testid="admin-input-password" />
<el-button type="primary" size="large" :loading="busy" data-testid="admin-btn-login" @click="login">登 录</el-button>
</section>
</div>
<div v-else class="shell">
<aside class="sidebar">
<div class="logo"><span>趣</span> 智能体服务后台</div>
<nav aria-label="主导航">
<button :class="['menu-item', { active: current === 'tokens' }]" data-testid="admin-menu-tokens" @click="switchMenu('tokens')">👥 用户与 Token</button>
<button :class="['menu-item', { active: current === 'tools' }]" data-testid="admin-menu-tools" @click="switchMenu('tools')">🔧 工具管理</button>
<button :class="['menu-item', { active: current === 'scopes' }]" data-testid="admin-menu-scopes" @click="switchMenu('scopes')">🧩 作用域管理</button>
<button :class="['menu-item', { active: current === 'audit' }]" data-testid="admin-menu-audit" @click="switchMenu('audit')">📋 审计日志</button>
</nav>
</aside>
<main class="main">
<header class="header">
<div><span class="header-kicker">控制台</span><strong>{{ title }}</strong></div>
<div class="admin-user"><span class="avatar">{{ session.name?.slice(0, 1) }}</span>{{ session.name }}
<el-button text data-testid="admin-btn-logout" @click="logout">退出</el-button>
</div>
</header>
<section v-loading="busy" class="content">
<template v-if="current === 'tokens'">
<div class="token-layout">
<div>
<article class="card">
<div class="card-title"><span>人员</span><small>来自现有 user 表</small></div>
<div class="toolbar compact">
<el-input v-model="userKeyword" clearable placeholder="姓名 / 手机号" aria-label="搜索人员"
data-testid="admin-input-user-search" @keyup.enter="run(loadUsers)" />
<el-button data-testid="admin-user-search-button" @click="run(loadUsers)">搜索</el-button>
</div>
<div class="user-list">
<button v-for="user in users" :key="user.id" :class="['user-row', { selected: selectedUser?.id === user.id }]"
:data-testid="`admin-user-row-${user.id}`" @click="run(() => selectUser(user))">
<span><strong>{{ user.name || '未命名' }}</strong><small>{{ user.mobile || '无手机号' }}</small></span>
<el-tag size="small" effect="plain">{{ user.tokenCount }} 把</el-tag>
</button>
<el-empty v-if="!users.length" :image-size="60" description="未找到人员" />
</div>
</article>
<article class="card permission-card">
<div class="card-title"><span>数据权限归属</span><small>只读 · 在现有系统维护</small></div>
<dl v-if="permission">
<div><dt>权限组</dt><dd>{{ permission.authorityName }} <el-tag v-if="permission.isAgent != null" size="small" type="warning">isagent={{ permission.isAgent }}</el-tag></dd></div>
<div><dt>所属代理商</dt><dd>{{ permission.agentName }}</dd></div>
<div><dt>企业类型</dt><dd>{{ enterprise(permission.enterpriseType) }}</dd></div>
<div><dt>可见数据范围</dt><dd>{{ permission.visibleRange }}</dd></div>
</dl>
<p class="hint">行级权限跟人走。排查“查不到数据”时先检查这里。</p>
</article>
</div>
<article class="card token-card">
<div class="card-title">
<span>{{ selectedUser?.name || '请选择人员' }} 的 Token</span>
<el-button type="primary" :disabled="!selectedUser" data-testid="admin-btn-issue-token" @click="openToken()">+ 签发 Token</el-button>
</div>
<el-table :data="tokens" data-testid="admin-token-table" empty-text="暂无 Token">
<el-table-column label="前缀" min-width="125"><template #default="{ row }"><code>{{ row.keyPrefix }}…</code></template></el-table-column>
<el-table-column prop="memo" label="备注" min-width="150" />
<el-table-column label="作用域" min-width="180"><template #default="{ row }"><el-tag v-for="name in row.scopeNames" :key="name" size="small" effect="plain">{{ name }}</el-tag></template></el-table-column>
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">{{ row.status === 1 ? '正常' : '已禁用' }}</el-tag></template></el-table-column>
<el-table-column label="过期时间" min-width="150"><template #default="{ row }">{{ expiry(row.expireTime) }}</template></el-table-column>
<el-table-column label="最后使用" min-width="150"><template #default="{ row }">{{ date(row.lastUsedTime) }}</template></el-table-column>
<el-table-column label="操作" width="175" fixed="right"><template #default="{ row }">
<el-button link type="primary" :data-testid="`admin-token-edit-${row.id}`" @click="openToken(row)">编辑</el-button>
<el-button link :type="row.status === 1 ? 'warning' : 'success'" :data-testid="`admin-token-status-${row.id}`" @click="setTokenStatus(row)">{{ row.status === 1 ? '禁用' : '启用' }}</el-button>
<el-button link type="danger" :data-testid="`admin-token-delete-${row.id}`" @click="deleteToken(row)">删除</el-button>
</template></el-table-column>
</el-table>
<p class="hint">数据权限跟人不跟 Token;每把 Token 仅通过作用域控制可用工具。</p>
</article>
</div>
</template>
<template v-else-if="current === 'tools'">
<article class="card">
<div class="toolbar">
<el-select v-model="toolFilter.category" clearable placeholder="全部分类" aria-label="工具分类" data-testid="admin-tool-category"><el-option v-for="item in categories" :key="item" :label="item" :value="item" /></el-select>
<el-select v-model="toolFilter.kind" clearable placeholder="全部类型" aria-label="工具类型" data-testid="admin-tool-kind"><el-option label="读" value="read" /><el-option label="写" value="write" /></el-select>
<el-input v-model="toolFilter.keyword" clearable placeholder="工具名 / 描述" aria-label="工具关键词" data-testid="admin-tool-keyword" @keyup.enter="run(loadTools)" />
<el-button type="primary" data-testid="admin-tool-search" @click="run(loadTools)">查询</el-button>
</div>
</article>
<article class="card">
<div class="card-title"><span>工具列表</span><small>服务端注册自动同步</small></div>
<el-table :data="tools" data-testid="admin-table-tools" empty-text="暂无工具">
<el-table-column prop="name" label="工具名" min-width="210"><template #default="{ row }"><code>{{ row.name }}</code></template></el-table-column>
<el-table-column prop="category" label="分类" width="110" />
<el-table-column prop="description" label="描述" min-width="220" show-overflow-tooltip />
<el-table-column label="类型" width="80"><template #default="{ row }"><el-tag :type="row.kind === 'write' ? 'danger' : 'success'" size="small">{{ row.kind === 'write' ? '写' : '读' }}</el-tag></template></el-table-column>
<el-table-column label="所属作用域" min-width="180"><template #default="{ row }"><el-tag v-for="name in row.scopeNames" :key="name" size="small" effect="plain">{{ name }}</el-tag><span v-if="!row.scopeNames.length" class="danger-text">未被引用</span></template></el-table-column>
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">{{ row.status === 1 ? '启用' : '已停用' }}</el-tag></template></el-table-column>
<el-table-column label="同步时间" min-width="145"><template #default="{ row }">{{ date(row.modifyTime) }}</template></el-table-column>
<el-table-column label="操作" width="90"><template #default="{ row }"><el-button link :type="row.status === 1 ? 'danger' : 'primary'" :data-testid="`admin-tool-status-${row.id}`" @click="setToolStatus(row)">{{ row.status === 1 ? '停用' : '启用' }}</el-button></template></el-table-column>
</el-table>
<p class="hint">“停用”是全局紧急开关,生效后任何 Token 都无法调用该工具。</p>
</article>
</template>
<template v-else-if="current === 'scopes'">
<article class="card">
<div class="card-title"><span>作用域</span><el-button type="primary" data-testid="admin-btn-new-scope" @click="openScope()">+ 新建作用域</el-button></div>
<el-table :data="scopes" data-testid="admin-table-scopes" empty-text="暂无作用域">
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column prop="code" label="编码" min-width="150"><template #default="{ row }"><code>{{ row.code }}</code></template></el-table-column>
<el-table-column prop="memo" label="备注" min-width="220" />
<el-table-column label="工具数" width="90"><template #default="{ row }">{{ row.toolCount }} 个</template></el-table-column>
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">{{ row.status === 1 ? '启用' : '已停用' }}</el-tag></template></el-table-column>
<el-table-column label="更新时间" min-width="150"><template #default="{ row }">{{ date(row.modifyTime) }}</template></el-table-column>
<el-table-column label="操作" width="255"><template #default="{ row }">
<el-button link type="primary" :data-testid="`admin-scope-tools-${row.id}`" @click="openScopeTools(row)">配置工具</el-button>
<el-button link type="primary" :data-testid="`admin-scope-edit-${row.id}`" @click="openScope(row)">编辑</el-button>
<el-button link :type="row.status === 1 ? 'danger' : 'success'" :data-testid="`admin-scope-status-${row.id}`" @click="setScopeStatus(row)">{{ row.status === 1 ? '停用' : '启用' }}</el-button>
<el-button link type="danger" :data-testid="`admin-scope-delete-${row.id}`" @click="deleteScope(row)">删除</el-button>
</template></el-table-column>
</el-table>
</article>
</template>
<template v-else>
<article class="card">
<div class="toolbar audit-toolbar">
<el-input v-model="auditFilter.person" clearable placeholder="姓名 / 手机号" aria-label="人员" data-testid="admin-audit-person" />
<el-input v-model="auditFilter.tokenPrefix" clearable placeholder="Token 前缀" aria-label="Token 前缀" data-testid="admin-audit-token" />
<el-select v-model="auditFilter.tool" clearable filterable placeholder="全部工具" aria-label="工具名" data-testid="admin-audit-tool"><el-option v-for="item in tools" :key="item.id" :label="item.name" :value="item.name" /></el-select>
<el-select v-model="auditFilter.result" clearable placeholder="全部结果" aria-label="调用结果" data-testid="admin-audit-result"><el-option label="成功" value="success" /><el-option label="拒绝" value="denied" /><el-option label="错误" value="error" /></el-select>
<el-select v-model="auditFilter.client" clearable placeholder="全部客户端" aria-label="客户端" data-testid="admin-audit-client"><el-option label="Kimi" value="mcp-kimi" /><el-option label="Claude" value="mcp-claude" /><el-option label="CLI" value="cli" /><el-option label="未知" value="unknown" /></el-select>
<el-date-picker v-model="auditFilter.from" type="date" value-format="YYYY-MM-DD" placeholder="开始日期" aria-label="开始日期" data-testid="admin-audit-from" />
<el-date-picker v-model="auditFilter.to" type="date" value-format="YYYY-MM-DD" placeholder="结束日期" aria-label="结束日期" data-testid="admin-audit-to" />
<el-button type="primary" data-testid="admin-audit-search" @click="run(loadAudit)">查询</el-button>
</div>
</article>
<article class="card">
<div class="card-title"><span>调用审计</span><small>最近最多 200 条</small></div>
<el-table :data="audits" data-testid="admin-table-audit" empty-text="暂无审计记录">
<el-table-column label="时间" min-width="165"><template #default="{ row }">{{ date(row.createTime) }}</template></el-table-column>
<el-table-column prop="userName" label="人员" min-width="110" />
<el-table-column prop="tokenPrefix" label="Token" min-width="125"><template #default="{ row }"><code>{{ row.tokenPrefix || '—' }}</code></template></el-table-column>
<el-table-column prop="toolName" label="工具" min-width="190" />
<el-table-column prop="paramsDigest" label="参数摘要" min-width="190" show-overflow-tooltip />
<el-table-column label="结果" width="90"><template #default="{ row }"><el-tag :type="row.resultStatus === 'success' ? 'success' : row.resultStatus === 'denied' ? 'warning' : 'danger'" size="small">{{ row.resultStatus }}</el-tag></template></el-table-column>
<el-table-column prop="resultMessage" label="说明" min-width="160" show-overflow-tooltip />
<el-table-column label="耗时" width="90"><template #default="{ row }">{{ row.costMs == null ? '—' : `${row.costMs} ms` }}</template></el-table-column>
<el-table-column prop="clientType" label="客户端" width="110" />
<el-table-column prop="clientIp" label="IP" min-width="125" />
</el-table>
</article>
</template>
</section>
</main>
</div>
<el-dialog v-model="tokenDialog" :title="tokenForm.id ? '编辑 Token' : '签发 Token'" width="560px" destroy-on-close>
<el-form label-width="88px">
<el-form-item label="所属人员"><strong>{{ selectedUser?.name }}</strong></el-form-item>
<el-form-item label="用途备注"><el-input v-model="tokenForm.memo" maxlength="255" placeholder="如:Kimi 桌面用" aria-label="Token 用途备注" data-testid="admin-token-memo" /></el-form-item>
<el-form-item label="作用域"><el-checkbox-group v-model="tokenForm.scopeIds" data-testid="admin-token-scopes"><el-checkbox v-for="scope in scopes.filter(item => item.status === 1)" :key="scope.id" :value="scope.id">{{ scope.name }}</el-checkbox></el-checkbox-group></el-form-item>
<el-form-item label="过期时间"><el-date-picker v-model="tokenForm.expireTime" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="留空为永久" aria-label="Token 过期时间" data-testid="admin-token-expiry" /></el-form-item>
</el-form>
<template #footer><el-button data-testid="admin-token-cancel" @click="tokenDialog = false">取消</el-button><el-button type="primary" data-testid="admin-token-save" @click="saveToken">确认</el-button></template>
</el-dialog>
<el-dialog v-model="scopeDialog" :title="scopeForm.id ? '编辑作用域' : '新建作用域'" width="520px" destroy-on-close>
<el-form label-width="88px">
<el-form-item label="名称"><el-input v-model="scopeForm.name" maxlength="64" aria-label="作用域名称" data-testid="admin-scope-name" /></el-form-item>
<el-form-item label="编码"><el-input v-model="scopeForm.code" maxlength="64" placeholder="如 operation_read" aria-label="作用域编码" data-testid="admin-scope-code" /></el-form-item>
<el-form-item label="备注"><el-input v-model="scopeForm.memo" type="textarea" maxlength="255" aria-label="作用域备注" data-testid="admin-scope-memo" /></el-form-item>
</el-form>
<template #footer><el-button data-testid="admin-scope-cancel" @click="scopeDialog = false">取消</el-button><el-button type="primary" data-testid="admin-scope-save" @click="saveScope">保存</el-button></template>
</el-dialog>
<el-dialog v-model="scopeToolsDialog" :title="`配置工具 · ${activeScope?.name || ''}`" width="680px" destroy-on-close>
<el-checkbox-group v-model="selectedToolIds" class="tool-groups" data-testid="admin-scope-tool-options">
<section v-for="(items, category) in groupedTools" :key="category" class="tool-group"><h3>{{ category }}</h3><el-checkbox v-for="tool in items" :key="tool.id" :value="tool.id" :data-testid="`admin-scope-tool-${tool.id}`"><code>{{ tool.name }}</code><small>{{ tool.description }}</small></el-checkbox></section>
</el-checkbox-group>
<template #footer><el-button data-testid="admin-scope-tools-cancel" @click="scopeToolsDialog = false">取消</el-button><el-button type="primary" data-testid="admin-scope-tools-save" @click="saveScopeTools">保存配置</el-button></template>
</el-dialog>
<el-dialog :model-value="!!issuedPlaintext" title="Token 签发成功" width="560px" :close-on-click-modal="false" @close="issuedPlaintext = ''">
<el-alert title="明文只展示这一次,请立即复制并妥善保存。" type="warning" :closable="false" show-icon />
<code class="token-secret" data-testid="admin-issued-token">{{ issuedPlaintext }}</code>
<template #footer><el-button type="primary" data-testid="admin-copy-token" @click="copyToken">复制 Token</el-button><el-button data-testid="admin-close-token" @click="issuedPlaintext = ''">我已保存</el-button></template>
</el-dialog>
</template>
+26
View File
@@ -0,0 +1,26 @@
let csrfToken = ''
export function setCsrf(value) {
csrfToken = value || ''
}
export async function api(path, options = {}) {
const method = options.method || 'GET'
const response = await fetch(`/api/admin${path}`, {
credentials: 'same-origin',
...options,
headers: {
...(options.body ? { 'Content-Type': 'application/json' } : {}),
...(!['GET', 'HEAD'].includes(method) && csrfToken ? { 'X-CSRF-Token': csrfToken } : {}),
...options.headers,
},
})
if (!response.ok) {
let message = `请求失败(${response.status})`
try { message = (await response.json()).message || message } catch { /* empty response */ }
const error = new Error(message)
error.status = response.status
throw error
}
return response.status === 204 ? null : response.json()
}
+7
View File
@@ -0,0 +1,7 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import './style.css'
import App from './App.vue'
createApp(App).use(ElementPlus).mount('#app')
+62
View File
@@ -0,0 +1,62 @@
:root { font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Microsoft YaHei", Arial, sans-serif; color: #303133; background: #f0f2f5; font-synthesis: none; }
* { box-sizing: border-box; }
body { margin: 0; min-width: 1100px; background: #f0f2f5; }
button, input { font: inherit; }
.login-page { min-height: 100vh; display: grid; place-items: center; background: linear-gradient(135deg, #142f4e, #409eff); }
.login-card { width: 390px; padding: 42px; border-radius: 10px; background: white; box-shadow: 0 20px 50px rgb(0 21 41 / 25%); }
.brand-mark { width: 48px; height: 48px; margin: 0 auto 14px; display: grid; place-items: center; color: white; font-size: 24px; font-weight: 700; background: #409eff; border-radius: 12px; }
.login-card h1 { margin: 0; text-align: center; font-size: 22px; }
.login-card > p { margin: 7px 0 28px; text-align: center; color: #909399; }
.login-card .el-input { margin-bottom: 16px; }
.login-card .el-button { width: 100%; margin-top: 8px; }
.field-label { display: block; margin-bottom: 7px; color: #606266; font-size: 13px; }
.shell { min-height: 100vh; display: flex; }
.sidebar { position: fixed; inset: 0 auto 0 0; width: 220px; color: white; background: #001529; }
.logo { height: 60px; display: flex; align-items: center; justify-content: center; gap: 9px; font-weight: 650; border-bottom: 1px solid #1f3a5f; }
.logo span { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 7px; background: #409eff; }
.menu-item { width: 100%; padding: 14px 24px; display: flex; gap: 10px; border: 0; color: #a6adb4; background: transparent; cursor: pointer; text-align: left; }
.menu-item:hover { color: white; }
.menu-item.active { color: white; background: #409eff; }
.main { width: calc(100% - 220px); margin-left: 220px; }
.header { height: 60px; padding: 0 24px; display: flex; align-items: center; justify-content: space-between; background: white; box-shadow: 0 1px 4px rgb(0 21 41 / 8%); }
.header > div:first-child { display: flex; align-items: center; gap: 12px; }
.header-kicker { padding-right: 12px; color: #909399; border-right: 1px solid #dcdfe6; }
.admin-user { display: flex; align-items: center; gap: 8px; color: #606266; }
.avatar { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 50%; color: #409eff; background: #ecf5ff; }
.content { padding: 20px; }
.card { margin-bottom: 16px; padding: 20px; border-radius: 5px; background: white; box-shadow: 0 1px 2px rgb(0 0 0 / 4%); }
.card-title { min-height: 32px; margin-bottom: 14px; display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: 15px; font-weight: 650; }
.card-title small { margin-left: 5px; color: #909399; font-size: 12px; font-weight: 400; }
.toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.toolbar .el-input { width: 230px; }
.toolbar .el-select { width: 160px; }
.toolbar.compact { flex-wrap: nowrap; }
.toolbar.compact .el-input { width: auto; flex: 1; }
.token-layout { display: grid; grid-template-columns: 340px minmax(680px, 1fr); gap: 16px; }
.token-card { min-width: 0; }
.user-list { max-height: 370px; overflow: auto; border-top: 1px solid #ebeef5; }
.user-row { width: 100%; padding: 12px 9px; display: flex; align-items: center; justify-content: space-between; border: 0; border-bottom: 1px solid #ebeef5; color: #303133; background: white; cursor: pointer; text-align: left; }
.user-row:hover { background: #f5f7fa; }
.user-row.selected { background: #ecf5ff; }
.user-row span:first-child { display: flex; flex-direction: column; gap: 4px; }
.user-row small { color: #909399; }
.permission-card dl { margin: 0; border-top: 1px solid #ebeef5; }
.permission-card dl > div { display: grid; grid-template-columns: 92px 1fr; border-bottom: 1px solid #ebeef5; }
.permission-card dt, .permission-card dd { margin: 0; padding: 10px 6px; }
.permission-card dt { color: #909399; }
.hint { margin: 12px 0 0; color: #909399; font-size: 12px; line-height: 1.6; }
code { font-family: Menlo, Consolas, monospace; font-size: 12px; }
.el-table .el-tag + .el-tag { margin-left: 5px; }
.danger-text { color: #f56c6c; font-size: 12px; }
.audit-toolbar .el-input { width: 150px; }
.audit-toolbar .el-select { width: 140px; }
.audit-toolbar .el-date-editor { width: 150px; }
.tool-groups { display: block; max-height: 520px; overflow: auto; }
.tool-group { margin-bottom: 12px; border: 1px solid #ebeef5; border-radius: 4px; }
.tool-group h3 { margin: 0; padding: 9px 12px; font-size: 14px; background: #fafafa; border-bottom: 1px solid #ebeef5; }
.tool-group .el-checkbox { width: 100%; height: auto; margin: 0; padding: 9px 12px; align-items: flex-start; }
.tool-group .el-checkbox + .el-checkbox { border-top: 1px solid #f2f3f5; }
.tool-group .el-checkbox__label { display: flex; flex-direction: column; white-space: normal; }
.tool-group small { margin-top: 3px; color: #909399; }
.token-secret { display: block; margin-top: 14px; padding: 14px; word-break: break-all; border-radius: 4px; background: #f4f4f5; font-size: 13px; }
@media (max-width: 1200px) { .token-layout { grid-template-columns: 300px minmax(640px, 1fr); } }
@@ -0,0 +1,19 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { api, setCsrf } from '../src/api.js'
test('mutation sends the session CSRF token and credentials', async () => {
let request
globalThis.fetch = async (url, options) => {
request = { url, options }
return new Response(null, { status: 204 })
}
setCsrf('csrf-test')
await api('/tools/1/status', { method: 'PATCH', body: '{"status":0}' })
assert.equal(request.url, '/api/admin/tools/1/status')
assert.equal(request.options.credentials, 'same-origin')
assert.equal(request.options.headers['X-CSRF-Token'], 'csrf-test')
assert.equal(request.options.headers['Content-Type'], 'application/json')
})
@@ -0,0 +1,26 @@
import { readFile } from 'node:fs/promises'
import test from 'node:test'
import assert from 'node:assert/strict'
test('prototype interactions expose stable test ids', async () => {
const source = await readFile(new URL('../src/App.vue', import.meta.url), 'utf8')
const required = [
'admin-input-account',
'admin-input-password',
'admin-btn-login',
'admin-menu-tokens',
'admin-menu-tools',
'admin-menu-scopes',
'admin-menu-audit',
'admin-input-user-search',
'admin-btn-issue-token',
'admin-table-tools',
'admin-btn-new-scope',
'admin-table-scopes',
'admin-table-audit',
'admin-btn-logout'
]
for (const testId of required) {
assert.match(source, new RegExp(`data-testid=["']${testId}["']`), testId)
}
})
@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
build: { outDir: 'dist', emptyOutDir: true },
server: { proxy: { '/api': 'http://localhost:8088' } },
})
+38
View File
@@ -99,7 +99,45 @@
<build>
<finalName>xiaoqu-agent-service</finalName>
<resources>
<resource>
<directory>src/main/resources</directory>
</resource>
<resource>
<directory>front/dist</directory>
<targetPath>static</targetPath>
</resource>
</resources>
<plugins>
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<version>1.15.1</version>
<configuration>
<workingDirectory>front</workingDirectory>
<installDirectory>target</installDirectory>
</configuration>
<executions>
<execution>
<id>install-node-and-npm</id>
<goals><goal>install-node-and-npm</goal></goals>
<configuration>
<nodeVersion>v20.20.2</nodeVersion>
<npmVersion>10.8.2</npmVersion>
</configuration>
</execution>
<execution>
<id>npm-ci</id>
<goals><goal>npm</goal></goals>
<configuration><arguments>ci</arguments></configuration>
</execution>
<execution>
<id>npm-build</id>
<goals><goal>npm</goal></goals>
<configuration><arguments>run build</arguments></configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
+1 -1
View File
@@ -115,7 +115,7 @@ CREATE TABLE IF NOT EXISTS audit_log (
CREATE TABLE IF NOT EXISTS admin_user (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
account VARCHAR(64) NOT NULL COMMENT '登录账号',
password_hash CHAR(64) NOT NULL COMMENT '密码 SHA-256 hex',
password_hash VARCHAR(255) NOT NULL COMMENT 'PBKDF2 加盐密码哈希',
name VARCHAR(64) NULL COMMENT '姓名',
status TINYINT NOT NULL DEFAULT 1 COMMENT '1=正常 0=禁用',
create_id BIGINT NULL COMMENT '创建人',
@@ -0,0 +1,4 @@
USE xiaoqu_agent_svc_d;
ALTER TABLE admin_user
MODIFY COLUMN password_hash VARCHAR(255) NOT NULL COMMENT 'PBKDF2 加盐密码哈希';
@@ -0,0 +1,83 @@
package me.iiv.agent.admin;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import me.iiv.agent.entity.svc.AdminUser;
import me.iiv.agent.mapper.svc.AdminUserMapper;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.security.SecureRandom;
@RestController
@RequestMapping("/api/admin/auth")
public class AdminAuthController {
private static final SecureRandom RANDOM = new SecureRandom();
private final AdminUserMapper mapper;
private final AdminPasswordHasher hasher;
public AdminAuthController(AdminUserMapper mapper, AdminPasswordHasher hasher) {
this.mapper = mapper;
this.hasher = hasher;
}
@PostMapping("/login")
public SessionView login(@RequestBody LoginRequest body, HttpServletRequest request) {
if (body == null || body.account() == null || body.password() == null
|| body.account().length() > 64 || body.password().length() > 1024) throw unauthorized();
AdminUser admin = mapper.selectOne(new LambdaQueryWrapper<AdminUser>()
.eq(AdminUser::getAccount, body.account().trim())
.eq(AdminUser::getStatus, 1).eq(AdminUser::getSoftDelete, 0));
if (admin == null || !hasher.matches(body.password(), admin.getPasswordHash())) throw unauthorized();
HttpSession old = request.getSession(false);
if (old != null) old.invalidate();
HttpSession session = request.getSession(true);
session.setAttribute(AdminSessionFilter.ADMIN_SESSION_ID, admin.getId());
session.setAttribute(AdminSessionFilter.ADMIN_SESSION_NAME,
admin.getName() == null ? admin.getAccount() : admin.getName());
String csrf = randomHex(24);
session.setAttribute(AdminSessionFilter.CSRF_SESSION_KEY, csrf);
return new SessionView(admin.getId(), admin.getAccount(),
(String) session.getAttribute(AdminSessionFilter.ADMIN_SESSION_NAME), csrf);
}
@GetMapping("/me")
public SessionView me(HttpSession session) {
Long id = (Long) session.getAttribute(AdminSessionFilter.ADMIN_SESSION_ID);
AdminUser admin = mapper.selectById(id);
if (admin == null || admin.getStatus() == null || admin.getStatus() != 1
|| admin.getSoftDelete() == null || admin.getSoftDelete() != 0) throw unauthorized();
return new SessionView(id, admin.getAccount(),
(String) session.getAttribute(AdminSessionFilter.ADMIN_SESSION_NAME),
(String) session.getAttribute(AdminSessionFilter.CSRF_SESSION_KEY));
}
@PostMapping("/logout")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void logout(HttpSession session) {
session.invalidate();
}
private static ResponseStatusException unauthorized() {
return new ResponseStatusException(HttpStatus.UNAUTHORIZED, "账号或密码错误");
}
static String randomHex(int bytes) {
byte[] value = new byte[bytes];
RANDOM.nextBytes(value);
StringBuilder result = new StringBuilder(bytes * 2);
for (byte b : value) result.append(String.format("%02x", b));
return result.toString();
}
public record LoginRequest(String account, String password) {}
public record SessionView(Long id, String account, String name, String csrfToken) {}
}
@@ -0,0 +1,47 @@
package me.iiv.agent.admin;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import me.iiv.agent.entity.svc.AdminUser;
import me.iiv.agent.mapper.svc.AdminUserMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component
public class AdminBootstrap implements ApplicationRunner {
private final AdminUserMapper mapper;
private final AdminPasswordHasher hasher;
private final String account;
private final String password;
private final String name;
public AdminBootstrap(AdminUserMapper mapper, AdminPasswordHasher hasher,
@Value("${app.admin.bootstrap.account:}") String account,
@Value("${app.admin.bootstrap.password:}") String password,
@Value("${app.admin.bootstrap.name:系统管理员}") String name) {
this.mapper = mapper;
this.hasher = hasher;
this.account = account;
this.password = password;
this.name = name;
}
@Override
public void run(ApplicationArguments args) {
if (account.isBlank() || password.isBlank()) return;
if (account.length() > 64 || password.length() < 12) {
throw new IllegalStateException("管理员账号最多 64 字,初始密码至少 12 字");
}
Long count = mapper.selectCount(new LambdaQueryWrapper<AdminUser>().eq(AdminUser::getSoftDelete, 0));
if (count != 0) return;
AdminUser admin = new AdminUser();
admin.setAccount(account.trim());
admin.setPasswordHash(hasher.hash(password));
admin.setName(name);
admin.setStatus((byte) 1);
admin.setSoftDelete((byte) 0);
mapper.insert(admin);
}
}
@@ -0,0 +1,391 @@
package me.iiv.agent.admin;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import jakarta.servlet.http.HttpSession;
import me.iiv.agent.entity.m0.Agent;
import me.iiv.agent.entity.m0.Authority;
import me.iiv.agent.entity.m0.User;
import me.iiv.agent.entity.svc.AgentApiKey;
import me.iiv.agent.entity.svc.AgentScope;
import me.iiv.agent.entity.svc.AuditLog;
import me.iiv.agent.entity.svc.ScopeTool;
import me.iiv.agent.entity.svc.TokenScope;
import me.iiv.agent.entity.svc.Tool;
import me.iiv.agent.mapper.m0.AgentMapper;
import me.iiv.agent.mapper.m0.AuthorityMapper;
import me.iiv.agent.mapper.m0.UserMapper;
import me.iiv.agent.mapper.svc.AgentApiKeyMapper;
import me.iiv.agent.mapper.svc.AgentScopeMapper;
import me.iiv.agent.mapper.svc.AuditLogMapper;
import me.iiv.agent.mapper.svc.ScopeToolMapper;
import me.iiv.agent.mapper.svc.ToolMapper;
import me.iiv.agent.mapper.svc.TokenScopeMapper;
import me.iiv.agent.permission.PermissionService;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpStatus;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/admin")
public class AdminConsoleController {
private final UserMapper userMapper;
private final AuthorityMapper authorityMapper;
private final AgentMapper agentMapper;
private final AgentApiKeyMapper tokenMapper;
private final AgentScopeMapper scopeMapper;
private final ToolMapper toolMapper;
private final ScopeToolMapper scopeToolMapper;
private final TokenScopeMapper tokenScopeMapper;
private final AuditLogMapper auditMapper;
private final PermissionService permissionService;
private final AdminTokenService tokenService;
public AdminConsoleController(UserMapper userMapper, AuthorityMapper authorityMapper, AgentMapper agentMapper,
AgentApiKeyMapper tokenMapper, AgentScopeMapper scopeMapper, ToolMapper toolMapper,
ScopeToolMapper scopeToolMapper, AuditLogMapper auditMapper,
TokenScopeMapper tokenScopeMapper, PermissionService permissionService,
AdminTokenService tokenService) {
this.userMapper = userMapper;
this.authorityMapper = authorityMapper;
this.agentMapper = agentMapper;
this.tokenMapper = tokenMapper;
this.scopeMapper = scopeMapper;
this.toolMapper = toolMapper;
this.scopeToolMapper = scopeToolMapper;
this.tokenScopeMapper = tokenScopeMapper;
this.auditMapper = auditMapper;
this.permissionService = permissionService;
this.tokenService = tokenService;
}
@GetMapping("/users")
public List<UserView> users(@RequestParam(defaultValue = "") String keyword) {
if (keyword.length() > 100) bad("搜索关键词最多 100 字");
LambdaQueryWrapper<User> query = new LambdaQueryWrapper<User>().eq(User::getSoftDelete, 0);
if (!keyword.isBlank()) query.and(w -> w.like(User::getName, keyword.trim())
.or().like(User::getMobile, keyword.trim()));
List<User> users = userMapper.selectList(query.orderByDesc(User::getId).last("LIMIT 100"));
Set<Long> ids = users.stream().map(User::getId).collect(Collectors.toSet());
Map<Long, Long> counts = ids.isEmpty() ? Map.of() : tokenMapper.selectList(
new LambdaQueryWrapper<AgentApiKey>().in(AgentApiKey::getUserId, ids)
.eq(AgentApiKey::getSoftDelete, 0))
.stream().collect(Collectors.groupingBy(AgentApiKey::getUserId, Collectors.counting()));
return users.stream().map(user -> new UserView(user.getId(), user.getName(), maskMobile(user.getMobile()),
counts.getOrDefault(user.getId(), 0L))).toList();
}
@GetMapping("/users/{id}/permission")
public PermissionView permission(@PathVariable Long id) {
User user = userMapper.selectById(id);
if (user == null || Integer.valueOf(1).equals(user.getSoftDelete())) bad("人员不存在");
Authority authority = user.getAuthorityId() == null ? null : authorityMapper.selectById(user.getAuthorityId());
Agent agent = user.getAgentId() == null ? null : agentMapper.selectById(user.getAgentId());
String range;
try {
List<Long> ids = permissionService.visibleAgentIds(user);
range = ids == null ? "全部数据" : "自己、直属下级及代管代理(" + ids.size() + " 家)";
} catch (IllegalStateException e) {
range = "权限配置异常:" + e.getMessage();
}
return new PermissionView(authority == null ? "未配置" : authority.getName(),
authority == null ? null : authority.getIsagent(), agent == null ? "未关联" : agent.getName(),
user.getEnterpriseType(), range);
}
@GetMapping("/users/{id}/tokens")
public List<AdminTokenService.TokenView> tokens(@PathVariable Long id) {
return tokenService.list(id);
}
@PostMapping("/users/{id}/tokens")
public AdminTokenService.IssuedToken issue(@PathVariable Long id, @RequestBody TokenRequest body,
HttpSession session) {
if (body == null) bad("请求内容不能为空");
return tokenService.issue(id, body.memo(), body.scopeIds(), body.expireTime(), adminId(session));
}
@PutMapping("/tokens/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void updateToken(@PathVariable Long id, @RequestBody TokenRequest body, HttpSession session) {
if (body == null) bad("请求内容不能为空");
tokenService.update(id, body.memo(), body.scopeIds(), body.expireTime(), adminId(session));
}
@PatchMapping("/tokens/{id}/status")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void tokenStatus(@PathVariable Long id, @RequestBody StatusRequest body, HttpSession session) {
if (body == null) bad("请求内容不能为空");
tokenService.status(id, body.status(), adminId(session));
}
@DeleteMapping("/tokens/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteToken(@PathVariable Long id, HttpSession session) {
tokenService.delete(id, adminId(session));
}
@GetMapping("/tools")
public List<ToolView> tools(@RequestParam(defaultValue = "") String category,
@RequestParam(defaultValue = "") String kind,
@RequestParam(defaultValue = "") String keyword) {
LambdaQueryWrapper<Tool> query = new LambdaQueryWrapper<Tool>().eq(Tool::getSoftDelete, 0);
if (!category.isBlank()) query.eq(Tool::getCategory, category);
if (!kind.isBlank()) query.eq(Tool::getKind, kind);
if (!keyword.isBlank()) query.and(w -> w.like(Tool::getName, keyword.trim())
.or().like(Tool::getDescription, keyword.trim()));
List<Tool> tools = toolMapper.selectList(query.orderByAsc(Tool::getCategory, Tool::getName));
Map<Long, AgentScope> scopes = scopeMapper.selectList(new LambdaQueryWrapper<AgentScope>()
.eq(AgentScope::getSoftDelete, 0)).stream()
.collect(Collectors.toMap(AgentScope::getId, Function.identity()));
Map<Long, List<String>> scopeNames = scopeToolMapper.selectList(new LambdaQueryWrapper<ScopeTool>()
.eq(ScopeTool::getSoftDelete, 0)).stream()
.filter(r -> scopes.containsKey(r.getScopeId()))
.collect(Collectors.groupingBy(ScopeTool::getToolId,
Collectors.mapping(r -> scopes.get(r.getScopeId()).getName(), Collectors.toList())));
return tools.stream().map(tool -> new ToolView(tool.getId(), tool.getName(), tool.getCategory(),
tool.getDescription(), tool.getKind(), tool.getStatus(), tool.getModifyTime(),
scopeNames.getOrDefault(tool.getId(), List.of()))).toList();
}
@PatchMapping("/tools/{id}/status")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void toolStatus(@PathVariable Long id, @RequestBody StatusRequest body, HttpSession session) {
if (body == null) bad("请求内容不能为空");
validateStatus(body.status());
Tool tool = toolMapper.selectById(id);
if (tool == null || !Byte.valueOf((byte) 0).equals(tool.getSoftDelete())) bad("工具不存在");
tool.setStatus(body.status());
tool.setModifyId(adminId(session));
toolMapper.updateById(tool);
}
@GetMapping("/scopes")
public List<ScopeView> scopes() {
List<AgentScope> scopes = scopeMapper.selectList(new LambdaQueryWrapper<AgentScope>()
.eq(AgentScope::getSoftDelete, 0).orderByDesc(AgentScope::getId));
Map<Long, Long> counts = scopeToolMapper.selectList(new LambdaQueryWrapper<ScopeTool>()
.eq(ScopeTool::getSoftDelete, 0)).stream()
.collect(Collectors.groupingBy(ScopeTool::getScopeId, Collectors.counting()));
return scopes.stream().map(scope -> new ScopeView(scope.getId(), scope.getName(), scope.getCode(),
scope.getMemo(), scope.getStatus(), scope.getModifyTime(), counts.getOrDefault(scope.getId(), 0L))).toList();
}
@PostMapping("/scopes")
@Transactional(transactionManager = "svcTransactionManager")
public ScopeView createScope(@RequestBody ScopeRequest body, HttpSession session) {
validateScope(body, null);
AgentScope scope = new AgentScope();
apply(scope, body, adminId(session));
scope.setCreateId(adminId(session));
scope.setStatus((byte) 1);
scope.setSoftDelete((byte) 0);
scopeMapper.insert(scope);
return new ScopeView(scope.getId(), scope.getName(), scope.getCode(), scope.getMemo(),
scope.getStatus(), scope.getModifyTime(), 0);
}
@PutMapping("/scopes/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void updateScope(@PathVariable Long id, @RequestBody ScopeRequest body, HttpSession session) {
AgentScope scope = activeScope(id);
validateScope(body, id);
apply(scope, body, adminId(session));
scopeMapper.updateById(scope);
}
@PatchMapping("/scopes/{id}/status")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void scopeStatus(@PathVariable Long id, @RequestBody StatusRequest body, HttpSession session) {
if (body == null) bad("请求内容不能为空");
validateStatus(body.status());
AgentScope scope = activeScope(id);
scope.setStatus(body.status());
scope.setModifyId(adminId(session));
scopeMapper.updateById(scope);
}
@DeleteMapping("/scopes/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Transactional(transactionManager = "svcTransactionManager")
public void deleteScope(@PathVariable Long id, HttpSession session) {
AgentScope scope = activeScope(id);
Long tokenCount = tokenScopeMapper.selectCount(new LambdaQueryWrapper<TokenScope>()
.eq(TokenScope::getScopeId, id).eq(TokenScope::getSoftDelete, 0));
if (tokenCount != null && tokenCount > 0) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "该作用域仍被 Token 使用,请先调整 Token");
}
scope.setStatus((byte) 0);
scope.setSoftDelete((byte) 1);
scope.setModifyId(adminId(session));
scopeMapper.updateById(scope);
for (ScopeTool relation : scopeToolMapper.selectList(new LambdaQueryWrapper<ScopeTool>()
.eq(ScopeTool::getScopeId, id).eq(ScopeTool::getSoftDelete, 0))) {
relation.setSoftDelete((byte) 1);
relation.setModifyId(adminId(session));
scopeToolMapper.updateById(relation);
}
}
@GetMapping("/scopes/{id}/tools")
public List<Long> scopeTools(@PathVariable Long id) {
activeScope(id);
return scopeToolMapper.selectList(new LambdaQueryWrapper<ScopeTool>()
.eq(ScopeTool::getScopeId, id).eq(ScopeTool::getSoftDelete, 0))
.stream().map(ScopeTool::getToolId).toList();
}
@PutMapping("/scopes/{id}/tools")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Transactional(transactionManager = "svcTransactionManager")
public void updateScopeTools(@PathVariable Long id, @RequestBody ToolIdsRequest body, HttpSession session) {
activeScope(id);
if (body == null) bad("请求内容不能为空");
List<Long> toolIds = body.toolIds() == null ? List.of() : body.toolIds().stream().distinct().toList();
if (!toolIds.isEmpty() && toolMapper.selectList(new LambdaQueryWrapper<Tool>()
.in(Tool::getId, toolIds).eq(Tool::getSoftDelete, 0)).size() != toolIds.size()) bad("工具不存在");
Long adminId = adminId(session);
List<ScopeTool> old = scopeToolMapper.selectList(new LambdaQueryWrapper<ScopeTool>().eq(ScopeTool::getScopeId, id));
for (ScopeTool relation : old) {
relation.setSoftDelete((byte) 1);
relation.setModifyId(adminId);
scopeToolMapper.updateById(relation);
}
for (Long toolId : toolIds) {
ScopeTool relation = old.stream().filter(r -> Objects.equals(r.getToolId(), toolId)).findFirst().orElse(null);
if (relation == null) {
relation = new ScopeTool();
relation.setScopeId(id);
relation.setToolId(toolId);
relation.setCreateId(adminId);
}
relation.setSoftDelete((byte) 0);
relation.setModifyId(adminId);
if (relation.getId() == null) scopeToolMapper.insert(relation); else scopeToolMapper.updateById(relation);
}
}
@GetMapping("/audit")
public List<AuditView> audit(@RequestParam(defaultValue = "") String person,
@RequestParam(defaultValue = "") String tokenPrefix,
@RequestParam(defaultValue = "") String tool,
@RequestParam(defaultValue = "") String result,
@RequestParam(defaultValue = "") String client,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
LambdaQueryWrapper<AuditLog> query = new LambdaQueryWrapper<AuditLog>().eq(AuditLog::getSoftDelete, 0);
if (person.length() > 100 || tokenPrefix.length() > 16 || tool.length() > 128) bad("审计筛选条件过长");
if (!person.isBlank()) {
List<Long> ids = userMapper.selectList(new LambdaQueryWrapper<User>().eq(User::getSoftDelete, 0)
.and(w -> w.like(User::getName, person.trim()).or().like(User::getMobile, person.trim()))
.select(User::getId).last("LIMIT 1000"))
.stream().map(User::getId).toList();
if (ids.isEmpty()) return List.of();
query.in(AuditLog::getUserId, ids);
}
if (!tokenPrefix.isBlank()) {
List<Long> ids = tokenMapper.selectList(new LambdaQueryWrapper<AgentApiKey>()
.likeRight(AgentApiKey::getKeyPrefix, tokenPrefix.trim()).select(AgentApiKey::getId)
.last("LIMIT 1000"))
.stream().map(AgentApiKey::getId).toList();
if (ids.isEmpty()) return List.of();
query.in(AuditLog::getTokenId, ids);
}
if (!tool.isBlank()) query.like(AuditLog::getToolName, tool.trim());
if (!result.isBlank()) query.eq(AuditLog::getResultStatus, result);
if (!client.isBlank()) query.eq(AuditLog::getClientType, client);
ZoneId zone = ZoneId.systemDefault();
if (from != null) query.ge(AuditLog::getCreateTime, Date.from(from.atStartOfDay(zone).toInstant()));
if (to != null) query.lt(AuditLog::getCreateTime, Date.from(to.plusDays(1).atStartOfDay(zone).toInstant()));
List<AuditLog> logs = auditMapper.selectList(query.orderByDesc(AuditLog::getCreateTime).last("LIMIT 200"));
Set<Long> userIds = logs.stream().map(AuditLog::getUserId).filter(Objects::nonNull).collect(Collectors.toSet());
Set<Long> tokenIds = logs.stream().map(AuditLog::getTokenId).filter(Objects::nonNull).collect(Collectors.toSet());
Map<Long, String> names = userIds.isEmpty() ? Map.of() : userMapper.selectBatchIds(userIds).stream()
.collect(Collectors.toMap(User::getId, user -> user.getName() == null ? "未命名" : user.getName()));
Map<Long, String> prefixes = tokenIds.isEmpty() ? Map.of() : tokenMapper.selectBatchIds(tokenIds).stream()
.collect(Collectors.toMap(AgentApiKey::getId, AgentApiKey::getKeyPrefix));
return logs.stream().map(log -> new AuditView(log.getId(), log.getCreateTime(), log.getUserId(),
log.getUserId() == null ? "未知用户" : names.getOrDefault(log.getUserId(), "未知用户"),
log.getTokenId() == null ? null : prefixes.get(log.getTokenId()), log.getToolName(),
log.getParamsDigest(), log.getResultStatus(), log.getResultMessage(), log.getCostMs(),
log.getClientIp(), log.getClientType())).toList();
}
private void validateScope(ScopeRequest body, Long currentId) {
if (body == null) bad("请求内容不能为空");
if (body.name() == null || body.name().isBlank() || body.name().length() > 64) bad("作用域名称不能为空且最多 64 字");
if (body.code() == null || !body.code().matches("[a-z][a-z0-9_-]{0,63}")) bad("作用域编码格式无效");
if (body.memo() != null && body.memo().length() > 255) bad("作用域备注最多 255 字");
AgentScope duplicate = scopeMapper.selectOne(new LambdaQueryWrapper<AgentScope>()
.eq(AgentScope::getCode, body.code()).eq(AgentScope::getSoftDelete, 0)
.ne(currentId != null, AgentScope::getId, currentId));
if (duplicate != null) throw new ResponseStatusException(HttpStatus.CONFLICT, "作用域编码已存在");
}
private static void apply(AgentScope scope, ScopeRequest body, Long adminId) {
scope.setName(body.name().trim());
scope.setCode(body.code().trim());
scope.setMemo(body.memo() == null ? null : body.memo().trim());
scope.setModifyId(adminId);
}
private AgentScope activeScope(Long id) {
AgentScope scope = scopeMapper.selectById(id);
if (scope == null || !Byte.valueOf((byte) 0).equals(scope.getSoftDelete())) bad("作用域不存在");
return scope;
}
private static Long adminId(HttpSession session) {
return (Long) session.getAttribute(AdminSessionFilter.ADMIN_SESSION_ID);
}
private static void validateStatus(byte status) {
if (status != 0 && status != 1) bad("状态无效");
}
private static void bad(String message) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
}
private static String maskMobile(String mobile) {
if (mobile == null || mobile.length() < 7) return mobile;
return mobile.substring(0, 3) + "****" + mobile.substring(mobile.length() - 4);
}
public record UserView(Long id, String name, String mobile, long tokenCount) {}
public record PermissionView(String authorityName, Integer isAgent, String agentName,
Integer enterpriseType, String visibleRange) {}
public record TokenRequest(String memo, List<Long> scopeIds, Date expireTime) {}
public record StatusRequest(byte status) {}
public record ToolView(Long id, String name, String category, String description, String kind,
Byte status, Date modifyTime, List<String> scopeNames) {}
public record ScopeRequest(String name, String code, String memo) {}
public record ScopeView(Long id, String name, String code, String memo, Byte status,
Date modifyTime, long toolCount) {}
public record ToolIdsRequest(List<Long> toolIds) {}
public record AuditView(Long id, Date createTime, Long userId, String userName, String tokenPrefix,
String toolName, String paramsDigest, String resultStatus, String resultMessage,
Long costMs, String clientIp, String clientType) {}
}
@@ -0,0 +1,47 @@
package me.iiv.agent.admin;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
@Component
public class AdminPasswordHasher {
private static final int ITERATIONS = 210_000;
private static final SecureRandom RANDOM = new SecureRandom();
public String hash(String password) {
byte[] salt = new byte[16];
RANDOM.nextBytes(salt);
return "pbkdf2$" + ITERATIONS + "$" + Base64.getEncoder().encodeToString(salt)
+ "$" + Base64.getEncoder().encodeToString(derive(password, salt, ITERATIONS));
}
public boolean matches(String password, String encoded) {
if (password == null || encoded == null) return false;
try {
String[] parts = encoded.split("\\$", -1);
if (parts.length != 4 || !"pbkdf2".equals(parts[0])) return false;
int iterations = Integer.parseInt(parts[1]);
if (iterations < 10_000 || iterations > 1_000_000) return false;
byte[] salt = Base64.getDecoder().decode(parts[2]);
byte[] expected = Base64.getDecoder().decode(parts[3]);
return MessageDigest.isEqual(expected, derive(password, salt, iterations));
} catch (RuntimeException e) {
return false;
}
}
private static byte[] derive(String password, byte[] salt, int iterations) {
try {
PBEKeySpec spec = new PBEKeySpec(password.toCharArray(), salt, iterations, 256);
return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded();
} catch (Exception e) {
throw new IllegalStateException("密码哈希失败", e);
}
}
}
@@ -0,0 +1,60 @@
package me.iiv.agent.admin;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@Component
public class AdminSessionFilter extends OncePerRequestFilter {
static final String ADMIN_SESSION_ID = "adminUserId";
static final String ADMIN_SESSION_NAME = "adminName";
static final String CSRF_SESSION_KEY = "adminCsrf";
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().startsWith("/api/admin/");
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String uri = request.getRequestURI();
response.setHeader("Cache-Control", "no-store");
if ("/api/admin/auth/login".equals(uri)) {
chain.doFilter(request, response);
return;
}
HttpSession session = request.getSession(false);
if (session == null || session.getAttribute(ADMIN_SESSION_ID) == null) {
error(response, 401, "请先登录");
return;
}
Object csrf = session.getAttribute(CSRF_SESSION_KEY);
if (isMutation(request.getMethod())
&& (csrf == null || !csrf.equals(request.getHeader("X-CSRF-Token")))) {
error(response, 403, "请求校验失败,请刷新后重试");
return;
}
chain.doFilter(request, response);
}
private static boolean isMutation(String method) {
return "POST".equals(method) || "PUT".equals(method)
|| "PATCH".equals(method) || "DELETE".equals(method);
}
private static void error(HttpServletResponse response, int status, String message) throws IOException {
response.setStatus(status);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType("application/json");
response.getWriter().write("{\"message\":\"" + message + "\"}");
}
}
@@ -0,0 +1,159 @@
package me.iiv.agent.admin;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import me.iiv.agent.entity.m0.User;
import me.iiv.agent.entity.svc.AgentApiKey;
import me.iiv.agent.entity.svc.AgentScope;
import me.iiv.agent.entity.svc.TokenScope;
import me.iiv.agent.mapper.m0.UserMapper;
import me.iiv.agent.mapper.svc.AgentApiKeyMapper;
import me.iiv.agent.mapper.svc.AgentScopeMapper;
import me.iiv.agent.mapper.svc.TokenScopeMapper;
import me.iiv.agent.security.AuthService;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.Date;
import java.util.List;
@Service
public class AdminTokenService {
private final UserMapper userMapper;
private final AgentApiKeyMapper tokenMapper;
private final TokenScopeMapper relationMapper;
private final AgentScopeMapper scopeMapper;
public AdminTokenService(UserMapper userMapper, AgentApiKeyMapper tokenMapper,
TokenScopeMapper relationMapper, AgentScopeMapper scopeMapper) {
this.userMapper = userMapper;
this.tokenMapper = tokenMapper;
this.relationMapper = relationMapper;
this.scopeMapper = scopeMapper;
}
@Transactional(transactionManager = "svcTransactionManager")
public IssuedToken issue(Long userId, String memo, List<Long> scopeIds, Date expireTime, Long adminId) {
User user = userMapper.selectById(userId);
if (user == null || Integer.valueOf(1).equals(user.getSoftDelete())) bad("人员不存在");
validateMemo(memo);
validateScopes(scopeIds);
String plaintext = "xqak_" + AdminAuthController.randomHex(24);
AgentApiKey token = new AgentApiKey();
token.setUserId(userId);
token.setKeyPrefix(plaintext.substring(0, 13));
token.setKeyHash(AuthService.sha256Hex(plaintext));
token.setMemo(trim(memo, 255));
token.setStatus((byte) 1);
token.setExpireTime(expireTime);
token.setCreateId(adminId);
token.setModifyId(adminId);
token.setSoftDelete((byte) 0);
tokenMapper.insert(token);
replaceScopes(token.getId(), scopeIds, adminId);
return new IssuedToken(plaintext, view(token));
}
@Transactional(transactionManager = "svcTransactionManager")
public void update(Long tokenId, String memo, List<Long> scopeIds, Date expireTime, Long adminId) {
AgentApiKey current = activeToken(tokenId);
validateMemo(memo);
validateScopes(scopeIds);
current.setMemo(trim(memo, 255));
current.setExpireTime(expireTime);
current.setModifyId(adminId);
tokenMapper.updateById(current);
replaceScopes(tokenId, scopeIds, adminId);
}
public void status(Long tokenId, byte status, Long adminId) {
if (status != 0 && status != 1) bad("状态无效");
AgentApiKey token = activeToken(tokenId);
token.setStatus(status);
token.setModifyId(adminId);
tokenMapper.updateById(token);
}
public void delete(Long tokenId, Long adminId) {
AgentApiKey token = activeToken(tokenId);
token.setStatus((byte) 0);
token.setSoftDelete((byte) 1);
token.setModifyId(adminId);
tokenMapper.updateById(token);
}
public List<TokenView> list(Long userId) {
List<AgentApiKey> tokens = tokenMapper.selectList(new LambdaQueryWrapper<AgentApiKey>()
.eq(AgentApiKey::getUserId, userId).eq(AgentApiKey::getSoftDelete, 0)
.orderByDesc(AgentApiKey::getCreateTime));
return tokens.stream().map(this::view).toList();
}
private TokenView view(AgentApiKey token) {
List<Long> scopeIds = relationMapper.selectList(new LambdaQueryWrapper<TokenScope>()
.eq(TokenScope::getTokenId, token.getId()).eq(TokenScope::getSoftDelete, 0))
.stream().map(TokenScope::getScopeId).toList();
List<String> scopeNames = scopeIds.isEmpty() ? List.of() : scopeMapper.selectList(
new LambdaQueryWrapper<AgentScope>().in(AgentScope::getId, scopeIds))
.stream().map(AgentScope::getName).toList();
return new TokenView(token.getId(), token.getKeyPrefix(), token.getMemo(), token.getStatus(),
token.getExpireTime(), token.getLastUsedTime(), scopeIds, scopeNames);
}
private void replaceScopes(Long tokenId, List<Long> scopeIds, Long adminId) {
List<TokenScope> old = relationMapper.selectList(new LambdaQueryWrapper<TokenScope>()
.eq(TokenScope::getTokenId, tokenId));
for (TokenScope relation : old) {
relation.setSoftDelete((byte) 1);
relation.setModifyId(adminId);
relationMapper.updateById(relation);
}
for (Long scopeId : scopeIds) {
TokenScope relation = old.stream().filter(r -> scopeId.equals(r.getScopeId())).findFirst().orElse(null);
if (relation == null) {
relation = new TokenScope();
relation.setTokenId(tokenId);
relation.setScopeId(scopeId);
relation.setCreateId(adminId);
}
relation.setSoftDelete((byte) 0);
relation.setModifyId(adminId);
if (relation.getId() == null) relationMapper.insert(relation); else relationMapper.updateById(relation);
}
}
private void validateScopes(List<Long> scopeIds) {
if (scopeIds == null || scopeIds.isEmpty()) bad("至少选择一个作用域");
if (scopeIds.stream().distinct().count() != scopeIds.size()) bad("作用域不能重复");
long count = scopeMapper.selectList(new LambdaQueryWrapper<AgentScope>()
.in(AgentScope::getId, scopeIds).eq(AgentScope::getStatus, 1)
.eq(AgentScope::getSoftDelete, 0)).size();
if (count != scopeIds.stream().distinct().count()) bad("作用域不存在或已停用");
}
private static void validateMemo(String memo) {
if (memo != null && memo.length() > 255) bad("用途备注最多 255 字");
}
private AgentApiKey activeToken(Long id) {
AgentApiKey token = tokenMapper.selectById(id);
if (token == null || token.getSoftDelete() == null || token.getSoftDelete() != 0) bad("Token 不存在");
return token;
}
private static String trim(String value, int max) {
if (value == null) return null;
String result = value.trim();
return result.length() <= max ? result : result.substring(0, max);
}
private static void bad(String message) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
}
public record TokenView(Long id, String keyPrefix, String memo, Byte status, Date expireTime,
Date lastUsedTime, List<Long> scopeIds, List<String> scopeNames) {}
public record IssuedToken(String plaintext, TokenView token) {}
}
@@ -10,6 +10,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;
import javax.sql.DataSource;
@@ -35,4 +37,9 @@ public class SvcDataSourceConfig {
bean.setConfiguration(configuration);
return bean.getObject();
}
@Bean(name = "svcTransactionManager")
public PlatformTransactionManager svcTransactionManager(@Qualifier("svcDataSource") DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
}
@@ -38,7 +38,7 @@ public class AdminUser implements Serializable {
private String account;
/**
* 密码 SHA-256 hex
* PBKDF2 加盐密码哈希
*/
private String passwordHash;
@@ -1,5 +1,19 @@
server:
port: 8088
servlet:
session:
timeout: 30m
cookie:
http-only: true
same-site: strict
secure: ${ADMIN_COOKIE_SECURE:false}
app:
admin:
bootstrap:
account: ${ADMIN_BOOTSTRAP_ACCOUNT:}
password: ${ADMIN_BOOTSTRAP_PASSWORD:}
name: ${ADMIN_BOOTSTRAP_NAME:系统管理员}
spring:
application:
@@ -0,0 +1,23 @@
package me.iiv.agent.admin;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class AdminPasswordHasherTest {
private final AdminPasswordHasher hasher = new AdminPasswordHasher();
@Test
void hashesAreSaltedAndVerifiable() {
String first = hasher.hash("correct horse battery staple");
String second = hasher.hash("correct horse battery staple");
assertTrue(first.startsWith("pbkdf2$"));
assertNotEquals(first, second);
assertTrue(hasher.matches("correct horse battery staple", first));
assertFalse(hasher.matches("wrong", first));
}
}
@@ -0,0 +1,93 @@
package me.iiv.agent.admin;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.mock.web.MockHttpSession;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
class AdminSessionFilterTest {
private final AdminSessionFilter filter = new AdminSessionFilter();
@Test
void protectedApiRequiresSession() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/admin/tools");
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
filter.doFilter(request, response, chain);
assertEquals(401, response.getStatus());
assertNull(chain.getRequest());
}
@Test
void loginEndpointIsPublic() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/admin/auth/login");
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
filter.doFilter(request, response, chain);
assertSame(request, chain.getRequest());
}
@Test
void authenticatedReadIsAllowed() throws Exception {
MockHttpSession session = authenticatedSession();
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/admin/tools");
request.setSession(session);
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
filter.doFilter(request, response, chain);
assertSame(request, chain.getRequest());
}
@Test
void mutationRequiresMatchingCsrfToken() throws Exception {
MockHttpSession session = authenticatedSession();
session.setAttribute(AdminSessionFilter.CSRF_SESSION_KEY, "csrf-token");
MockHttpServletRequest request = new MockHttpServletRequest("PATCH", "/api/admin/tools/1/status");
request.setSession(session);
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
filter.doFilter(request, response, chain);
assertEquals(403, response.getStatus());
assertNull(chain.getRequest());
request.addHeader("X-CSRF-Token", "csrf-token");
response = new MockHttpServletResponse();
chain = new MockFilterChain();
filter.doFilter(request, response, chain);
assertSame(request, chain.getRequest());
}
@Test
void missingCsrfSessionValueCannotBeForgedAsNullText() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("DELETE", "/api/admin/tokens/1");
request.setSession(authenticatedSession());
request.addHeader("X-CSRF-Token", "null");
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
filter.doFilter(request, response, chain);
assertEquals(403, response.getStatus());
assertNull(chain.getRequest());
}
private static MockHttpSession authenticatedSession() {
MockHttpSession session = new MockHttpSession();
session.setAttribute(AdminSessionFilter.ADMIN_SESSION_ID, 1L);
return session;
}
}
@@ -0,0 +1,74 @@
package me.iiv.agent.admin;
import me.iiv.agent.entity.m0.User;
import me.iiv.agent.entity.svc.AgentApiKey;
import me.iiv.agent.entity.svc.AgentScope;
import me.iiv.agent.mapper.m0.UserMapper;
import me.iiv.agent.mapper.svc.AgentApiKeyMapper;
import me.iiv.agent.mapper.svc.AgentScopeMapper;
import me.iiv.agent.mapper.svc.TokenScopeMapper;
import me.iiv.agent.security.AuthService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AdminTokenServiceTest {
@Mock UserMapper userMapper;
@Mock AgentApiKeyMapper tokenMapper;
@Mock TokenScopeMapper tokenScopeMapper;
@Mock AgentScopeMapper scopeMapper;
@Test
void issueReturnsPlaintextOnceAndStoresOnlyHash() {
User user = new User();
user.setId(7L);
user.setSoftDelete(0);
when(userMapper.selectById(7L)).thenReturn(user);
AgentScope scope = new AgentScope();
scope.setId(3L);
scope.setStatus((byte) 1);
scope.setSoftDelete((byte) 0);
when(scopeMapper.selectList(any())).thenReturn(List.of(scope));
when(tokenMapper.insert(any(AgentApiKey.class))).thenAnswer(invocation -> {
invocation.<AgentApiKey>getArgument(0).setId(11L);
return 1;
});
AdminTokenService service = new AdminTokenService(
userMapper, tokenMapper, tokenScopeMapper, scopeMapper);
AdminTokenService.IssuedToken issued = service.issue(7L, "Kimi", List.of(3L), null, 1L);
ArgumentCaptor<AgentApiKey> captor = ArgumentCaptor.forClass(AgentApiKey.class);
org.mockito.Mockito.verify(tokenMapper).insert(captor.capture());
AgentApiKey stored = captor.getValue();
assertTrue(issued.plaintext().matches("xqak_[0-9a-f]{48}"));
assertEquals(AuthService.sha256Hex(issued.plaintext()), stored.getKeyHash());
assertNotEquals(issued.plaintext(), stored.getKeyHash());
assertEquals(11L, issued.token().id());
}
@Test
void issueRejectsDuplicateScopesBeforeWriting() {
User user = new User();
user.setId(7L);
user.setSoftDelete(0);
when(userMapper.selectById(7L)).thenReturn(user);
AdminTokenService service = new AdminTokenService(userMapper, tokenMapper, tokenScopeMapper, scopeMapper);
assertThrows(RuntimeException.class,
() -> service.issue(7L, "Kimi", List.of(3L, 3L), null, 1L));
}
}