Compare commits

...
Author SHA1 Message Date
xiaoqu e331650dcd merge: dev配置明文化 2026-09-04 14:28:53 +08:00
xiaoqu af23ced207 feat(config): dev环境DB/Redis连接信息明文写入application-dev.yml, 不再走环境变量; 同步README/.env.example/设计文档 2026-09-04 14:28:53 +08:00
xiaoqu 40ac398acc docs: 新增MCP工具接口文档(agent-mcp-tools-reference), 约定新工具必须同步文档 2026-09-04 11:10:25 +08:00
xiaoqu ffb705ba43 docs: 用户与Token管理只列企业账号(排除物业) 2026-09-03 18:49:44 +08:00
xiaoqu 4b5bd9e898 merge: 用户列表排除物业账号 2026-09-03 18:48:21 +08:00
xiaoqu 48e4f61ff4 feat(admin): 用户管理列表只查企业账号(enterprise_type 0/1, NULL保留), 排除物业账号(2) 2026-09-03 18:48:21 +08:00
xiaoqu 3087570803 docs: 补开发流程约定(worktree开发/列表分页上限100)与取舍记录 2026-09-03 18:29:23 +08:00
xiaoqu 9b64798d6b merge: 用户管理分页 + 工具分页上限确认 2026-09-03 18:27:44 +08:00
xiaoqu abfaa8c2d1 feat(admin): 用户管理改服务端分页(page/pageSize/total, pageSize上限100), 前端加分页器; MCP工具rows上限100已有, 补测试 2026-09-03 18:27:35 +08:00
xiaoqu c381249fa8 前端构建走国内镜像: node 用 npmmirror downloadRoot, npm 用 node 自带(provided), front/.npmrc 指向 registry.npmmirror.com; 修复 nodejs.org 直连超时 2026-09-03 17:31:47 +08:00
xqzp2026 87659f9de9 fix(database): enforce Flyway migration governance 2026-09-03 18:28:10 +09:30
xqzp2026 b1dd68a14c feat(admin): implement P4 management console 2026-09-03 18:01:00 +09:30
xiaoqu 691b3a3713 新增项目级 Kimi MCP 配置(xiaoqu, bearerTokenEnvVar) 2026-09-03 15:18:11 +08:00
xiaoqu 7eaf51973a 新增本机部署脚本 deploy/deploy.sh(build/start/stop/restart/status/log+健康检查), 凭据走 gitignore 的 deploy/.env.dev; README 补部署说明 2026-09-03 15:05:02 +08:00
wangheng b44fa9a86a Merge pull request '[security] 收紧 MCP 数据权限并移除默认凭据' (#1) from fix/mcp-security-hardening into main 2026-09-03 14:42:52 +08:00
43 changed files with 3878 additions and 46 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"xiaoqu": {
"url": "http://192.168.4.236:8088/mcp",
"bearerTokenEnvVar": "XQ_API_KEY"
}
}
}
+7
View File
@@ -1,6 +1,13 @@
target/
front/node_modules/
front/dist/
*.log
.idea/
*.iml
.vscode/
.DS_Store
# 本地部署凭据与运行态
deploy/.env.dev
deploy/app.pid
logs/
+36 -19
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)通过。
@@ -24,8 +25,11 @@ xiaoqu-agent-service/
├── mvnw / .mvn/wrapper/ # Maven Wrapper(本机无全局 mvn)
├── pom.xml
├── sql/
│ ├── V1__init.sql # 自有库 xiaoqu_agent_svc_d 建表(手工执行,未接 flyway)
│ └── seed-dev.sql # dev 种子数据(运营作用域授权,不含固定 ak)
│ ├── README.md # migration 编写、回滚与不可变规则
│ ├── bootstrap/create-database.sql # 首次创建数据库(结构不在这里维护)
│ ├── rollback/ # 与每个版本配套的人工回滚方案
│ └── seed-dev.sql # dev 种子数据,不进入 Flyway
├── front/ # Vue 3 管理后台,Maven 打包时自动构建进 jar
├── src/main/java/me/iiv/agent/
│ ├── AgentServiceApplication.java
│ ├── config/ # M0/M1/M2/SvcDataSourceConfig(独立 SqlSessionFactory + MapperScan)、McpToolConfig
@@ -35,10 +39,11 @@ 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
│ ├── db/migration/ # Flyway V1/V2... + 历史文件校验和清单
│ └── application-dev.yml # 四数据源 + Redis,账号密码必须由环境变量提供
└── src/test/java/me/iiv/agent/ # 权限回归测试 + run-once 代码生成器
```
@@ -83,24 +88,22 @@ xiaoqu-agent-service/
## 启动
```bash
# 首次:由数据库管理员建自有库;启动一次同步工具后,再创建 dev 作用域
mysql -h192.168.1.181 -u<svc_db_admin> -p < sql/V1__init.sql
# 首次仅由数据库管理员创建空库;表结构由应用启动时的 Flyway 自动迁移
mysql -h192.168.1.181 -u<svc_db_admin> -p < sql/bootstrap/create-database.sql
# 启动一次让 ToolSyncService 同步 tool 表后:
mysql -h192.168.1.181 -u<svc_db_admin> -p xiaoqu_agent_svc_d < sql/seed-dev.sql
# 必须显式指定环境及凭据;m0/m1/m2 使用数据库只读账号
# 数据库/Redis 连接信息已明文写在 application-dev.yml(内网 dev 库);
# 仍需显式指定 profile
export SPRING_PROFILES_ACTIVE=dev
export M0_DB_USERNAME=<readonly_user>
export M0_DB_PASSWORD=<password>
export M1_DB_USERNAME=<readonly_user>
export M1_DB_PASSWORD=<password>
export M2_DB_USERNAME=<readonly_user>
export M2_DB_PASSWORD=<password>
export SVC_DB_USERNAME=<svc_user>
export SVC_DB_PASSWORD=<password>
export REDIS_PASSWORD=<password>
# 已有 V1 库首次接入时设 true;确认 flyway_schema_history 后可设 false
export SVC_FLYWAY_BASELINE_ON_MIGRATE=true
# 仅首次、且 admin_user 为空时创建管理员;创建完成后可移除这三个变量
export ADMIN_BOOTSTRAP_ACCOUNT=<admin_account>
export ADMIN_BOOTSTRAP_PASSWORD=<strong_password>
export ADMIN_BOOTSTRAP_NAME=<display_name>
./mvnw clean package -DskipTests
./mvnw clean package
java -jar target/xiaoqu-agent-service.jar
# 或
./mvnw spring-boot:run
@@ -108,8 +111,22 @@ java -jar target/xiaoqu-agent-service.jar
nohup java -jar target/xiaoqu-agent-service.jar > target/app.log 2>&1 &
```
- 不提供默认 profile、数据库账号或密码;缺失配置时不得启动。
- dev 端口 **8088**,MCP 端点 `http://localhost:8088/mcp`。
- 不提供默认 profile;数据库/Redis 连接信息明文写在 `application-dev.yml`(内网 dev 库,与 xiaoqu-franchisee pom.xml dev profile 同一惯例);生产/测试环境不得在配置文件写明文密码。
- dev 端口 **8088**,管理后台 `http://localhost:8088/`,MCP 端点 `http://localhost:8088/mcp`。
- Flyway 在 Web 服务启动前执行 `validate + migrate`;checksum、版本或 SQL 执行失败时应用不会启动。
- 新增 SQL 必须遵循 [sql/README.md](sql/README.md),禁止修改已合并的历史 migration。
### 部署脚本(推荐,本机 dev 部署)
```bash
cp deploy/.env.example deploy/.env.dev # 填写 dev 凭据(已 gitignore,不入库)
./deploy/deploy.sh build # 全量测试 + 构建 jar(含 migration 治理测试)
./deploy/deploy.sh restart # 停旧起新;启动先迁移,失败立即报告并阻断
./deploy/deploy.sh status | log | stop # 状态 / 跟踪日志 / 停止
```
- `.env.dev` 只需管理员引导和 Flyway 开关;数据库/Redis 已走配置文件。日志在 `logs/agent-service.log`,pid 在 `deploy/app.pid`。
- 注意应用启动较慢(多数据源初始化,约 60-90s),脚本健康检查窗口已放宽到 180s。
## 测试 ak(仅 dev)
+10
View File
@@ -0,0 +1,10 @@
# dev 环境变量(本文件为模板;实际使用复制为 .env.dev,已 gitignore)
# 数据库/Redis 连接信息已明文写在 application-dev.yml,无需在此配置
# 后台管理员首次引导(仅首次启动且 admin_user 为空时建号,之后可删除)
APP_ADMIN_BOOTSTRAP_ACCOUNT=admin
APP_ADMIN_BOOTSTRAP_PASSWORD=
# APP_ADMIN_BOOTSTRAP_NAME=系统管理员
# 默认拒绝为非空库自动建基线;仅已有 V1 库首次接入时临时改为 true
SVC_FLYWAY_BASELINE_ON_MIGRATE=false
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# xiaoqu-agent-service 本机部署脚本(dev:本机开发 + 本机运行)
# 用法: ./deploy/deploy.sh {build|start|stop|restart|status|log}
# 凭据: 首次使用复制 deploy/.env.example 为 deploy/.env.dev 并填写(该文件已 gitignore,不入库)
set -euo pipefail
APP_HOME="$(cd "$(dirname "$0")/.." && pwd)"
JAR="$APP_HOME/target/xiaoqu-agent-service.jar"
ENV_FILE="$APP_HOME/deploy/.env.dev"
PID_FILE="$APP_HOME/deploy/app.pid"
LOG_DIR="$APP_HOME/logs"
LOG_FILE="$LOG_DIR/agent-service.log"
PORT=8088
PROFILE=dev
load_env() {
if [ ! -f "$ENV_FILE" ]; then
echo "[ERROR] 缺少 $ENV_FILE,请复制 deploy/.env.example 并填写 dev 凭据" >&2
exit 1
fi
set -a; source "$ENV_FILE"; set +a
}
is_running() {
[ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null
}
health_check() {
# /mcp 未带 ak 会返回 401,带 ak 返回 200——两者都证明服务已起来
for i in $(seq 1 90); do
if ! is_running; then
echo "[ERROR] 服务进程已退出,可能是数据库迁移或启动失败" >&2
tail -40 "$LOG_FILE" >&2 || true
rm -f "$PID_FILE"
return 1
fi
code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "http://127.0.0.1:$PORT/mcp" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"deploy-check","version":"0"}}}' || true)
if [ "$code" = "200" ] || [ "$code" = "401" ]; then
echo "[OK] 服务已就绪 (HTTP $code, /mcp)"
return 0
fi
sleep 2
done
echo "[ERROR] 健康检查超时,查看日志: tail -f $LOG_FILE" >&2
return 1
}
do_build() {
cd "$APP_HOME"
./mvnw clean package
echo "[OK] 构建完成: $JAR"
}
do_start() {
if is_running; then
echo "[SKIP] 已在运行 (pid $(cat "$PID_FILE"))"
return 0
fi
[ -f "$JAR" ] || { echo "[ERROR] 未找到 jar,先执行: $0 build" >&2; exit 1; }
load_env
mkdir -p "$LOG_DIR"
cd "$APP_HOME"
nohup java -jar "$JAR" --spring.profiles.active="$PROFILE" >> "$LOG_FILE" 2>&1 &
echo $! > "$PID_FILE"
echo "[OK] 已启动 (pid $(cat "$PID_FILE")),等待健康检查..."
health_check
}
do_stop() {
if is_running; then
kill "$(cat "$PID_FILE")"
for i in $(seq 1 15); do is_running || break; sleep 1; done
is_running && { echo "[WARN] 优雅退出超时,强制 kill -9"; kill -9 "$(cat "$PID_FILE")"; }
echo "[OK] 已停止"
else
echo "[SKIP] 未在运行"
fi
rm -f "$PID_FILE"
}
do_status() {
if is_running; then
echo "运行中 (pid $(cat "$PID_FILE"), port $PORT)"
else
echo "未运行"
exit 1
fi
}
case "${1:-}" in
build) do_build ;;
start) do_start ;;
stop) do_stop ;;
restart) do_stop; do_start ;;
status) do_status ;;
log) tail -f "$LOG_FILE" ;;
*)
echo "用法: $0 {build|start|stop|restart|status|log}" >&2
exit 1 ;;
esac
+1
View File
@@ -0,0 +1 @@
registry=https://registry.npmmirror.com
+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"
}
}
+418
View File
@@ -0,0 +1,418 @@
<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 userPage = reactive({ page: 1, pageSize: 20, total: 0 })
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(resetPage = false) {
if (resetPage) userPage.page = 1
const data = await api(`/users${qs({ keyword: userKeyword.value, page: userPage.page, pageSize: userPage.pageSize })}`)
users.value = data.records
userPage.total = data.total
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 changeUserPage(page) {
userPage.page = page
await run(loadUsers)
}
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(true))" />
<el-button data-testid="admin-user-search-button" @click="run(() => loadUsers(true))">搜索</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>
<el-pagination class="user-pager" layout="prev, pager, next, total" :total="userPage.total"
:page-size="userPage.pageSize" :current-page="userPage.page" small background
data-testid="admin-user-pager" @current-change="changeUserPage" />
</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')
+63
View File
@@ -0,0 +1,63 @@
: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; }
.user-pager { margin-top: 10px; justify-content: flex-end; }
.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' } },
})
+54
View File
@@ -64,6 +64,14 @@
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
<!-- Redis(db5,用户热缓存/字典,P1 仅做连通性自检) -->
<dependency>
@@ -82,6 +90,11 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<!-- codegen(run-once,仅 test scope,不进主启动流程) -->
<dependency>
@@ -99,7 +112,48 @@
<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>
<!-- provided = 使用 node 自带的 npm, 无需单独下载 -->
<npmVersion>provided</npmVersion>
<!-- 国内镜像, 避免 nodejs.org 直连超时 -->
<downloadRoot>https://npmmirror.com/mirrors/node/</downloadRoot>
</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>
+12
View File
@@ -0,0 +1,12 @@
# 数据库迁移规范
自有库结构由 Flyway 管理,运行时只扫描 `src/main/resources/db/migration`。
1. 已合并的 `V<N>__description.sql` 永不修改、删除或改名;Flyway 与测试都会校验历史 checksum。
2. 每次结构或数据变更新增下一个连续版本,并同步新增 `sql/rollback/V<N>__description.down.sql`。
3. Migration 只操作当前 svc schema,禁止包含 `CREATE DATABASE`、`USE` 和环境专属数据。
4. DDL 优先向后兼容;删除字段或表必须先经过废弃版本。大表变更必须给出锁表时间与分批方案。
5. `seed-dev.sql` 仅用于开发数据,不进入 Flyway。数据库首次创建使用 `bootstrap/create-database.sql`。
6. 合并前必须运行 `./mvnw clean package`;部署启动时 Flyway 会再次 validate 并按版本迁移,失败则应用不会启动。
`baseline-on-migrate` 默认关闭,避免连错非空库时被静默接管。已有 V1 数据库首次接入时,显式设置 `SVC_FLYWAY_BASELINE_ON_MIGRATE=true`:Flyway 记录 V1 基线并只执行 V2 以后版本;确认生成 `flyway_schema_history` 后立即恢复为 `false`。空数据库无需开启,会正常执行全部版本。
@@ -0,0 +1,3 @@
CREATE DATABASE IF NOT EXISTS xiaoqu_agent_svc_d
DEFAULT CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci;
@@ -0,0 +1,8 @@
-- 仅用于尚未承载业务数据的环境,按依赖逆序删除整个服务自有结构。
DROP TABLE IF EXISTS admin_user;
DROP TABLE IF EXISTS audit_log;
DROP TABLE IF EXISTS token_scope;
DROP TABLE IF EXISTS scope_tool;
DROP TABLE IF EXISTS tool;
DROP TABLE IF EXISTS agent_scope;
DROP TABLE IF EXISTS agent_api_key;
@@ -0,0 +1,15 @@
-- PBKDF2 哈希长度超过 64 时拒绝回退,避免截断管理员密码。
DELIMITER //
CREATE PROCEDURE rollback_v2_p4_admin()
BEGIN
IF EXISTS (SELECT 1 FROM admin_user WHERE CHAR_LENGTH(password_hash) > 64) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'V2 rollback blocked: PBKDF2 password hashes already exist';
END IF;
ALTER TABLE admin_user
MODIFY COLUMN password_hash CHAR(64) NOT NULL COMMENT '密码 SHA-256 hex';
END //
DELIMITER ;
CALL rollback_v2_p4_admin();
DROP PROCEDURE rollback_v2_p4_admin;
@@ -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,401 @@
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 UserPageView users(@RequestParam(defaultValue = "") String keyword,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize) {
if (keyword.length() > 100) bad("搜索关键词最多 100 字");
int pageNo = Math.max(page, 1);
int size = pageSize < 1 ? 20 : Math.min(pageSize, 100);
LambdaQueryWrapper<User> query = new LambdaQueryWrapper<User>().eq(User::getSoftDelete, 0)
// 只查企业账号(0=企业内部 1=代理),物业账号(2)不进列表;历史数据可能为 NULL 一并保留
.and(w -> w.ne(User::getEnterpriseType, 2).or().isNull(User::getEnterpriseType));
if (!keyword.isBlank()) query.and(w -> w.like(User::getName, keyword.trim())
.or().like(User::getMobile, keyword.trim()));
Long total = userMapper.selectCount(query);
List<User> users = userMapper.selectList(query.orderByDesc(User::getId)
.last("LIMIT " + (long) (pageNo - 1) * size + "," + size));
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()));
List<UserView> records = users.stream().map(user -> new UserView(user.getId(), user.getName(),
maskMobile(user.getMobile()), counts.getOrDefault(user.getId(), 0L))).toList();
return new UserPageView(total, pageNo, size, records);
}
@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 UserPageView(long total, int page, int pageSize, List<UserView> records) {}
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) {}
}
@@ -7,9 +7,12 @@ import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.autoconfigure.flyway.FlywayDataSource;
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;
@@ -21,6 +24,7 @@ import javax.sql.DataSource;
public class SvcDataSourceConfig {
@Bean(name = "svcDataSource")
@FlywayDataSource
@ConfigurationProperties(prefix = "app.datasource.svc")
public DataSource svcDataSource() {
return DataSourceBuilder.create().type(HikariDataSource.class).build();
@@ -35,4 +39,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,33 +1,34 @@
# dev 环境:内网 192.168.1.181(沿用 xiaoqu-franchisee pom.xml dev profile)
# 账号和密码必须由环境变量提供;m0/m1/m2 应使用数据库只读账号
# 账号密码明文写在本文件(内网 dev 库,与 xiaoqu-franchisee pom.xml dev profile 同一惯例);
# 生产/测试环境不得在配置文件中写明文密码,改用环境变量
app:
datasource:
m0: # 权限库 xiaoqu_comples_d(user/agent/authority/propery/agent_manage_district_auth)
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://192.168.1.181:3306/xiaoqu_comples_d?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: ${M0_DB_USERNAME}
password: ${M0_DB_PASSWORD}
username: root
password: kaixinjiuhao
m1: # 商城库 xiaoqu_mall_d(按需启用)
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://192.168.1.181:3306/xiaoqu_mall_d?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: ${M1_DB_USERNAME}
password: ${M1_DB_PASSWORD}
username: root
password: kaixinjiuhao
m2: # 业务库 xiaoqu_franchisee_d(warehouse_agent_auth 等)
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://192.168.1.181:3306/xiaoqu_franchisee_d?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: ${M2_DB_USERNAME}
password: ${M2_DB_PASSWORD}
username: root
password: kaixinjiuhao
svc: # 自有库 xiaoqu_agent_svc_d(token/scope/tool/audit,读写)
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://192.168.1.181:3306/xiaoqu_agent_svc_d?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: ${SVC_DB_USERNAME}
password: ${SVC_DB_PASSWORD}
username: root
password: kaixinjiuhao
spring:
data:
redis:
host: 192.168.1.181
port: 6379
password: ${REDIS_PASSWORD}
password: kaixinjiuhao
database: 5
timeout: 5s
@@ -1,9 +1,33 @@
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:
name: xiaoqu-agent-service
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: ${SVC_FLYWAY_BASELINE_ON_MIGRATE:false}
baseline-version: 1
baseline-description: legacy V1 schema
validate-on-migrate: true
validate-migration-naming: true
out-of-order: false
clean-disabled: true
ai:
mcp:
server:
@@ -1,13 +1,9 @@
-- =============================================================
-- xiaoqu-agent-service 自有库初始化(dev 环境)
-- xiaoqu-agent-service 自有库基线结构
-- 设计文档:doc/agent-mcp-service-design.md §7
-- 执行:mysql -h192.168.1.181 -uroot -p < sql/V1__init.sql
-- 由 Flyway 执行;数据库本身由 sql/bootstrap/create-database.sql 创建
-- =============================================================
CREATE DATABASE IF NOT EXISTS xiaoqu_agent_svc_d DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
USE xiaoqu_agent_svc_d;
-- token(ak):属于某个人(user_id 逻辑关联 m0.user),只存 SHA-256 哈希
CREATE TABLE IF NOT EXISTS agent_api_key (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
@@ -115,7 +111,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,2 @@
ALTER TABLE admin_user
MODIFY COLUMN password_hash VARCHAR(255) NOT NULL COMMENT 'PBKDF2 加盐密码哈希';
@@ -0,0 +1,2 @@
f8ec43a4fda81382d5675bc59fe7f4339f2e38a5b4241c260c3569e33b43a26d V1__init.sql
0ad892601e891a18712b1b6a613c392c94e63492dc14f4f779375c092a3acab6 V2__p4_admin.sql
@@ -0,0 +1,81 @@
package me.iiv.agent.admin;
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.TokenScopeMapper;
import me.iiv.agent.mapper.svc.ToolMapper;
import me.iiv.agent.permission.PermissionService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
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.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AdminConsoleControllerTest {
@Mock UserMapper userMapper;
@Mock AuthorityMapper authorityMapper;
@Mock AgentMapper agentMapper;
@Mock AgentApiKeyMapper tokenMapper;
@Mock AgentScopeMapper scopeMapper;
@Mock ToolMapper toolMapper;
@Mock ScopeToolMapper scopeToolMapper;
@Mock AuditLogMapper auditMapper;
@Mock TokenScopeMapper tokenScopeMapper;
@Mock PermissionService permissionService;
@Mock AdminTokenService tokenService;
AdminConsoleController controller;
@BeforeEach
void setUp() {
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
new org.apache.ibatis.builder.MapperBuilderAssistant(
new com.baomidou.mybatisplus.core.MybatisConfiguration(), ""),
me.iiv.agent.entity.m0.User.class);
controller = new AdminConsoleController(userMapper, authorityMapper, agentMapper, tokenMapper,
scopeMapper, toolMapper, scopeToolMapper, auditMapper, tokenScopeMapper,
permissionService, tokenService);
when(userMapper.selectCount(any())).thenReturn(0L);
when(userMapper.selectList(any())).thenReturn(List.of());
}
@Test
void usersClampsPageSizeToHundred() {
AdminConsoleController.UserPageView view = controller.users("", 1, 500);
assertEquals(100, view.pageSize());
assertEquals(1, view.page());
assertEquals(0, view.total());
}
@Test
void usersDefaultsInvalidPageAndSize() {
AdminConsoleController.UserPageView view = controller.users("", 0, 0);
assertEquals(1, view.page());
assertEquals(20, view.pageSize());
}
@Test
void usersExcludesPropertyAccounts() {
controller.users("", 1, 20);
org.mockito.ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<me.iiv.agent.entity.m0.User>>
captor = org.mockito.ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper.class);
org.mockito.Mockito.verify(userMapper, org.mockito.Mockito.atLeastOnce()).selectCount(captor.capture());
String sql = captor.getValue().getSqlSegment();
assertTrue(sql.contains("enterprise_type"), "用户列表必须过滤物业账号: " + sql);
assertTrue(sql.contains("IS NULL"), "历史 NULL 数据需保留: " + sql);
}
}
@@ -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));
}
}
@@ -0,0 +1,62 @@
package me.iiv.agent.config;
import org.flywaydb.core.Flyway;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.Statement;
import static org.junit.jupiter.api.Assertions.assertEquals;
class FlywayMigrationTest {
@Test
void emptyDatabaseMigratesThroughLatestVersion() throws Exception {
JdbcDataSource dataSource = dataSource("empty");
Flyway flyway = flyway(dataSource, false);
flyway.migrate();
assertEquals("2", flyway.info().current().getVersion().getVersion());
assertEquals(255, passwordHashLength(dataSource));
}
@Test
void legacyV1DatabaseIsBaselinedThenUpgraded() throws Exception {
JdbcDataSource dataSource = dataSource("legacy");
try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) {
statement.execute("CREATE TABLE admin_user (id BIGINT AUTO_INCREMENT PRIMARY KEY, password_hash CHAR(64) NOT NULL)");
}
Flyway flyway = flyway(dataSource, true);
flyway.migrate();
assertEquals("2", flyway.info().current().getVersion().getVersion());
assertEquals(255, passwordHashLength(dataSource));
}
private static Flyway flyway(JdbcDataSource dataSource, boolean baseline) {
return Flyway.configure().dataSource(dataSource).locations("classpath:db/migration")
.baselineOnMigrate(baseline).baselineVersion("1").validateMigrationNaming(true)
.cleanDisabled(true).load();
}
private static JdbcDataSource dataSource(String name) {
JdbcDataSource dataSource = new JdbcDataSource();
dataSource.setURL("jdbc:h2:mem:flyway_" + name + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1");
dataSource.setUser("sa");
return dataSource;
}
private static int passwordHashLength(JdbcDataSource dataSource) throws Exception {
try (Connection connection = dataSource.getConnection();
Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery("SELECT CHARACTER_MAXIMUM_LENGTH FROM INFORMATION_SCHEMA.COLUMNS "
+ "WHERE TABLE_NAME='admin_user' AND COLUMN_NAME='password_hash'")) {
result.next();
return result.getInt(1);
}
}
}
@@ -0,0 +1,76 @@
package me.iiv.agent.config;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.IntStream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class MigrationGovernanceTest {
private static final Path MIGRATIONS = Path.of("src/main/resources/db/migration");
private static final Path ROLLBACKS = Path.of("sql/rollback");
private static final Pattern NAME = Pattern.compile("V(\\d+)__[a-z0-9_]+\\.sql");
@Test
void migrationsAreOrderedAndHaveRollbackPlans() throws Exception {
assertTrue(Files.isDirectory(MIGRATIONS), "缺少 Flyway migration 目录");
List<Path> files;
try (var stream = Files.list(MIGRATIONS)) {
files = stream.filter(path -> path.getFileName().toString().endsWith(".sql")).sorted().toList();
}
assertFalse(files.isEmpty(), "至少保留一个基线 migration");
Map<Integer, Path> versions = new TreeMap<>();
for (Path file : files) {
Matcher matcher = NAME.matcher(file.getFileName().toString());
assertTrue(matcher.matches(), () -> "迁移文件名不合规: " + file.getFileName());
int version = Integer.parseInt(matcher.group(1));
assertFalse(versions.containsKey(version), () -> "迁移版本重复: V" + version);
versions.put(version, file);
String sql = Files.readString(file);
assertFalse(Pattern.compile("(?im)^\\s*(USE|CREATE\\s+DATABASE)\\b").matcher(sql).find(),
() -> "migration 只能操作当前 svc schema: " + file.getFileName());
String rollback = file.getFileName().toString().replace(".sql", ".down.sql");
assertTrue(Files.isRegularFile(ROLLBACKS.resolve(rollback)), () -> "缺少回滚方案: " + rollback);
}
List<Integer> expected = IntStream.rangeClosed(1, versions.size()).boxed().toList();
assertEquals(expected, versions.keySet().stream().toList(), "迁移版本必须从 V1 连续递增");
}
@Test
void committedMigrationChecksumsMatchManifest() throws Exception {
Path manifest = MIGRATIONS.resolve("checksums.sha256");
assertTrue(Files.isRegularFile(manifest), "缺少 migration 校验和清单");
Map<String, String> checksums = new TreeMap<>();
for (String line : Files.readAllLines(manifest)) {
if (line.isBlank() || line.startsWith("#")) continue;
String[] parts = line.trim().split("\\s+", 2);
assertEquals(2, parts.length, "校验和格式应为: sha256 文件名");
checksums.put(parts[1], parts[0]);
}
Set<String> migrationNames;
try (var stream = Files.list(MIGRATIONS)) {
migrationNames = stream.map(path -> path.getFileName().toString())
.filter(name -> name.endsWith(".sql")).collect(java.util.stream.Collectors.toSet());
}
assertEquals(migrationNames, checksums.keySet(), "校验和清单必须覆盖全部 migration");
MessageDigest digest = MessageDigest.getInstance("SHA-256");
for (Map.Entry<String, String> entry : checksums.entrySet()) {
String actual = HexFormat.of().formatHex(digest.digest(Files.readAllBytes(MIGRATIONS.resolve(entry.getKey()))));
assertEquals(entry.getValue(), actual, () -> "历史 migration 被修改: " + entry.getKey());
}
}
}
@@ -11,8 +11,6 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
class ConfigurationSecurityTest {
private static final Pattern PASSWORD_WITH_DEFAULT =
Pattern.compile("password:\\s*\\$\\{[^}:]+:[^}]+}");
private static final Pattern COMMITTED_AK =
Pattern.compile("xqak_[0-9a-f]{20,}");
@@ -24,11 +22,11 @@ class ConfigurationSecurityTest {
}
@Test
void devConfigurationHasNoRootUserOrPasswordDefaults() throws IOException {
String application = Files.readString(Path.of("src/main/resources/application-dev.yml"));
void baseConfigurationHasNoPlaintextPassword() throws IOException {
// dev 允许明文(内网库,与 xiaoqu-franchisee pom dev 同一惯例);公共 application.yml 不允许
String application = Files.readString(Path.of("src/main/resources/application.yml"));
assertFalse(application.contains("username: root"));
assertFalse(PASSWORD_WITH_DEFAULT.matcher(application).find());
assertFalse(application.contains("password: kaixinjiuhao"));
}
@Test
@@ -94,7 +94,7 @@ xq CLI ─────┘ (Go 单二进制, 同为 MCP 客户端) ┌──
| ES | 部分列表查询 | 预留,一期可后置 |
- 全部使用**只读数据库账号**(物理封死误写);将来写工具单独配有写权限的账号。
- 配置按环境分文件(dev/test/prd),密码走环境变量,对齐现有 Maven profile 注入方式。
- 配置按环境分文件(dev/test/prd);dev 环境密码明文写在 `application-dev.yml`(内网 dev 库,与现有 pom.xml dev profile 明文同一惯例),生产/测试环境不得在配置文件写明文密码。
### 4.1 Entity 生成
@@ -123,7 +123,7 @@ xq CLI ─────┘ (Go 单二进制, 同为 MCP 客户端) ┌──
### 5.2 Web 管理后台(四个页面)
- **用户与 Token 管理**:人员选自现有 `user` 表;每人名下签发/禁用 token,展示 key 前缀、作用域、最后使用时间;选中人员展示其**数据权限归属**(权限组/isagent、所属代理、企业类型、可见范围说明,只读,便于排查"查不到数据")。
- **用户与 Token 管理**:人员选自现有 `user` 表,**只列企业账号**(`enterprise_type` 0=企业内部 / 1=代理,历史 NULL 保留),物业账号(=2)不进列表——Token 是给能查代理商/客户数据的人用的;每人名下签发/禁用 token,展示 key 前缀、作用域、最后使用时间;选中人员展示其**数据权限归属**(权限组/isagent、所属代理、企业类型、可见范围说明,只读,便于排查"查不到数据")。
- **工具管理**:工具列表来自服务端注册自动同步(工具名/分类/读写类型页面只读);展示每个工具被哪些作用域引用;「停用」为全局紧急开关。分类(category)在代码注册时由开发者声明(工具的业务域归属是客观属性,不允许页面上修改,避免与代码漂移)。
- **作用域管理**:作用域 CRUD + 勾选工具(工具按 category 分组展示)。
- **审计日志**:按人/token/工具/时间筛选。
@@ -223,6 +223,7 @@ xiaoqu-agent-service/
- CLI 复用 `/mcp` 端点做 MCP 客户端,不开第二套服务 → 权限逻辑只有一份;Go 单二进制分发,与服务端 Java 栈解耦
- 不依赖老 jar,Entity 自行生成 → 与老系统零代码依赖
- 前后端一体打包单 jar → 可逆决定,将来可拆
- 列表类接口一律服务端分页,pageSize/rows 上限 100(超出截断为 100)→ 防止一次性拉全表,MCP 工具与后台 API 同一尺度
## 11. 开发环境(dev)
@@ -236,6 +237,14 @@ xiaoqu-agent-service/
| Redis | 192.168.1.181:6379 | 密码见 pom dev | db5(`entity:User:{id}`、Dictionary)、db13 |
| ES | 192.168.1.152:9200 | 无认证 | 预留,一期不用 |
- 密码等敏感值不入库本文档,以 `pom.xml` dev profile 为准;新系统 `application-dev.yml` 引用同名环境变量。
- 密码等敏感值不入库本文档;dev 环境 DB/Redis 连接信息明文写在新系统 `application-dev.yml`(内网 dev 库,与 `pom.xml` dev profile 明文同一惯例)。
- 自有库表(§7)建在同实例新 schema:dev 用 `xiaoqu_agent_svc_d`(沿用 `_d` 后缀约定),首次部署前手工建库。
- 新系统与老的 dev 后端(Jetty 8091)可同时运行,互不干扰——注意两边连的是**同一套** dev 数据库和 Redis,老系统dev库的数据变动会同时影响两边的查询结果。
## 12. 开发流程约定
- **一律用 git worktree 开发**:每个需求从 main 拉分支到 `../oldproject-worktrees/<feature>/`,在 worktree 里改代码、跑测试;验证通过后合并回 main 再推送远程。主检出(本目录)保持可随时部署的干净状态。
- 合并回 main 后用 `xiaoqu-agent-service/deploy/deploy.sh restart` 完成构建+重启,接口实测通过再 `git push`。
- 后台列表页统一服务端分页(page 从 1 开始,pageSize 默认 20、上限 100);MCP 工具 `rows` 同样上限 100,超出截断并在 `notice` 字段提示。
- **新增/修改 MCP 工具必须同步更新 `doc/agent-mcp-tools-reference.md`**(入参、出参、权限语义、示例),工具对外契约以该文档为准。
@@ -0,0 +1,153 @@
# xiaoqu-agent-service MCP 工具接口文档
> 本文档是 MCP 服务**对外暴露工具**的接口契约。新增/修改工具时必须同步更新本文档(开发约定见 `agent-mcp-service-design.md` §12)。
> 文档中的入参与代码 `@ToolParam`、出参与工具实际返回保持一致;在线验证方式见文末附录。
## 0. 调用约定(所有工具共用)
| 项 | 说明 |
|---|---|
| 端点 | `POST http://<host>:8088/mcp`(MCP Streamable HTTP) |
| 认证 | Header `Authorization: Bearer xqak_xxx`,ak 在管理后台「用户与 Token」签发,服务端只存 SHA-256 |
| 工具可见性 | 按 ak 绑定的作用域过滤,`tools/list` 只返回该 ak 可用的工具 |
| 行级数据权限 | 跟 ak 所属人走,照搬老系统 `getByIsAgent` 语义(见设计文档 §3.3),与作用域无关 |
| 分页 | 列表类工具统一 `page`(从 1 开始,默认 1)、`rows`(默认 20,**上限 100**,超出截断为 100 并在返回 `notice` 字段提示) |
| 分页出参 | 统一含 `total`(总数)、`page`、`rows`、`records`(本页记录)、`notice`(仅截断时出现) |
---
## 1. agent.count
统计代理商总数(demo 工具)。
- 分类:system | 类型:读
- 数据来源:m0 库 `agent` 表,固定条件 `soft_delete = 0`
**入参**:无
**出参**:
| 字段 | 类型 | 说明 |
|---|---|---|
| count | number | 代理商总数 |
```json
{ "count": 62 }
```
---
## 2. agent.list.query
查询代理商列表(分页),对应老系统 `/agent/listByPage`。
- 分类:customer | 类型:读
- 数据来源:m0 库 `agent` 表
- 行级权限:权限组 `isagent=1`(代理商身份)只查自己名下直属下级(`supervisor_id = 自己agentId`);`isagent=0`(总部)看全部。传 `id` 精确查询时:代理商仅限自己或直属下级,总部可传任意合法 id,且其余过滤条件失效。
**入参**:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| id | number | 否 | 代理商 id,精确查询;传入后忽略其他过滤条件 |
| name | string | 否 | 代理商名称,模糊匹配 |
| level | number | 否 | 级别:0=总部 1=一级 2=二级 |
| channelNo | string | 否 | 渠道编号:Z=总部 F=分公司 D=代理 |
| province | string | 否 | 省份,精确匹配,如 `北京市` |
| city | string | 否 | 城市,精确匹配,如 `北京市` |
| serialnumber | string | 否 | 代理商编号,精确匹配 |
| page | number | 否 | 页码,从 1 开始,默认 1 |
| rows | number | 否 | 每页条数,默认 20,上限 100 |
**出参 records 字段**:
| 字段 | 说明 |
|---|---|
| id / name | 代理商 id / 名称 |
| level / channelNo / serialnumber | 级别 / 渠道编号 / 代理商编号 |
| supervisorName | 上级代理名称(无上级为 `-`,批量富化) |
| province / city / countyseat | 省 / 市 / 区县 |
| contact / phone | 联系人 / 电话 |
| agencyarea / memo | 代理区域 / 备注 |
| createTime | 创建时间,`yyyy-MM-dd HH:mm:ss` |
```json
{
"total": 62, "page": 1, "rows": 20,
"records": [ { "id": 19, "name": "北京D类代理", "level": 2, "channelNo": "D", "...": "..." } ]
}
```
---
## 3. property.list.query
查询物业(客户)列表(分页),对应老系统 `/property/listByPage`。
- 分类:customer | 类型:读
- 数据来源:m0 库 `properyt` 表
- 行级权限(完整版 `getByIsAgent` 语义):
- 总部(isagent=0):全部;
- 代理商(isagent=1):自己 + 直属下级代理 + `warehouse_agent_auth` 代管代理 名下物业,及 `agent_manage_district_auth` 授权代管的物业;
- 销售部门非负责人:自己创建的物业 ∪ 代管授权代理名下物业(`id IN (...)` 结构,与其余规则互斥,1:1 复刻老代码 saleFlag 分支)。
- **不含** enterprise_type=2 物业用户限自己物业的规则(物业用户不查此列表)。
**入参**:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| name | string | 否 | 物业名称,模糊匹配 |
| agentId | number | 否 | 所属代理商 id,精确匹配 |
| serialnumber | string | 否 | 物业编号,精确匹配 |
| province | string | 否 | 省份,精确匹配 |
| city | string | 否 | 城市,精确匹配 |
| contact | string | 否 | 联系人,精确匹配 |
| phone | string | 否 | 联系电话,精确匹配 |
| page | number | 否 | 页码,从 1 开始,默认 1 |
| rows | number | 否 | 每页条数,默认 20,上限 100 |
**出参 records 字段**:
| 字段 | 说明 |
|---|---|
| id / name / serialnumber | 物业 id / 名称 / 编号 |
| agentName | 所属代理商名称(查不到为 `未查询到`) |
| createName | 创建人/销售姓名(未分配为 `未分配`) |
| province / city / countyseat / address | 省 / 市 / 区县 / 详细地址 |
| contact / phone | 联系人 / 电话 |
| createTime | 创建时间,`yyyy-MM-dd HH:mm:ss` |
---
## 4. sys.ping
服务自检(demo 工具,证明链路连通)。
- 分类:system | 类型:读
**入参**:无
**出参**:
| 字段 | 说明 |
|---|---|
| service | 服务名(spring.application.name) |
| version | 服务版本 |
| time | 服务器当前时间 |
| redis | `PONG` 表示 Redis 连通;否则为 `ERROR: ...` |
---
## 附录:在线查看工具清单
```bash
AK=xqak_xxx; H=http://localhost:8088/mcp
# 1) initialize,从响应头取 mcp-session-id
# 2) 带 mcp-session-id 发 notifications/initialized
# 3) 带 mcp-session-id 调用 tools/list
curl -X POST $H -H "Authorization: Bearer $AK" -H "mcp-session-id: $SID" \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
```
也可在管理后台「工具管理」页查看全量注册工具(不受作用域过滤)。