Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bb115dee0 | ||
|
|
e331650dcd | ||
|
|
af23ced207 | ||
|
|
40ac398acc | ||
|
|
ffb705ba43 | ||
|
|
4b5bd9e898 | ||
|
|
48e4f61ff4 | ||
|
|
3087570803 | ||
|
|
9b64798d6b | ||
|
|
abfaa8c2d1 | ||
|
|
c381249fa8 |
@@ -93,19 +93,11 @@ mysql -h192.168.1.181 -u<svc_db_admin> -p < sql/bootstrap/create-database.sql
|
|||||||
# 启动一次让 ToolSyncService 同步 tool 表后:
|
# 启动一次让 ToolSyncService 同步 tool 表后:
|
||||||
mysql -h192.168.1.181 -u<svc_db_admin> -p xiaoqu_agent_svc_d < sql/seed-dev.sql
|
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 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>
|
|
||||||
# 已有 V1 库首次接入时设 true;确认 flyway_schema_history 后可设 false
|
# 已有 V1 库首次接入时设 true;确认 flyway_schema_history 后可设 false
|
||||||
export SVC_FLYWAY_BASELINE_ON_MIGRATE=true
|
export SVC_FLYWAY_BASELINE_ON_MIGRATE=true
|
||||||
export REDIS_PASSWORD=<password>
|
|
||||||
# 仅首次、且 admin_user 为空时创建管理员;创建完成后可移除这三个变量
|
# 仅首次、且 admin_user 为空时创建管理员;创建完成后可移除这三个变量
|
||||||
export ADMIN_BOOTSTRAP_ACCOUNT=<admin_account>
|
export ADMIN_BOOTSTRAP_ACCOUNT=<admin_account>
|
||||||
export ADMIN_BOOTSTRAP_PASSWORD=<strong_password>
|
export ADMIN_BOOTSTRAP_PASSWORD=<strong_password>
|
||||||
@@ -119,7 +111,7 @@ java -jar target/xiaoqu-agent-service.jar
|
|||||||
nohup java -jar target/xiaoqu-agent-service.jar > target/app.log 2>&1 &
|
nohup java -jar target/xiaoqu-agent-service.jar > target/app.log 2>&1 &
|
||||||
```
|
```
|
||||||
|
|
||||||
- 不提供默认 profile、数据库账号或密码;缺失配置时不得启动。
|
- 不提供默认 profile;数据库/Redis 连接信息明文写在 `application-dev.yml`(内网 dev 库,与 xiaoqu-franchisee pom.xml dev profile 同一惯例);生产/测试环境不得在配置文件写明文密码。
|
||||||
- dev 端口 **8088**,管理后台 `http://localhost:8088/`,MCP 端点 `http://localhost:8088/mcp`。
|
- dev 端口 **8088**,管理后台 `http://localhost:8088/`,MCP 端点 `http://localhost:8088/mcp`。
|
||||||
- Flyway 在 Web 服务启动前执行 `validate + migrate`;checksum、版本或 SQL 执行失败时应用不会启动。
|
- Flyway 在 Web 服务启动前执行 `validate + migrate`;checksum、版本或 SQL 执行失败时应用不会启动。
|
||||||
- 新增 SQL 必须遵循 [sql/README.md](sql/README.md),禁止修改已合并的历史 migration。
|
- 新增 SQL 必须遵循 [sql/README.md](sql/README.md),禁止修改已合并的历史 migration。
|
||||||
@@ -133,7 +125,7 @@ cp deploy/.env.example deploy/.env.dev # 填写 dev 凭据(已 gitignore,
|
|||||||
./deploy/deploy.sh status | log | stop # 状态 / 跟踪日志 / 停止
|
./deploy/deploy.sh status | log | stop # 状态 / 跟踪日志 / 停止
|
||||||
```
|
```
|
||||||
|
|
||||||
- 凭据从 `deploy/.env.dev` 读取,无需手工 export;日志在 `logs/agent-service.log`,pid 在 `deploy/app.pid`。
|
- `.env.dev` 只需管理员引导和 Flyway 开关;数据库/Redis 已走配置文件。日志在 `logs/agent-service.log`,pid 在 `deploy/app.pid`。
|
||||||
- 注意应用启动较慢(多数据源初始化,约 60-90s),脚本健康检查窗口已放宽到 180s。
|
- 注意应用启动较慢(多数据源初始化,约 60-90s),脚本健康检查窗口已放宽到 180s。
|
||||||
|
|
||||||
## 测试 ak(仅 dev)
|
## 测试 ak(仅 dev)
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
# dev 环境凭据(本文件为模板;实际使用复制为 .env.dev,已 gitignore)
|
# dev 环境变量(本文件为模板;实际使用复制为 .env.dev,已 gitignore)
|
||||||
# 取值参考 xiaoqu-franchisee/pom.xml dev profile
|
# 数据库/Redis 连接信息已明文写在 application-dev.yml,无需在此配置
|
||||||
M0_DB_USERNAME=root
|
|
||||||
M0_DB_PASSWORD=
|
# 后台管理员首次引导(仅首次启动且 admin_user 为空时建号,之后可删除)
|
||||||
M1_DB_USERNAME=root
|
APP_ADMIN_BOOTSTRAP_ACCOUNT=admin
|
||||||
M1_DB_PASSWORD=
|
APP_ADMIN_BOOTSTRAP_PASSWORD=
|
||||||
M2_DB_USERNAME=root
|
# APP_ADMIN_BOOTSTRAP_NAME=系统管理员
|
||||||
M2_DB_PASSWORD=
|
|
||||||
SVC_DB_USERNAME=root
|
|
||||||
SVC_DB_PASSWORD=
|
|
||||||
# 默认拒绝为非空库自动建基线;仅已有 V1 库首次接入时临时改为 true
|
# 默认拒绝为非空库自动建基线;仅已有 V1 库首次接入时临时改为 true
|
||||||
SVC_FLYWAY_BASELINE_ON_MIGRATE=false
|
SVC_FLYWAY_BASELINE_ON_MIGRATE=false
|
||||||
REDIS_PASSWORD=
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
registry=https://registry.npmmirror.com
|
||||||
@@ -16,6 +16,7 @@ const session = reactive({ name: '', csrfToken: '' })
|
|||||||
const loginForm = reactive({ account: '', password: '' })
|
const loginForm = reactive({ account: '', password: '' })
|
||||||
const users = ref([])
|
const users = ref([])
|
||||||
const userKeyword = ref('')
|
const userKeyword = ref('')
|
||||||
|
const userPage = reactive({ page: 1, pageSize: 20, total: 0 })
|
||||||
const selectedUser = ref(null)
|
const selectedUser = ref(null)
|
||||||
const permission = ref(null)
|
const permission = ref(null)
|
||||||
const tokens = ref([])
|
const tokens = ref([])
|
||||||
@@ -80,14 +81,22 @@ async function switchMenu(id) {
|
|||||||
if (id === 'audit') await Promise.all([loadAudit(), api('/tools').then(data => { tools.value = data })])
|
if (id === 'audit') await Promise.all([loadAudit(), api('/tools').then(data => { tools.value = data })])
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers(resetPage = false) {
|
||||||
users.value = await api(`/users${qs({ keyword: userKeyword.value })}`)
|
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)
|
const existing = users.value.find(user => user.id === selectedUser.value?.id)
|
||||||
if (existing) selectedUser.value = existing
|
if (existing) selectedUser.value = existing
|
||||||
else if (users.value.length) await selectUser(users.value[0])
|
else if (users.value.length) await selectUser(users.value[0])
|
||||||
else { selectedUser.value = null; permission.value = null; tokens.value = [] }
|
else { selectedUser.value = null; permission.value = null; tokens.value = [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function changeUserPage(page) {
|
||||||
|
userPage.page = page
|
||||||
|
await run(loadUsers)
|
||||||
|
}
|
||||||
|
|
||||||
async function selectUser(user) {
|
async function selectUser(user) {
|
||||||
selectedUser.value = user
|
selectedUser.value = user
|
||||||
;[permission.value, tokens.value, scopes.value] = await Promise.all([
|
;[permission.value, tokens.value, scopes.value] = await Promise.all([
|
||||||
@@ -245,11 +254,11 @@ onMounted(async () => {
|
|||||||
<div class="token-layout">
|
<div class="token-layout">
|
||||||
<div>
|
<div>
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<div class="card-title"><span>人员</span><small>来自现有 user 表</small></div>
|
<div class="card-title"><span>人员</span><small>来自现有 user 表 · 仅企业账号(不含物业)</small></div>
|
||||||
<div class="toolbar compact">
|
<div class="toolbar compact">
|
||||||
<el-input v-model="userKeyword" clearable placeholder="姓名 / 手机号" aria-label="搜索人员"
|
<el-input v-model="userKeyword" clearable placeholder="姓名 / 手机号" aria-label="搜索人员"
|
||||||
data-testid="admin-input-user-search" @keyup.enter="run(loadUsers)" />
|
data-testid="admin-input-user-search" @keyup.enter="run(() => loadUsers(true))" />
|
||||||
<el-button data-testid="admin-user-search-button" @click="run(loadUsers)">搜索</el-button>
|
<el-button data-testid="admin-user-search-button" @click="run(() => loadUsers(true))">搜索</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-list">
|
<div class="user-list">
|
||||||
<button v-for="user in users" :key="user.id" :class="['user-row', { selected: selectedUser?.id === user.id }]"
|
<button v-for="user in users" :key="user.id" :class="['user-row', { selected: selectedUser?.id === user.id }]"
|
||||||
@@ -259,6 +268,9 @@ onMounted(async () => {
|
|||||||
</button>
|
</button>
|
||||||
<el-empty v-if="!users.length" :image-size="60" description="未找到人员" />
|
<el-empty v-if="!users.length" :image-size="60" description="未找到人员" />
|
||||||
</div>
|
</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>
|
||||||
<article class="card permission-card">
|
<article class="card permission-card">
|
||||||
<div class="card-title"><span>数据权限归属</span><small>只读 · 在现有系统维护</small></div>
|
<div class="card-title"><span>数据权限归属</span><small>只读 · 在现有系统维护</small></div>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ button, input { font: inherit; }
|
|||||||
.user-row.selected { background: #ecf5ff; }
|
.user-row.selected { background: #ecf5ff; }
|
||||||
.user-row span:first-child { display: flex; flex-direction: column; gap: 4px; }
|
.user-row span:first-child { display: flex; flex-direction: column; gap: 4px; }
|
||||||
.user-row small { color: #909399; }
|
.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 { 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 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, .permission-card dd { margin: 0; padding: 10px 6px; }
|
||||||
|
|||||||
@@ -136,7 +136,10 @@
|
|||||||
<goals><goal>install-node-and-npm</goal></goals>
|
<goals><goal>install-node-and-npm</goal></goals>
|
||||||
<configuration>
|
<configuration>
|
||||||
<nodeVersion>v20.20.2</nodeVersion>
|
<nodeVersion>v20.20.2</nodeVersion>
|
||||||
<npmVersion>10.8.2</npmVersion>
|
<!-- provided = 使用 node 自带的 npm, 无需单独下载 -->
|
||||||
|
<npmVersion>provided</npmVersion>
|
||||||
|
<!-- 国内镜像, 避免 nodejs.org 直连超时 -->
|
||||||
|
<downloadRoot>https://npmmirror.com/mirrors/node/</downloadRoot>
|
||||||
</configuration>
|
</configuration>
|
||||||
</execution>
|
</execution>
|
||||||
<execution>
|
<execution>
|
||||||
|
|||||||
@@ -82,19 +82,28 @@ public class AdminConsoleController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/users")
|
@GetMapping("/users")
|
||||||
public List<UserView> users(@RequestParam(defaultValue = "") String keyword) {
|
public UserPageView users(@RequestParam(defaultValue = "") String keyword,
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int pageSize) {
|
||||||
if (keyword.length() > 100) bad("搜索关键词最多 100 字");
|
if (keyword.length() > 100) bad("搜索关键词最多 100 字");
|
||||||
LambdaQueryWrapper<User> query = new LambdaQueryWrapper<User>().eq(User::getSoftDelete, 0);
|
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())
|
if (!keyword.isBlank()) query.and(w -> w.like(User::getName, keyword.trim())
|
||||||
.or().like(User::getMobile, keyword.trim()));
|
.or().like(User::getMobile, keyword.trim()));
|
||||||
List<User> users = userMapper.selectList(query.orderByDesc(User::getId).last("LIMIT 100"));
|
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());
|
Set<Long> ids = users.stream().map(User::getId).collect(Collectors.toSet());
|
||||||
Map<Long, Long> counts = ids.isEmpty() ? Map.of() : tokenMapper.selectList(
|
Map<Long, Long> counts = ids.isEmpty() ? Map.of() : tokenMapper.selectList(
|
||||||
new LambdaQueryWrapper<AgentApiKey>().in(AgentApiKey::getUserId, ids)
|
new LambdaQueryWrapper<AgentApiKey>().in(AgentApiKey::getUserId, ids)
|
||||||
.eq(AgentApiKey::getSoftDelete, 0))
|
.eq(AgentApiKey::getSoftDelete, 0))
|
||||||
.stream().collect(Collectors.groupingBy(AgentApiKey::getUserId, Collectors.counting()));
|
.stream().collect(Collectors.groupingBy(AgentApiKey::getUserId, Collectors.counting()));
|
||||||
return users.stream().map(user -> new UserView(user.getId(), user.getName(), maskMobile(user.getMobile()),
|
List<UserView> records = users.stream().map(user -> new UserView(user.getId(), user.getName(),
|
||||||
counts.getOrDefault(user.getId(), 0L))).toList();
|
maskMobile(user.getMobile()), counts.getOrDefault(user.getId(), 0L))).toList();
|
||||||
|
return new UserPageView(total, pageNo, size, records);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/users/{id}/permission")
|
@GetMapping("/users/{id}/permission")
|
||||||
@@ -375,6 +384,7 @@ public class AdminConsoleController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public record UserView(Long id, String name, String mobile, long tokenCount) {}
|
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,
|
public record PermissionView(String authorityName, Integer isAgent, String agentName,
|
||||||
Integer enterpriseType, String visibleRange) {}
|
Integer enterpriseType, String visibleRange) {}
|
||||||
public record TokenRequest(String memo, List<Long> scopeIds, Date expireTime) {}
|
public record TokenRequest(String memo, List<Long> scopeIds, Date expireTime) {}
|
||||||
|
|||||||
@@ -1,33 +1,34 @@
|
|||||||
# dev 环境:内网 192.168.1.181(沿用 xiaoqu-franchisee pom.xml dev profile)
|
# dev 环境:内网 192.168.1.181(沿用 xiaoqu-franchisee pom.xml dev profile)
|
||||||
# 账号和密码必须由环境变量提供;m0/m1/m2 应使用数据库只读账号
|
# 账号密码明文写在本文件(内网 dev 库,与 xiaoqu-franchisee pom.xml dev profile 同一惯例);
|
||||||
|
# 生产/测试环境不得在配置文件中写明文密码,改用环境变量
|
||||||
app:
|
app:
|
||||||
datasource:
|
datasource:
|
||||||
m0: # 权限库 xiaoqu_comples_d(user/agent/authority/propery/agent_manage_district_auth)
|
m0: # 权限库 xiaoqu_comples_d(user/agent/authority/propery/agent_manage_district_auth)
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
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
|
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}
|
username: root
|
||||||
password: ${M0_DB_PASSWORD}
|
password: kaixinjiuhao
|
||||||
m1: # 商城库 xiaoqu_mall_d(按需启用)
|
m1: # 商城库 xiaoqu_mall_d(按需启用)
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
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
|
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}
|
username: root
|
||||||
password: ${M1_DB_PASSWORD}
|
password: kaixinjiuhao
|
||||||
m2: # 业务库 xiaoqu_franchisee_d(warehouse_agent_auth 等)
|
m2: # 业务库 xiaoqu_franchisee_d(warehouse_agent_auth 等)
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
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
|
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}
|
username: root
|
||||||
password: ${M2_DB_PASSWORD}
|
password: kaixinjiuhao
|
||||||
svc: # 自有库 xiaoqu_agent_svc_d(token/scope/tool/audit,读写)
|
svc: # 自有库 xiaoqu_agent_svc_d(token/scope/tool/audit,读写)
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
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
|
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}
|
username: root
|
||||||
password: ${SVC_DB_PASSWORD}
|
password: kaixinjiuhao
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
data:
|
data:
|
||||||
redis:
|
redis:
|
||||||
host: 192.168.1.181
|
host: 192.168.1.181
|
||||||
port: 6379
|
port: 6379
|
||||||
password: ${REDIS_PASSWORD}
|
password: kaixinjiuhao
|
||||||
database: 5
|
database: 5
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-6
@@ -11,8 +11,6 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
|
|||||||
|
|
||||||
class ConfigurationSecurityTest {
|
class ConfigurationSecurityTest {
|
||||||
|
|
||||||
private static final Pattern PASSWORD_WITH_DEFAULT =
|
|
||||||
Pattern.compile("password:\\s*\\$\\{[^}:]+:[^}]+}");
|
|
||||||
private static final Pattern COMMITTED_AK =
|
private static final Pattern COMMITTED_AK =
|
||||||
Pattern.compile("xqak_[0-9a-f]{20,}");
|
Pattern.compile("xqak_[0-9a-f]{20,}");
|
||||||
|
|
||||||
@@ -24,11 +22,11 @@ class ConfigurationSecurityTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void devConfigurationHasNoRootUserOrPasswordDefaults() throws IOException {
|
void baseConfigurationHasNoPlaintextPassword() throws IOException {
|
||||||
String application = Files.readString(Path.of("src/main/resources/application-dev.yml"));
|
// dev 允许明文(内网库,与 xiaoqu-franchisee pom dev 同一惯例);公共 application.yml 不允许
|
||||||
|
String application = Files.readString(Path.of("src/main/resources/application.yml"));
|
||||||
|
|
||||||
assertFalse(application.contains("username: root"));
|
assertFalse(application.contains("password: kaixinjiuhao"));
|
||||||
assertFalse(PASSWORD_WITH_DEFAULT.matcher(application).find());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ xq CLI ─────┘ (Go 单二进制, 同为 MCP 客户端) ┌──
|
|||||||
| ES | 部分列表查询 | 预留,一期可后置 |
|
| ES | 部分列表查询 | 预留,一期可后置 |
|
||||||
|
|
||||||
- 全部使用**只读数据库账号**(物理封死误写);将来写工具单独配有写权限的账号。
|
- 全部使用**只读数据库账号**(物理封死误写);将来写工具单独配有写权限的账号。
|
||||||
- 配置按环境分文件(dev/test/prd),密码走环境变量,对齐现有 Maven profile 注入方式。
|
- 配置按环境分文件(dev/test/prd);dev 环境密码明文写在 `application-dev.yml`(内网 dev 库,与现有 pom.xml dev profile 明文同一惯例),生产/测试环境不得在配置文件写明文密码。
|
||||||
|
|
||||||
### 4.1 Entity 生成
|
### 4.1 Entity 生成
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ xq CLI ─────┘ (Go 单二进制, 同为 MCP 客户端) ┌──
|
|||||||
|
|
||||||
### 5.2 Web 管理后台(四个页面)
|
### 5.2 Web 管理后台(四个页面)
|
||||||
|
|
||||||
- **用户与 Token 管理**:人员选自现有 `user` 表;每人名下签发/禁用 token,展示 key 前缀、作用域、最后使用时间;选中人员展示其**数据权限归属**(权限组/isagent、所属代理、企业类型、可见范围说明,只读,便于排查"查不到数据")。
|
- **用户与 Token 管理**:人员选自现有 `user` 表,**只列企业账号**(`enterprise_type` 0=企业内部 / 1=代理,历史 NULL 保留),物业账号(=2)不进列表——Token 是给能查代理商/客户数据的人用的;每人名下签发/禁用 token,展示 key 前缀、作用域、最后使用时间;选中人员展示其**数据权限归属**(权限组/isagent、所属代理、企业类型、可见范围说明,只读,便于排查"查不到数据")。
|
||||||
- **工具管理**:工具列表来自服务端注册自动同步(工具名/分类/读写类型页面只读);展示每个工具被哪些作用域引用;「停用」为全局紧急开关。分类(category)在代码注册时由开发者声明(工具的业务域归属是客观属性,不允许页面上修改,避免与代码漂移)。
|
- **工具管理**:工具列表来自服务端注册自动同步(工具名/分类/读写类型页面只读);展示每个工具被哪些作用域引用;「停用」为全局紧急开关。分类(category)在代码注册时由开发者声明(工具的业务域归属是客观属性,不允许页面上修改,避免与代码漂移)。
|
||||||
- **作用域管理**:作用域 CRUD + 勾选工具(工具按 category 分组展示)。
|
- **作用域管理**:作用域 CRUD + 勾选工具(工具按 category 分组展示)。
|
||||||
- **审计日志**:按人/token/工具/时间筛选。
|
- **审计日志**:按人/token/工具/时间筛选。
|
||||||
@@ -223,6 +223,7 @@ xiaoqu-agent-service/
|
|||||||
- CLI 复用 `/mcp` 端点做 MCP 客户端,不开第二套服务 → 权限逻辑只有一份;Go 单二进制分发,与服务端 Java 栈解耦
|
- CLI 复用 `/mcp` 端点做 MCP 客户端,不开第二套服务 → 权限逻辑只有一份;Go 单二进制分发,与服务端 Java 栈解耦
|
||||||
- 不依赖老 jar,Entity 自行生成 → 与老系统零代码依赖
|
- 不依赖老 jar,Entity 自行生成 → 与老系统零代码依赖
|
||||||
- 前后端一体打包单 jar → 可逆决定,将来可拆
|
- 前后端一体打包单 jar → 可逆决定,将来可拆
|
||||||
|
- 列表类接口一律服务端分页,pageSize/rows 上限 100(超出截断为 100)→ 防止一次性拉全表,MCP 工具与后台 API 同一尺度
|
||||||
|
|
||||||
## 11. 开发环境(dev)
|
## 11. 开发环境(dev)
|
||||||
|
|
||||||
@@ -236,6 +237,14 @@ xiaoqu-agent-service/
|
|||||||
| Redis | 192.168.1.181:6379 | 密码见 pom dev | db5(`entity:User:{id}`、Dictionary)、db13 |
|
| Redis | 192.168.1.181:6379 | 密码见 pom dev | db5(`entity:User:{id}`、Dictionary)、db13 |
|
||||||
| ES | 192.168.1.152:9200 | 无认证 | 预留,一期不用 |
|
| 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` 后缀约定),首次部署前手工建库。
|
- 自有库表(§7)建在同实例新 schema:dev 用 `xiaoqu_agent_svc_d`(沿用 `_d` 后缀约定),首次部署前手工建库。
|
||||||
- 新系统与老的 dev 后端(Jetty 8091)可同时运行,互不干扰——注意两边连的是**同一套** dev 数据库和 Redis,老系统dev库的数据变动会同时影响两边的查询结果。
|
- 新系统与老的 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":{}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
也可在管理后台「工具管理」页查看全量注册工具(不受作用域过滤)。
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
# xiaoqu-ai 每用户 MCP Token 接入开发设计文档
|
||||||
|
|
||||||
|
## 1. 需求概述
|
||||||
|
|
||||||
|
### 1.1 需求信息
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 需求标题 | xiaoqu-ai 每用户独立 MCP Token 接入 |
|
||||||
|
| 优先级 | high |
|
||||||
|
| 目标客户端 | xiaoqu-ai iOS ClawMobile |
|
||||||
|
| 目标服务 | xiaoqu-agent-service `/mcp` |
|
||||||
|
| 认证要求 | 每个登录用户使用自己的 MCP Token |
|
||||||
|
| 预估工时 | 12-16h |
|
||||||
|
|
||||||
|
### 1.2 成功标准
|
||||||
|
|
||||||
|
- 用户 A 的 MCP 请求只能携带用户 A 的 Token,不能使用用户 B 或应用公共 Token。
|
||||||
|
- 登录、刷新 Token、切换账号、退出登录后,Claw/MCP 会话不会串用旧用户凭证。
|
||||||
|
- MCP 请求使用 `Authorization: Bearer <token>`,服务端继续执行用户、租户、作用域和审计校验。
|
||||||
|
- Token 不出现在 prompt、工具参数、普通日志、崩溃信息或 Git 文件中。
|
||||||
|
- 用户无 MCP Token、Token 过期、Token 被撤销时,客户端显示可恢复错误,不发送无凭证请求。
|
||||||
|
|
||||||
|
### 1.3 明确不做
|
||||||
|
|
||||||
|
- 不把一个固定 Token 写入 App、公共配置文件或所有用户共用。
|
||||||
|
- 不把数据库中保存的 Token 哈希当作客户端 Token 返回;明文只在签发响应中出现一次。
|
||||||
|
- 不让客户端提交任意 `user_id`、`tenant_id` 来冒充用户身份。
|
||||||
|
|
||||||
|
## 2. 现状分析
|
||||||
|
|
||||||
|
### 2.1 相关现有代码
|
||||||
|
|
||||||
|
| 文件 | 层级 | 当前功能 | 改动类型 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `xiaoqu-ai/ios/Sources/XiaoquCRM/Core/Dependencies/ClawBridge.swift` | iOS 依赖 | 静态初始化 Claw 配置,并创建 MCP Server 列表 | 修改 |
|
||||||
|
| `xiaoqu-ai/claw-code-parity/rust/crates/claw-mobile/src/ffi.rs` | Rust FFI | 接收 MCP 配置,连接、发现工具、调用工具 | 修改 |
|
||||||
|
| `xiaoqu-ai/claw-code-parity/rust/crates/claw-mobile/src/mcp_http.rs` | Rust HTTP | JSON-RPC HTTP/SSE MCP 客户端 | 修改 |
|
||||||
|
| `xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AkAuthFilter.java` | 服务鉴权 | 仅解析 `Authorization: Bearer <ak>` | 保持兼容 |
|
||||||
|
| `xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AuthService.java` | 服务鉴权 | AK SHA-256 哈希查询、用户加载、过期检查 | 修改/复用 |
|
||||||
|
| `xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminTokenService.java` | 服务 Token | 管理员为指定用户签发 AK,明文只返回一次 | 扩展复用 |
|
||||||
|
|
||||||
|
### 2.2 当前缺口
|
||||||
|
|
||||||
|
1. `ClawBridge.liveValue` 是静态值,Token 只在进程初始化时读取一次。
|
||||||
|
2. 当前 FFI 将 `apiKey` 发送为 `X-API-Key`,与服务端要求的 Bearer 不一致。
|
||||||
|
3. iOS FFI 未把配置文件中的 MCP Server 自动接入 `FfiEngineConfig`。
|
||||||
|
4. 多 MCP Client 共存时,工具调用没有稳定的“工具名 → 所属 Server”映射。
|
||||||
|
5. 当前管理端签发接口需要管理员会话,不适合作为普通用户登录后的自动取 Token 接口。
|
||||||
|
|
||||||
|
## 3. 方案设计
|
||||||
|
|
||||||
|
### 3.1 认证模型
|
||||||
|
|
||||||
|
采用两级凭证:
|
||||||
|
|
||||||
|
1. xiaoqu-ai 登录 `accessToken`:证明当前用户登录身份,只用于向业务后端换取 MCP Token。
|
||||||
|
2. MCP Token(`xqak_*`):绑定 `user_id`,受 MCP scope、租户/行级权限、过期时间和撤销状态约束,只用于 `/mcp`。
|
||||||
|
|
||||||
|
客户端不得把 `accessToken` 和 MCP Token 混为一谈。MCP 服务仍只接受 MCP Token。
|
||||||
|
|
||||||
|
### 3.2 Token 生命周期
|
||||||
|
|
||||||
|
| 场景 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| 登录成功 | 用当前用户登录态换取 MCP Token,按用户保存 |
|
||||||
|
| MCP Token 过期/401 | 清除当前用户 MCP Token,重新换取一次;同一请求不自动重放工具副作用 |
|
||||||
|
| accessToken 刷新 | 保留 MCP Token;若服务返回 401,再重新换取 |
|
||||||
|
| 切换账号 | 先断开旧 MCP Client,再加载新账号 Token 并建立新 Client |
|
||||||
|
| 退出登录 | 销毁旧 Claw/MCP Client,删除当前账号本地 MCP Token;服务端撤销或等待短期过期 |
|
||||||
|
| 多账号 | Keychain key 必须包含稳定用户 ID,禁止所有账号共用 `mcpToken` |
|
||||||
|
|
||||||
|
建议 MCP Token 默认有效期 15 分钟,并提供换取接口;长期 AK 仅用于受控开发/管理场景。
|
||||||
|
|
||||||
|
## 4. API 契约
|
||||||
|
|
||||||
|
### 4.1 POST `/api/v1/mcp/session-token`
|
||||||
|
|
||||||
|
描述: 使用当前 xiaoqu-ai 登录态,为当前用户签发一个短期 MCP Token。
|
||||||
|
|
||||||
|
认证:
|
||||||
|
|
||||||
|
- `Authorization: Bearer <xiaoqu-access-token>`
|
||||||
|
- 服务端必须从签名 Token 解析用户和租户,忽略请求体中的身份字段。
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
|
- `client`: string (required) — 固定为 `xiaoqu-ai-ios`,最长 64 字符。
|
||||||
|
- `device_id`: string (required) — 设备安装实例 ID,最长 128 字符;不得包含 Token。
|
||||||
|
|
||||||
|
响应 200:
|
||||||
|
|
||||||
|
- `token`: string (required) — MCP 明文 Token,仅本次返回。
|
||||||
|
- `expires_at`: datetime (required) — MCP Token 到期时间。
|
||||||
|
- `user_id`: int (required) — 服务端从登录态解析出的用户 ID。
|
||||||
|
- `tenant_id`: int (required) — 服务端解析出的租户 ID。
|
||||||
|
- `scope_version`: string (required) — 本次绑定权限版本,用于客户端诊断,不作为鉴权依据。
|
||||||
|
|
||||||
|
错误:
|
||||||
|
|
||||||
|
- `401`: 登录态缺失、无效或已过期。
|
||||||
|
- `403`: 用户无权使用 MCP 或租户未开通。
|
||||||
|
- `409`: 用户 Token 配置缺失/作用域未配置。
|
||||||
|
- `429`: 换取频率超限。
|
||||||
|
- `503`: MCP Token 服务暂不可用;客户端不得携带旧 Token 强行重试写操作。
|
||||||
|
|
||||||
|
服务端处理要求:
|
||||||
|
|
||||||
|
- 使用现有 `agent_api_key` 哈希存储和 `token_scope` 绑定逻辑。
|
||||||
|
- 每次签发生成新的随机 Token,明文仅返回一次;不能查询旧 Token 明文。
|
||||||
|
- 默认只绑定当前用户允许的只读作用域;高风险作用域必须单独审批。
|
||||||
|
- 签发、刷新、撤销写入审计日志,记录 `user_id`、`tenant_id`、client、device_id、结果和原因,不记录 Token。
|
||||||
|
|
||||||
|
### 4.2 POST `/mcp`
|
||||||
|
|
||||||
|
描述: MCP Streamable HTTP JSON-RPC 端点,现有接口保持不变。
|
||||||
|
|
||||||
|
请求 Header:
|
||||||
|
|
||||||
|
- `Authorization: Bearer <mcp-token>` (required)
|
||||||
|
- `Content-Type: application/json` (required)
|
||||||
|
- `Accept: application/json, text/event-stream` (required)
|
||||||
|
- `Mcp-Session-Id: <id>` (required after initialize response contains it)
|
||||||
|
|
||||||
|
请求/响应:
|
||||||
|
|
||||||
|
- 保持 MCP `initialize` → `notifications/initialized` → `tools/list` → `tools/call` 顺序。
|
||||||
|
- `tools/call` 必须继续执行 Token 作用域、用户和租户数据范围校验。
|
||||||
|
|
||||||
|
错误:
|
||||||
|
|
||||||
|
- `401`: MCP Token 缺失、无效、撤销或过期。
|
||||||
|
- `200` + MCP `isError=true`: Token 有效但无权调用该工具,客户端展示权限错误,不切换账号重试。
|
||||||
|
|
||||||
|
### 4.3 客户端配置文件
|
||||||
|
|
||||||
|
配置文件只保存非用户敏感配置:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"xiaoqu": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://<host>:8088/mcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Token 不写入该文件。客户端运行时读取当前用户 Token,并动态加入 `Authorization` Header。
|
||||||
|
|
||||||
|
## 5. 客户端实现设计
|
||||||
|
|
||||||
|
### 5.1 ClawBridge
|
||||||
|
|
||||||
|
- 将 `ClawBridge` 从静态全局凭证改为按当前用户创建的实例。
|
||||||
|
- `connectMcp(userID, mcpToken)` 创建只属于当前用户的 FFI Engine。
|
||||||
|
- `disconnectMcp()` 在切换账号、退出登录和重新换 Token 前调用。
|
||||||
|
- 不在 `liveValue` 捕获 `accessToken` 或 MCP Token;通过依赖注入传入当前账号凭证。
|
||||||
|
|
||||||
|
### 5.2 FFI / HTTP
|
||||||
|
|
||||||
|
- `FfiMcpServerConfig` 增加明确的认证模式,或直接将 `apiKey` 改为 `bearerToken`。
|
||||||
|
- 使用 `Authorization: Bearer <mcpToken>`,禁止发送 `X-API-Key`。
|
||||||
|
- `initialize` 后保存 `Mcp-Session-Id`,发送 `notifications/initialized`,再调用 `tools/list`。
|
||||||
|
- 工具发现时保存 `tool_name → server_name` 映射;调用只发送到所属 Server,不按 Client 顺序盲试。
|
||||||
|
- Token 过期只触发一次重新换取和重新连接;并发请求共享刷新锁,避免同一用户并发签发多个 Token。
|
||||||
|
|
||||||
|
### 5.3 Keychain
|
||||||
|
|
||||||
|
- Key 名:`mcpToken.<user_id>`,值为 MCP Token;使用 ThisDeviceOnly 存储。
|
||||||
|
- `device_id` 单独生成并持久化,不从 Token 派生。
|
||||||
|
- 日志只记录 Token 是否存在、剩余有效期和用户 ID,不记录 Token 内容。
|
||||||
|
|
||||||
|
## 6. 数据模型变更
|
||||||
|
|
||||||
|
### 6.1 现有表复用
|
||||||
|
|
||||||
|
| 表 | 用途 | 变更 |
|
||||||
|
|---|---|---|
|
||||||
|
| `agent_api_key` | MCP Token 哈希、用户绑定、状态、过期时间 | 复用;确认 `user_id/status/expire_time/key_hash` 索引 |
|
||||||
|
| `token_scope` | Token 与作用域绑定 | 复用 |
|
||||||
|
| `audit_log` | MCP 调用和 Token 生命周期审计 | 扩展事件类型/元数据(若当前字段不足) |
|
||||||
|
|
||||||
|
### 6.2 建议新增字段(仅在现有字段不足时)
|
||||||
|
|
||||||
|
| 表 | 字段 | 类型/约束 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `agent_api_key` | `source` | `VARCHAR(32) NOT NULL DEFAULT 'admin'` | `admin` / `user_session`,便于撤销和审计 |
|
||||||
|
| `agent_api_key` | `device_id` | `VARCHAR(128) NULL` | 用户会话 Token 的设备绑定 |
|
||||||
|
|
||||||
|
索引:`(user_id, source, status, expire_time)`。新增字段必须通过新的 Flyway migration,禁止修改已执行 migration。
|
||||||
|
|
||||||
|
## 7. 变更文件清单
|
||||||
|
|
||||||
|
### xiaoqu-agent-service
|
||||||
|
|
||||||
|
| 文件/目录 | 层级 | 类型 | 改动说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `security/` JWT 验证组件 | Security | 新增/修改 | 验证 xiaoqu 登录态并解析 user/tenant,拒绝客户端自报身份 |
|
||||||
|
| `mcp/` Token exchange service | Service | 新增 | 签发短期用户 MCP Token、作用域绑定、限流和审计 |
|
||||||
|
| `handler/` MCP credential controller | Handler | 新增 | 实现 `/api/v1/mcp/session-token` |
|
||||||
|
| `security/AkAuthFilter.java` | Filter | 修改 | 保持 Bearer MCP AK 兼容,补齐撤销/错误审计要求 |
|
||||||
|
| `db/migration/V__user_session_token.sql` | Migration | 条件新增 | 只有字段/索引确有需要时新增 |
|
||||||
|
| `src/test/...` | Test | 新增 | 用户隔离、过期、撤销、越权、限流和不记录明文测试 |
|
||||||
|
|
||||||
|
### xiaoqu-ai
|
||||||
|
|
||||||
|
| 文件/目录 | 层级 | 类型 | 改动说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `ios/.../ClawBridge.swift` | iOS | 修改 | 按登录用户注入 Token,重建/销毁 Claw 实例 |
|
||||||
|
| `claw-mobile/src/ffi.rs` | Rust FFI | 修改 | Bearer Header、会话通知、Server 路由映射 |
|
||||||
|
| `claw-mobile/src/mcp_http.rs` | Rust HTTP | 修改 | 通用 Header、Session ID 和 JSON-RPC 初始化流程 |
|
||||||
|
| `ios/.../KeychainClient.swift` | iOS 存储 | 修改 | 账号隔离的 MCP Token 存取和删除 |
|
||||||
|
| `ios/.../AuthFeature.swift` | 登录生命周期 | 修改 | 登录/刷新/切换/退出触发 MCP 凭证生命周期 |
|
||||||
|
| `ios/Tests/...` | Test | 新增 | 用户切换、401 刷新、Token 不串号测试 |
|
||||||
|
| `claw-code-parity/.../settings*.json` | 配置 | 修改 | 仅保存 URL,不保存用户 Token |
|
||||||
|
|
||||||
|
## 8. 开发任务拆分
|
||||||
|
|
||||||
|
| # | 任务标题 | 工时 | 依赖 | 完成标准 |
|
||||||
|
|---|---|---:|---|---|
|
||||||
|
| 1 | 服务端确认 JWT 与租户映射、Token exchange 契约 | 2h | - | 401/403/409/429 语义和 scope 默认值确定 |
|
||||||
|
| 2 | 服务端实现用户 MCP Token 签发与审计 | 4h | #1 | Token 只返回一次,哈希落库,绑定 user/tenant/scope |
|
||||||
|
| 3 | iOS Keychain 账号隔离和认证生命周期接入 | 3h | #1 | 登录/切换/退出无旧 Token 残留 |
|
||||||
|
| 4 | Rust MCP HTTP Bearer、Session、初始化通知 | 3h | #3 | initialize/list/call 真实握手成功 |
|
||||||
|
| 5 | MCP 工具到 Server 路由映射 | 2h | #4 | 多 Server 下工具调用不发错目标 |
|
||||||
|
| 6 | 集成与安全回归测试 | 2-4h | #2-#5 | 用户隔离、401 刷新、权限拒绝和日志脱敏通过 |
|
||||||
|
|
||||||
|
实施顺序:#1 → #2;#3 与 #4 可并行;完成后 #5 → #6。
|
||||||
|
|
||||||
|
## 9. 测试与验收
|
||||||
|
|
||||||
|
### 9.1 服务端
|
||||||
|
|
||||||
|
- 用户 A Token 调用 `tools/list` / `tools/call` 成功,审计 `user_id=A`。
|
||||||
|
- 用户 B Token 调用时只能看到/调用 B 作用域允许的数据。
|
||||||
|
- A 的 Token 调用不存在或已撤销 Token 返回 401;A 的 Token 调 B 身份参数不得改变审计身份。
|
||||||
|
- A 无某工具 scope 时返回 MCP `isError=true`,不得执行 Handler。
|
||||||
|
- 签发响应后数据库和日志中搜索不到 Token 明文。
|
||||||
|
|
||||||
|
### 9.2 iOS
|
||||||
|
|
||||||
|
- A 登录后抓包确认所有 MCP 请求为 `Authorization: Bearer <A-MCP-Token>`。
|
||||||
|
- 切换 B 后,旧连接关闭,后续请求只携带 B Token。
|
||||||
|
- A Token 401 时只刷新 A 的 Token,不影响 B 或其他账号。
|
||||||
|
- 退出后旧 Claw 不再产生网络请求。
|
||||||
|
- 配置文件、prompt、工具参数和日志不包含 Token。
|
||||||
|
|
||||||
|
### 9.3 联调命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用当前用户登录态换 MCP Token(示例,不把 Token 写入仓库)
|
||||||
|
curl -X POST http://<host>/api/v1/mcp/session-token \
|
||||||
|
-H 'Authorization: Bearer <xiaoqu-access-token>' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"client":"xiaoqu-ai-ios","device_id":"<device-id>"}'
|
||||||
|
|
||||||
|
# 使用返回的用户 MCP Token 完成 MCP 握手
|
||||||
|
curl -X POST http://<host>:8088/mcp \
|
||||||
|
-H 'Authorization: Bearer <user-mcp-token>' \
|
||||||
|
-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":"xiaoqu-ai","version":"1.0"}}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. 技术风险与回退
|
||||||
|
|
||||||
|
| 风险 | 等级 | 应对 |
|
||||||
|
|---|---|---|
|
||||||
|
| accessToken 与 MCP AK 类型混用 | 高 | 强制 Token exchange;服务端只接受明确的 MCP AK |
|
||||||
|
| 静态 Claw 实例持有旧用户 Token | 高 | 用户切换/退出先销毁实例,增加生命周期测试 |
|
||||||
|
| 多 MCP Server 工具路由错误 | 高 | 建立工具归属映射,不采用首个 Client 试调用 |
|
||||||
|
| Token 泄露到日志/崩溃 | 高 | 脱敏中间件、禁止打印 Header、静态扫描和测试 |
|
||||||
|
| Token exchange 不可用 | 中 | MCP 功能降级为不可用并提示重试;不得使用公共 Token 兜底 |
|
||||||
|
| 新字段迁移失败 | 中 | 字段变更独立 Flyway migration;回滚只撤销新增字段/索引 |
|
||||||
|
|
||||||
|
回退策略:保留现有管理员签发 Token 和 `/mcp` 协议不变;先关闭用户自动换取入口,客户端显示 MCP 不可用。禁止回退到全局共享 Token。
|
||||||
|
|
||||||
|
## 11. 设计结论
|
||||||
|
|
||||||
|
公共 MCP 配置文件只负责“连哪个服务”;用户 Token 由登录用户上下文动态决定。实现完成后,xiaoqu-ai 的每个用户拥有独立 MCP 权限边界,服务端仍能按用户、租户、作用域和审计链路追踪调用。
|
||||||
Reference in New Issue
Block a user