feat(admin): implement P4 management console
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
target/
|
||||
front/node_modules/
|
||||
front/dist/
|
||||
*.log
|
||||
.idea/
|
||||
*.iml
|
||||
|
||||
@@ -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 部署)
|
||||
|
||||
|
||||
@@ -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>
|
||||
+1595
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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' } },
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user