diff --git a/xiaoqu-agent-service/README.md b/xiaoqu-agent-service/README.md index c9f4dca..e6d4b68 100644 --- a/xiaoqu-agent-service/README.md +++ b/xiaoqu-agent-service/README.md @@ -2,7 +2,7 @@ 面向 MCP 客户端(Kimi Code / Claude 等)的小趣业务数据服务:独立 Spring Boot 应用,只读查询现有三库(m0 权限库 / m1 商城库 / m2 业务库),以 MCP(Streamable HTTP)形式对外暴露工具。 -设计方案见 `../xiaoqu-franchisee/doc/agent-mcp-service-design.md`。当前为 **P1 骨架**:工程初始化、版本组合验证、三数据源连通、codegen 跑通、demo 工具经 MCP 调通。鉴权 / 权限引擎 / 管理后台为 P2+,未实现。 +设计方案见 `../xiaoqu-franchisee/doc/agent-mcp-service-design.md`。当前进度:**P2 权限层已完成**(ak 鉴权、scope 校验、行级数据权限引擎、审计落库,均经 curl 实测)。管理后台为 P4,未实现。 ## 技术栈与已验证版本组合 @@ -23,23 +23,67 @@ 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 + 运营作用域授权) ├── src/main/java/me/iiv/agent/ │ ├── AgentServiceApplication.java -│ ├── config/ # M0/M1/M2DataSourceConfig(独立 SqlSessionFactory + MapperScan)、McpToolConfig -│ ├── mcp/tool/ # @Tool 工具(SysTools: sys.ping;AgentTools: agent.count) -│ ├── security/ permission/ admin/ common/ # P2+ 占位包 -│ ├── entity/m0、m2 mapper/m0、m1、m2 # codegen 生成物(提交进仓库) +│ ├── config/ # M0/M1/M2/SvcDataSourceConfig(独立 SqlSessionFactory + MapperScan)、McpToolConfig +│ ├── mcp/ # ToolObject 标记、@XqTool 元数据、ToolSyncService(启动同步 tool 表,只插不删) +│ │ ├── GuardedToolCallback(Provider) # 统一包装:scope 强制校验 + 审计 +│ │ └── tool/ # @Tool 工具(SysTools: sys.ping;AgentTools: agent.count / agent.list.query) +│ ├── security/ # AkAuthFilter(/mcp ak→401/AuthContext)、AuthService(ak 解析+用户加载)、ToolGuard +│ ├── permission/ # PermissionService:行级数据权限引擎(移植 getByIsAgent) +│ ├── common/ # AuditService(audit_log 落库) +│ ├── admin/ # P4 占位包 +│ ├── entity/m0、m2、svc mapper/m0、m1、m2、svc # codegen 生成物(提交进仓库) ├── src/main/resources/ -│ ├── application.yml # MCP server: protocol=STREAMABLE, 端点 /mcp;默认 dev profile -│ └── application-dev.yml # 三数据源 + Redis,密码用环境变量占位符带默认值 +│ ├── application.yml # MCP server: protocol=STREAMABLE, type=SYNC, 端点 /mcp;默认 dev profile +│ └── application-dev.yml # 四数据源 + Redis,密码用环境变量占位符带默认值 └── src/test/java/me/iiv/agent/codegen/CodeGenerator.java # run-once 代码生成器 ``` -数据源分包约定:`entity/m0|m1|m2` 与 `mapper/m0|m1|m2` 一一对应各自的 `SqlSessionFactory`(`@MapperScan` + `sqlSessionFactoryRef`)。 +数据源分包约定:`entity/m0|m1|m2|svc` 与 `mapper/...` 一一对应各自的 `SqlSessionFactory`(`@MapperScan` + `sqlSessionFactoryRef`)。m0/m1/m2 只读,svc(自有库)读写。 + +## 鉴权(P2) + +- `/mcp` 全路径要求 `Authorization: Bearer `,缺失/无效/禁用/过期 → 401 JSON。 +- 判定链(设计文档 §3.2):ak → SHA-256 hex → `agent_api_key` 匹配 → 解析出人(Redis db5 `entity:User:{id}` 热缓存优先,fastjson camelCase JSON 用 Jackson 解析;读不到回源 m0 `user` 表)→ AuthContext 存 ThreadLocal → 工具调用前 scope 校验 → 审计落库。 +- **ThreadLocal 实测结论**:`spring.ai.mcp.server.type=SYNC`(application.yml 已显式设置)时,Spring AI 1.1.8 的工具回调在 servlet 请求线程(`nio-8088-exec-*`)上同步执行,ThreadLocal 的 AuthContext 与 RequestContextHolder 均有效(已用真实 curl 验证:agent.list.query 按人过滤、audit_log 正确落 user_id/token_id/client_ip)。AkAuthFilter 在请求结束 finally 清理。 +- **已知限制**:`tools/list` 暂不按 key 过滤工具列表,所有工具可见;`tools/call` 时的 scope 强制校验是底线。 +- client_type 识别:`X-Client-Type` 头优先,否则 User-Agent 含 kimi→mcp-kimi / claude→mcp-claude / curl→cli,其余 unknown。 + +## 行级数据权限引擎(permission/PermissionService) + +移植老系统 `WrapperUtils.getByIsAgent` 语义,输出中性 id 集合,调用方拼 wrapper: + +| 方法 | 老系统来源 | 语义 | +|---|---|---| +| `isAgentAuthority(authorityId)` | `AuthorityServiceImpl.checkAuthorityIsagent` | 权限组 isagent=1 → 代理商视角 | +| `visibleAgentIds(user)` | `getByIsAgent` 主重载 | isagent!=1 返回 null(不过滤);否则 自己 + 直属下级(supervisor_id 一层)+ m2 warehouse_agent_auth 代管 manager_agent_id | +| `isSalesNonLeader(user)` | saleFlag 逻辑(:382-427) | Redis db5 `Dictionary` hash 的 authDeptId 命中 ",deptId," 且 authDeptPostId 不含 "postId,";字典缺失按不限制处理并告警 | +| `isPropertyUser(user)` | enterprise_type=2 规则 | 物业用户(后续 property 查询再限 property_id=自己) | + +`agent.list.query` 的行权限照搬老系统 `AgentController.listByPage`:isagent=1 → `supervisor_id = 用户agentId`(只查直属下级);isagent=0 → 不过滤;入参传 `id` → 精确查询(可查到自身,不加 supervisor 限制)。 + +## MCP 工具 + +| 工具名 | 分类 | 读写 | 说明 | +|---|---|---|---| +| `sys.ping` | system | read | 服务自检:服务名、版本、当前时间、Redis PING | +| `agent.count` | system | read | 查 m0 库 `agent` 表 `soft_delete=0` 总数 | +| `agent.list.query` | customer | read | 代理商分页查询(行级权限),入参 id/name/level/channelNo/page/rows | + +工具名使用点号分隔(MCP 规范允许 `[a-zA-Z0-9._-]`,实测 Spring AI 1.1.8 与握手流程均正常)。工具的 category/kind 由代码 `@XqTool` 注解声明,启动时同步进 `tool` 表(只插不删,管理员停用状态不会被覆盖)。新工具默认不属于任何作用域 = 任何 token 调不到,需管理员配 scope。 ## 启动 ```bash +# 首次:建自有库 + 种子数据(dev) +mysql -h192.168.1.181 -uroot -p < sql/V1__init.sql +# 启动一次让 ToolSyncService 同步 tool 表后: +mysql -h192.168.1.181 -uroot -p xiaoqu_agent_svc_d < sql/seed-dev.sql + ./mvnw clean package -DskipTests java -jar target/xiaoqu-agent-service.jar # 或 @@ -47,53 +91,41 @@ java -jar target/xiaoqu-agent-service.jar ``` - 默认 `spring.profiles.active=dev`,端口 **8088**,MCP 端点 `http://localhost:8088/mcp`。 -- 数据库/Redis 密码默认值仅供本地 dev,可用环境变量覆盖:`M0_DB_PASSWORD` / `M1_DB_PASSWORD` / `M2_DB_PASSWORD` / `REDIS_PASSWORD`。 +- 密码默认值仅供本地 dev,环境变量覆盖:`M0_DB_PASSWORD` / `M1_DB_PASSWORD` / `M2_DB_PASSWORD` / `SVC_DB_PASSWORD` / `REDIS_PASSWORD`。 -## MCP 工具 +## 测试 ak(仅 dev,明文只在此处与 sql/seed-dev.sql 出现,服务端只存 SHA-256) -| 工具名 | 说明 | -|---|---| -| `sys.ping` | 服务自检:服务名、版本、当前时间、Redis PING | -| `agent.count` | 查 m0 库 `agent` 表 `soft_delete=0` 总数(真实查库) | - -工具名使用点号分隔(MCP 规范允许 `[a-zA-Z0-9._-]`,实测 Spring AI 1.1.8 与握手流程均正常)。若未来接入的客户端不接受点号,改为下划线即可。 +| 用途 | 用户 | ak | +|---|---|---| +| 总部视角(isagent=0) | 46 代理商管理端 | `xqak_1d21057ad83203dbeb767eb5069701046b27debdc08a733c` | +| 代理商视角(isagent=1,agent_id=1,26 个直属下级) | 1000 斤斤计较 | `xqak_7df02ccee2133dc2cf951db12e3958557bdf2d30a91e7f08` | +| 无任何 scope(验证拒绝) | 1000 斤斤计较 | `xqak_5bdc0eac8c1cab535f22dc3e9757f72b3127de43a14997a7` | ## curl 验证(Streamable HTTP 握手) ```bash # 1) initialize(从响应头取 Mcp-Session-Id) curl -i -X POST http://localhost:8088/mcp \ + -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' -# 2) notifications/initialized(带 session id) -curl -X POST http://localhost:8088/mcp \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -H 'Mcp-Session-Id: <上一步的session-id>' \ - -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' - -# 3) tools/list +# 2) notifications/initialized → 3) tools/list → 4) tools/call,均带: +# -H 'Authorization: Bearer ' -H 'Mcp-Session-Id: ' curl -X POST http://localhost:8088/mcp \ + -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -H 'Mcp-Session-Id: ' \ - -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' - -# 4) tools/call -curl -X POST http://localhost:8088/mcp \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -H 'Mcp-Session-Id: ' \ - -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"agent.count","arguments":{}}}' + -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"agent.list.query","arguments":{"rows":100}}}' ``` -响应为 SSE 帧(`event: message` / `data: {...}`)。 +响应为 SSE 帧(`event: message` / `data: {...}`)。2026-09-03 实测:无 ak/无效 ak → 401;总部 ak 查 agent.list.query total=62(全量);代理商 ak total=26(与 `SELECT COUNT(*) FROM agent WHERE soft_delete=0 AND supervisor_id=1` 一致);无 scope 的 ak → `isError:true` + "该 token 的作用域不包含工具 [agent.list.query],请联系管理员在对应作用域开通";audit_log 记录 success/denied 与 client_type。 ## codegen(run-once) -表白名单:m0 `user, agent, authority, properyt, agent_manage_district_auth`;m2 `warehouse_agent_auth`(注:物业表真实表名为老系统遗留拼写 `properyt`)。生成物落在 `src/main/java` 对应分包并提交进仓库。 +表白名单:m0 `user, agent, authority, properyt, agent_manage_district_auth`;m2 `warehouse_agent_auth`;svc `agent_api_key, agent_scope, tool, scope_tool, token_scope, audit_log, admin_user`(注:物业表真实表名为老系统遗留拼写 `properyt`)。生成物落在 `src/main/java` 对应分包并提交进仓库。 ```bash # 生成(依赖已下载后执行;连 dev 库) diff --git a/xiaoqu-agent-service/sql/V1__init.sql b/xiaoqu-agent-service/sql/V1__init.sql new file mode 100644 index 0000000..7c5594e --- /dev/null +++ b/xiaoqu-agent-service/sql/V1__init.sql @@ -0,0 +1,128 @@ +-- ============================================================= +-- xiaoqu-agent-service 自有库初始化(dev 环境) +-- 设计文档:doc/agent-mcp-service-design.md §7 +-- 执行:mysql -h192.168.1.181 -uroot -p < sql/V1__init.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 '主键', + user_id BIGINT NOT NULL COMMENT '所属人(m0.user.id)', + key_prefix VARCHAR(16) NOT NULL COMMENT 'ak 前缀(展示用,如 xqak_abcd1234)', + key_hash CHAR(64) NOT NULL COMMENT 'ak 明文 SHA-256 hex', + memo VARCHAR(255) NULL COMMENT '用途备注(如 Kimi 用 / Claude 用)', + status TINYINT NOT NULL DEFAULT 1 COMMENT '1=正常 0=禁用', + expire_time DATETIME NULL COMMENT '过期时间,NULL=永久有效', + last_used_time DATETIME NULL COMMENT '最后使用时间', + create_id BIGINT NULL COMMENT '创建人', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + modify_id BIGINT NULL COMMENT '修改人', + modify_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + soft_delete TINYINT NOT NULL DEFAULT 0 COMMENT '0=正常 1=删除', + PRIMARY KEY (id), + UNIQUE KEY uk_key_hash (key_hash), + KEY idx_user_id (user_id) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='API token(ak)'; + +-- 作用域(避免使用疑似关键字的 scope 作为表名,定名 agent_scope) +CREATE TABLE IF NOT EXISTS agent_scope ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + name VARCHAR(64) NOT NULL COMMENT '作用域名称(如 运营)', + code VARCHAR(64) NOT NULL COMMENT '作用域编码(如 operation)', + memo VARCHAR(255) NULL COMMENT '备注', + status TINYINT NOT NULL DEFAULT 1 COMMENT '1=启用 0=停用', + create_id BIGINT NULL COMMENT '创建人', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + modify_id BIGINT NULL COMMENT '修改人', + modify_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + soft_delete TINYINT NOT NULL DEFAULT 0 COMMENT '0=正常 1=删除', + PRIMARY KEY (id), + UNIQUE KEY uk_code (code) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='作用域(工具集合)'; + +-- 工具注册表:启动时由服务端注册自动同步(只插不删) +CREATE TABLE IF NOT EXISTS tool ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + name VARCHAR(128) NOT NULL COMMENT '工具名(如 agent.list.query)', + category VARCHAR(64) NOT NULL COMMENT '分类(代码注册时声明,如 customer/system)', + description VARCHAR(512) NULL COMMENT '工具描述', + kind VARCHAR(8) NOT NULL DEFAULT 'read' COMMENT 'read=读 write=写', + status TINYINT NOT NULL DEFAULT 1 COMMENT '1=启用 0=停用(全局紧急开关)', + create_id BIGINT NULL COMMENT '创建人', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + modify_id BIGINT NULL COMMENT '修改人', + modify_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + soft_delete TINYINT NOT NULL DEFAULT 0 COMMENT '0=正常 1=删除', + PRIMARY KEY (id), + UNIQUE KEY uk_name (name) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='工具注册表'; + +-- 作用域-工具 +CREATE TABLE IF NOT EXISTS scope_tool ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + scope_id BIGINT NOT NULL COMMENT 'agent_scope.id', + tool_id BIGINT NOT NULL COMMENT 'tool.id', + create_id BIGINT NULL COMMENT '创建人', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + modify_id BIGINT NULL COMMENT '修改人', + modify_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + soft_delete TINYINT NOT NULL DEFAULT 0 COMMENT '0=正常 1=删除', + PRIMARY KEY (id), + UNIQUE KEY uk_scope_tool (scope_id, tool_id) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='作用域-工具关系'; + +-- token-作用域 +CREATE TABLE IF NOT EXISTS token_scope ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + token_id BIGINT NOT NULL COMMENT 'agent_api_key.id', + scope_id BIGINT NOT NULL COMMENT 'agent_scope.id', + create_id BIGINT NULL COMMENT '创建人', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + modify_id BIGINT NULL COMMENT '修改人', + modify_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + soft_delete TINYINT NOT NULL DEFAULT 0 COMMENT '0=正常 1=删除', + PRIMARY KEY (id), + UNIQUE KEY uk_token_scope (token_id, scope_id) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='token-作用域关系'; + +-- 审计日志 +CREATE TABLE IF NOT EXISTS audit_log ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + user_id BIGINT NULL COMMENT '调用人(m0.user.id)', + token_id BIGINT NULL COMMENT 'agent_api_key.id', + tool_name VARCHAR(128) NOT NULL COMMENT '工具名', + params_digest VARCHAR(500) NULL COMMENT '入参 JSON 摘要(截断 500 字,不含 ak)', + result_status VARCHAR(16) NOT NULL COMMENT 'success/denied/error', + result_message VARCHAR(500) NULL COMMENT '结果说明(拒绝原因/异常摘要)', + cost_ms BIGINT NULL COMMENT '耗时毫秒', + client_ip VARCHAR(64) NULL COMMENT '客户端 IP', + client_type VARCHAR(32) NULL COMMENT 'mcp-kimi/mcp-claude/cli/unknown', + create_id BIGINT NULL COMMENT '创建人', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + modify_id BIGINT NULL COMMENT '修改人', + modify_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + soft_delete TINYINT NOT NULL DEFAULT 0 COMMENT '0=正常 1=删除', + PRIMARY KEY (id), + KEY idx_user_tool (user_id, tool_name), + KEY idx_create_time (create_time) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='工具调用审计'; + +-- 后台管理员(P4 管理后台使用,与 ak 体系分离) +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', + name VARCHAR(64) NULL COMMENT '姓名', + status TINYINT NOT NULL DEFAULT 1 COMMENT '1=正常 0=禁用', + create_id BIGINT NULL COMMENT '创建人', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + modify_id BIGINT NULL COMMENT '修改人', + modify_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + soft_delete TINYINT NOT NULL DEFAULT 0 COMMENT '0=正常 1=删除', + PRIMARY KEY (id), + UNIQUE KEY uk_account (account) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='后台管理员'; diff --git a/xiaoqu-agent-service/sql/seed-dev.sql b/xiaoqu-agent-service/sql/seed-dev.sql new file mode 100644 index 0000000..5976331 --- /dev/null +++ b/xiaoqu-agent-service/sql/seed-dev.sql @@ -0,0 +1,53 @@ +-- ============================================================= +-- dev 种子数据(仅 dev 环境使用!) +-- 用户:46=代理商管理端(isagent=0 总部视角, authority_id=1) +-- 1000=斤斤计较(isagent=1 代理商视角, authority_id=108, agent_id=1 有 26 个直属下级) +-- token: +-- HQ user_id=46 xqak_1d21057ad83203dbeb767eb5069701046b27debdc08a733c +-- AGENT user_id=1000 xqak_7df02ccee2133dc2cf951db12e3958557bdf2d30a91e7f08 +-- NOSCOPE user_id=1000 xqak_5bdc0eac8c1cab535f22dc3e9757f72b3127de43a14997a7 (未授权任何 scope,验证拒绝) +-- 依赖:tool 表已由应用启动时 ToolSyncService 同步(sys.ping/agent.count/agent.list.query) +-- ============================================================= + +USE xiaoqu_agent_svc_d; + +START TRANSACTION; + +-- token(ak 只存 SHA-256 哈希;明文只保留在本文件顶部注释,仅 dev) +INSERT INTO agent_api_key (user_id, key_prefix, key_hash, memo, status, create_id) +SELECT 46, 'xqak_1d21', '9068d5a6406afdb853cdc9f5e45e16a5191255e2feb1aba171cd66ea13d5711c', 'dev 测试:总部视角(isagent=0)', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM agent_api_key WHERE key_hash = '9068d5a6406afdb853cdc9f5e45e16a5191255e2feb1aba171cd66ea13d5711c'); + +INSERT INTO agent_api_key (user_id, key_prefix, key_hash, memo, status, create_id) +SELECT 1000, 'xqak_7df0', '00ab77ba18c11c8870500e1c09a9e25747e4cf0d8f1c88ae255a2beca6eef007', 'dev 测试:代理商视角(isagent=1)', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM agent_api_key WHERE key_hash = '00ab77ba18c11c8870500e1c09a9e25747e4cf0d8f1c88ae255a2beca6eef007'); + +INSERT INTO agent_api_key (user_id, key_prefix, key_hash, memo, status, create_id) +SELECT 1000, 'xqak_5bdc', '85733c4eaab02e956bc555e0f25e5043629f9b8f8ad9ca39046735f5e817ca9c', 'dev 测试:无任何 scope 授权', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM agent_api_key WHERE key_hash = '85733c4eaab02e956bc555e0f25e5043629f9b8f8ad9ca39046735f5e817ca9c'); + +-- 作用域:运营 +INSERT INTO agent_scope (name, code, memo, status, create_id) +SELECT '运营', 'operation', 'dev 种子:运营作用域', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM agent_scope WHERE code = 'operation'); + +-- 作用域-工具:sys.ping / agent.count / agent.list.query +INSERT INTO scope_tool (scope_id, tool_id, create_id) +SELECT s.id, t.id, 0 FROM agent_scope s JOIN tool t +WHERE s.code = 'operation' AND t.name IN ('sys.ping', 'agent.count', 'agent.list.query') + AND NOT EXISTS (SELECT 1 FROM scope_tool st WHERE st.scope_id = s.id AND st.tool_id = t.id); + +-- token-作用域:HQ 与 AGENT 两把 token 授权"运营";NOSCOPE 不授权 +INSERT INTO token_scope (token_id, scope_id, create_id) +SELECT k.id, s.id, 0 FROM agent_api_key k JOIN agent_scope s +WHERE k.key_hash IN ('9068d5a6406afdb853cdc9f5e45e16a5191255e2feb1aba171cd66ea13d5711c', + '00ab77ba18c11c8870500e1c09a9e25747e4cf0d8f1c88ae255a2beca6eef007') + AND s.code = 'operation' + AND NOT EXISTS (SELECT 1 FROM token_scope ts WHERE ts.token_id = k.id AND ts.scope_id = s.id); + +COMMIT; + +-- 校验 +SELECT k.id, k.user_id, k.key_prefix, k.memo FROM agent_api_key k ORDER BY k.id; +SELECT s.id, s.code, t.name FROM agent_scope s JOIN scope_tool st ON s.id = st.scope_id JOIN tool t ON st.tool_id = t.id WHERE s.soft_delete = 0; +SELECT ts.token_id, ts.scope_id FROM token_scope ts WHERE ts.soft_delete = 0; diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/package-info.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/package-info.java index 63080ce..393c51b 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/package-info.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/package-info.java @@ -1,4 +1,4 @@ /** - * 管理后台REST,用户/token、作用域、审计(P4) + * 管理后台 REST:用户/token、作用域、审计(P4)。 */ package me.iiv.agent.admin; diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/common/AuditService.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/common/AuditService.java new file mode 100644 index 0000000..d678af2 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/common/AuditService.java @@ -0,0 +1,97 @@ +package me.iiv.agent.common; + +import jakarta.servlet.http.HttpServletRequest; +import me.iiv.agent.entity.svc.AuditLog; +import me.iiv.agent.mapper.svc.AuditLogMapper; +import me.iiv.agent.security.AuthContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +/** + * 工具调用审计(设计文档 §3.2 第 5 步)。不记录 ak。 + */ +@Component +public class AuditService { + + private static final Logger log = LoggerFactory.getLogger(AuditService.class); + + private static final int PARAMS_DIGEST_MAX = 500; + + final AuditLogMapper auditLogMapper; + + public AuditService(AuditLogMapper auditLogMapper) { + this.auditLogMapper = auditLogMapper; + } + + public void record(AuthContext ctx, String toolName, String paramsJson, + String resultStatus, String resultMessage, long costMs) { + try { + AuditLog auditLog = new AuditLog(); + auditLog.setUserId(ctx == null ? null : ctx.getUserId()); + auditLog.setTokenId(ctx == null ? null : ctx.getTokenId()); + auditLog.setToolName(toolName); + auditLog.setParamsDigest(truncate(paramsJson, PARAMS_DIGEST_MAX)); + auditLog.setResultStatus(resultStatus); + auditLog.setResultMessage(truncate(resultMessage, PARAMS_DIGEST_MAX)); + auditLog.setCostMs(costMs); + auditLog.setClientIp(clientIp()); + auditLog.setClientType(clientType()); + auditLogMapper.insert(auditLog); + } catch (Exception e) { + // 审计失败不影响主流程 + log.error("audit_log 落库失败 tool={}: {}", toolName, e.getMessage()); + } + } + + private static String truncate(String s, int max) { + if (s == null) { + return null; + } + return s.length() <= max ? s : s.substring(0, max); + } + + private static HttpServletRequest currentRequest() { + if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attrs) { + return attrs.getRequest(); + } + return null; + } + + private static String clientIp() { + HttpServletRequest request = currentRequest(); + return request == null ? null : request.getRemoteAddr(); + } + + /** + * client_type:X-Client-Type 头优先;否则 User-Agent 含 kimi→mcp-kimi, + * 含 claude→mcp-claude,含 curl→cli,其余 unknown。 + */ + private static String clientType() { + HttpServletRequest request = currentRequest(); + if (request == null) { + return "unknown"; + } + String explicit = request.getHeader("X-Client-Type"); + if (explicit != null && !explicit.isBlank()) { + return explicit; + } + String ua = request.getHeader("User-Agent"); + if (ua == null) { + return "unknown"; + } + String lower = ua.toLowerCase(); + if (lower.contains("kimi")) { + return "mcp-kimi"; + } + if (lower.contains("claude")) { + return "mcp-claude"; + } + if (lower.contains("curl")) { + return "cli"; + } + return "unknown"; + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/common/package-info.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/common/package-info.java index e5e1b0e..7b0a61e 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/common/package-info.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/common/package-info.java @@ -1,4 +1,4 @@ /** - * 审计AOP、限流、结果包装(P2+) + * 审计落库等通用组件。 */ package me.iiv.agent.common; diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/config/McpToolConfig.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/config/McpToolConfig.java index 03d430e..c51f083 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/config/McpToolConfig.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/config/McpToolConfig.java @@ -1,23 +1,29 @@ package me.iiv.agent.config; -import me.iiv.agent.mcp.tool.AgentTools; -import me.iiv.agent.mcp.tool.SysTools; +import me.iiv.agent.common.AuditService; +import me.iiv.agent.mcp.GuardedToolCallbackProvider; +import me.iiv.agent.mcp.ToolObject; +import me.iiv.agent.security.ToolGuard; import org.springframework.ai.tool.ToolCallbackProvider; import org.springframework.ai.tool.method.MethodToolCallbackProvider; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import java.util.List; + /** - * MCP 工具注册:@Tool 注解方法 → ToolCallbackProvider, + * MCP 工具注册:@Tool 注解方法 → MethodToolCallbackProvider, + * 再统一套 GuardedToolCallback(scope 强制校验 + 审计落库), * 由 spring-ai-starter-mcp-server-webmvc 自动暴露到 /mcp 端点。 */ @Configuration public class McpToolConfig { @Bean - public ToolCallbackProvider xiaoquTools(SysTools sysTools, AgentTools agentTools) { - return MethodToolCallbackProvider.builder() - .toolObjects(sysTools, agentTools) + public ToolCallbackProvider xiaoquTools(List toolObjects, ToolGuard toolGuard, AuditService auditService) { + ToolCallbackProvider methodProvider = MethodToolCallbackProvider.builder() + .toolObjects(toolObjects.toArray()) .build(); + return new GuardedToolCallbackProvider(methodProvider, toolGuard, auditService); } } diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/config/SvcDataSourceConfig.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/config/SvcDataSourceConfig.java new file mode 100644 index 0000000..69614a6 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/config/SvcDataSourceConfig.java @@ -0,0 +1,38 @@ +package me.iiv.agent.config; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.zaxxer.hikari.HikariDataSource; +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.jdbc.DataSourceBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.sql.DataSource; + +/** + * svc 数据源(自有库 xiaoqu_agent_svc_d,读写):token/scope/tool/audit 等。 + */ +@Configuration +@MapperScan(basePackages = "me.iiv.agent.mapper.svc", sqlSessionFactoryRef = "svcSqlSessionFactory") +public class SvcDataSourceConfig { + + @Bean(name = "svcDataSource") + @ConfigurationProperties(prefix = "app.datasource.svc") + public DataSource svcDataSource() { + return DataSourceBuilder.create().type(HikariDataSource.class).build(); + } + + @Bean(name = "svcSqlSessionFactory") + public SqlSessionFactory svcSqlSessionFactory(@Qualifier("svcDataSource") DataSource dataSource) throws Exception { + MybatisSqlSessionFactoryBean bean = new MybatisSqlSessionFactoryBean(); + bean.setDataSource(dataSource); + MybatisConfiguration configuration = new MybatisConfiguration(); + configuration.setMapUnderscoreToCamelCase(true); + bean.setConfiguration(configuration); + return bean.getObject(); + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/AdminUser.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/AdminUser.java new file mode 100644 index 0000000..f5301be --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/AdminUser.java @@ -0,0 +1,79 @@ +package me.iiv.agent.entity.svc; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.io.Serializable; +import java.util.Date; + +/** + *

+ * 后台管理员 + *

+ * + * @author codegen + * @since 2026-09-03 + */ +@Getter +@Setter +@ToString +@TableName("admin_user") +public class AdminUser implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 登录账号 + */ + private String account; + + /** + * 密码 SHA-256 hex + */ + private String passwordHash; + + /** + * 姓名 + */ + private String name; + + /** + * 1=正常 0=禁用 + */ + private Byte status; + + /** + * 创建人 + */ + private Long createId; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 修改人 + */ + private Long modifyId; + + /** + * 修改时间 + */ + private Date modifyTime; + + /** + * 0=正常 1=删除 + */ + private Byte softDelete; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/AgentApiKey.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/AgentApiKey.java new file mode 100644 index 0000000..d8e8f02 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/AgentApiKey.java @@ -0,0 +1,94 @@ +package me.iiv.agent.entity.svc; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.io.Serializable; +import java.util.Date; + +/** + *

+ * API token(ak) + *

+ * + * @author codegen + * @since 2026-09-03 + */ +@Getter +@Setter +@ToString +@TableName("agent_api_key") +public class AgentApiKey implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 所属人(m0.user.id) + */ + private Long userId; + + /** + * ak 前缀(展示用,如 xqak_abcd1234) + */ + private String keyPrefix; + + /** + * ak 明文 SHA-256 hex + */ + private String keyHash; + + /** + * 用途备注(如 Kimi 用 / Claude 用) + */ + private String memo; + + /** + * 1=正常 0=禁用 + */ + private Byte status; + + /** + * 过期时间,NULL=永久有效 + */ + private Date expireTime; + + /** + * 最后使用时间 + */ + private Date lastUsedTime; + + /** + * 创建人 + */ + private Long createId; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 修改人 + */ + private Long modifyId; + + /** + * 修改时间 + */ + private Date modifyTime; + + /** + * 0=正常 1=删除 + */ + private Byte softDelete; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/AgentScope.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/AgentScope.java new file mode 100644 index 0000000..d719a60 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/AgentScope.java @@ -0,0 +1,79 @@ +package me.iiv.agent.entity.svc; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.io.Serializable; +import java.util.Date; + +/** + *

+ * 作用域(工具集合) + *

+ * + * @author codegen + * @since 2026-09-03 + */ +@Getter +@Setter +@ToString +@TableName("agent_scope") +public class AgentScope implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 作用域名称(如 运营) + */ + private String name; + + /** + * 作用域编码(如 operation) + */ + private String code; + + /** + * 备注 + */ + private String memo; + + /** + * 1=启用 0=停用 + */ + private Byte status; + + /** + * 创建人 + */ + private Long createId; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 修改人 + */ + private Long modifyId; + + /** + * 修改时间 + */ + private Date modifyTime; + + /** + * 0=正常 1=删除 + */ + private Byte softDelete; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/AuditLog.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/AuditLog.java new file mode 100644 index 0000000..834727f --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/AuditLog.java @@ -0,0 +1,104 @@ +package me.iiv.agent.entity.svc; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.io.Serializable; +import java.util.Date; + +/** + *

+ * 工具调用审计 + *

+ * + * @author codegen + * @since 2026-09-03 + */ +@Getter +@Setter +@ToString +@TableName("audit_log") +public class AuditLog implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 调用人(m0.user.id) + */ + private Long userId; + + /** + * agent_api_key.id + */ + private Long tokenId; + + /** + * 工具名 + */ + private String toolName; + + /** + * 入参 JSON 摘要(截断 500 字,不含 ak) + */ + private String paramsDigest; + + /** + * success/denied/error + */ + private String resultStatus; + + /** + * 结果说明(拒绝原因/异常摘要) + */ + private String resultMessage; + + /** + * 耗时毫秒 + */ + private Long costMs; + + /** + * 客户端 IP + */ + private String clientIp; + + /** + * mcp-kimi/mcp-claude/cli/unknown + */ + private String clientType; + + /** + * 创建人 + */ + private Long createId; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 修改人 + */ + private Long modifyId; + + /** + * 修改时间 + */ + private Date modifyTime; + + /** + * 0=正常 1=删除 + */ + private Byte softDelete; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/ScopeTool.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/ScopeTool.java new file mode 100644 index 0000000..007e5dc --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/ScopeTool.java @@ -0,0 +1,69 @@ +package me.iiv.agent.entity.svc; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.io.Serializable; +import java.util.Date; + +/** + *

+ * 作用域-工具关系 + *

+ * + * @author codegen + * @since 2026-09-03 + */ +@Getter +@Setter +@ToString +@TableName("scope_tool") +public class ScopeTool implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * agent_scope.id + */ + private Long scopeId; + + /** + * tool.id + */ + private Long toolId; + + /** + * 创建人 + */ + private Long createId; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 修改人 + */ + private Long modifyId; + + /** + * 修改时间 + */ + private Date modifyTime; + + /** + * 0=正常 1=删除 + */ + private Byte softDelete; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/TokenScope.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/TokenScope.java new file mode 100644 index 0000000..e29b30b --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/TokenScope.java @@ -0,0 +1,69 @@ +package me.iiv.agent.entity.svc; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.io.Serializable; +import java.util.Date; + +/** + *

+ * token-作用域关系 + *

+ * + * @author codegen + * @since 2026-09-03 + */ +@Getter +@Setter +@ToString +@TableName("token_scope") +public class TokenScope implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * agent_api_key.id + */ + private Long tokenId; + + /** + * agent_scope.id + */ + private Long scopeId; + + /** + * 创建人 + */ + private Long createId; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 修改人 + */ + private Long modifyId; + + /** + * 修改时间 + */ + private Date modifyTime; + + /** + * 0=正常 1=删除 + */ + private Byte softDelete; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/Tool.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/Tool.java new file mode 100644 index 0000000..6cf9b15 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/Tool.java @@ -0,0 +1,82 @@ +package me.iiv.agent.entity.svc; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.io.Serializable; +import java.util.Date; + +/** + *

+ * 工具注册表 + *

+ * + * @author codegen + * @since 2026-09-03 + */ +@Getter +@Setter +@ToString +public class Tool implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 主键 + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 工具名(如 agent.list.query) + */ + private String name; + + /** + * 分类(代码注册时声明,如 customer/system) + */ + private String category; + + /** + * 工具描述 + */ + private String description; + + /** + * read=读 write=写 + */ + private String kind; + + /** + * 1=启用 0=停用(全局紧急开关) + */ + private Byte status; + + /** + * 创建人 + */ + private Long createId; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 修改人 + */ + private Long modifyId; + + /** + * 修改时间 + */ + private Date modifyTime; + + /** + * 0=正常 1=删除 + */ + private Byte softDelete; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/svc/AdminUserMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/svc/AdminUserMapper.java new file mode 100644 index 0000000..250d4c5 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/svc/AdminUserMapper.java @@ -0,0 +1,16 @@ +package me.iiv.agent.mapper.svc; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.svc.AdminUser; + +/** + *

+ * 后台管理员 Mapper 接口 + *

+ * + * @author codegen + * @since 2026-09-03 + */ +public interface AdminUserMapper extends BaseMapper { + +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/svc/AgentApiKeyMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/svc/AgentApiKeyMapper.java new file mode 100644 index 0000000..a9d5d6d --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/svc/AgentApiKeyMapper.java @@ -0,0 +1,16 @@ +package me.iiv.agent.mapper.svc; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.svc.AgentApiKey; + +/** + *

+ * API token(ak) Mapper 接口 + *

+ * + * @author codegen + * @since 2026-09-03 + */ +public interface AgentApiKeyMapper extends BaseMapper { + +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/svc/AgentScopeMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/svc/AgentScopeMapper.java new file mode 100644 index 0000000..0e54758 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/svc/AgentScopeMapper.java @@ -0,0 +1,16 @@ +package me.iiv.agent.mapper.svc; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.svc.AgentScope; + +/** + *

+ * 作用域(工具集合) Mapper 接口 + *

+ * + * @author codegen + * @since 2026-09-03 + */ +public interface AgentScopeMapper extends BaseMapper { + +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/svc/AuditLogMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/svc/AuditLogMapper.java new file mode 100644 index 0000000..349fcbf --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/svc/AuditLogMapper.java @@ -0,0 +1,16 @@ +package me.iiv.agent.mapper.svc; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.svc.AuditLog; + +/** + *

+ * 工具调用审计 Mapper 接口 + *

+ * + * @author codegen + * @since 2026-09-03 + */ +public interface AuditLogMapper extends BaseMapper { + +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/svc/ScopeToolMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/svc/ScopeToolMapper.java new file mode 100644 index 0000000..a02cb0a --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/svc/ScopeToolMapper.java @@ -0,0 +1,16 @@ +package me.iiv.agent.mapper.svc; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.svc.ScopeTool; + +/** + *

+ * 作用域-工具关系 Mapper 接口 + *

+ * + * @author codegen + * @since 2026-09-03 + */ +public interface ScopeToolMapper extends BaseMapper { + +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/svc/TokenScopeMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/svc/TokenScopeMapper.java new file mode 100644 index 0000000..00daeac --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/svc/TokenScopeMapper.java @@ -0,0 +1,16 @@ +package me.iiv.agent.mapper.svc; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.svc.TokenScope; + +/** + *

+ * token-作用域关系 Mapper 接口 + *

+ * + * @author codegen + * @since 2026-09-03 + */ +public interface TokenScopeMapper extends BaseMapper { + +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/svc/ToolMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/svc/ToolMapper.java new file mode 100644 index 0000000..21e034f --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/svc/ToolMapper.java @@ -0,0 +1,16 @@ +package me.iiv.agent.mapper.svc; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.svc.Tool; + +/** + *

+ * 工具注册表 Mapper 接口 + *

+ * + * @author codegen + * @since 2026-09-03 + */ +public interface ToolMapper extends BaseMapper { + +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/GuardedToolCallback.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/GuardedToolCallback.java new file mode 100644 index 0000000..a10ba3a --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/GuardedToolCallback.java @@ -0,0 +1,66 @@ +package me.iiv.agent.mcp; + +import me.iiv.agent.common.AuditService; +import me.iiv.agent.security.AuthContext; +import me.iiv.agent.security.AuthContextHolder; +import me.iiv.agent.security.ToolGuard; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.ToolDefinition; +import org.springframework.ai.tool.metadata.ToolMetadata; + +/** + * 工具调用包装:scope 强制校验(拒绝时友好报错)+ 审计落库。 + */ +public class GuardedToolCallback implements ToolCallback { + + private final ToolCallback delegate; + private final ToolGuard toolGuard; + private final AuditService auditService; + + public GuardedToolCallback(ToolCallback delegate, ToolGuard toolGuard, AuditService auditService) { + this.delegate = delegate; + this.toolGuard = toolGuard; + this.auditService = auditService; + } + + @Override + public ToolDefinition getToolDefinition() { + return delegate.getToolDefinition(); + } + + @Override + public ToolMetadata getToolMetadata() { + return delegate.getToolMetadata(); + } + + @Override + public String call(String toolInput) { + return call(toolInput, null); + } + + @Override + public String call(String toolInput, ToolContext toolContext) { + String toolName = getToolDefinition().name(); + long start = System.currentTimeMillis(); + AuthContext ctx = AuthContextHolder.get(); + if (ctx == null) { + // AkAuthFilter 已拦截无 ak 请求,走到这里说明上下文丢失(如异步线程),按服务端错误处理 + auditService.record(null, toolName, toolInput, "error", "鉴权上下文缺失", 0); + throw new IllegalStateException("鉴权上下文缺失,请检查 MCP server type 是否为 SYNC"); + } + if (!toolGuard.isAllowed(ctx.getTokenId(), toolName)) { + String message = "该 token 的作用域不包含工具 [" + toolName + "],请联系管理员在对应作用域开通"; + auditService.record(ctx, toolName, toolInput, "denied", message, System.currentTimeMillis() - start); + throw new ToolAccessDeniedException(message); + } + try { + String result = toolContext == null ? delegate.call(toolInput) : delegate.call(toolInput, toolContext); + auditService.record(ctx, toolName, toolInput, "success", null, System.currentTimeMillis() - start); + return result; + } catch (Exception e) { + auditService.record(ctx, toolName, toolInput, "error", e.getMessage(), System.currentTimeMillis() - start); + throw e; + } + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/GuardedToolCallbackProvider.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/GuardedToolCallbackProvider.java new file mode 100644 index 0000000..d5d271f --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/GuardedToolCallbackProvider.java @@ -0,0 +1,31 @@ +package me.iiv.agent.mcp; + +import me.iiv.agent.common.AuditService; +import me.iiv.agent.security.ToolGuard; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.ToolCallbackProvider; + +import java.util.Arrays; + +/** + * 给每个 ToolCallback 套上 GuardedToolCallback(scope 校验 + 审计)的 Provider。 + */ +public class GuardedToolCallbackProvider implements ToolCallbackProvider { + + private final ToolCallbackProvider delegate; + private final ToolGuard toolGuard; + private final AuditService auditService; + + public GuardedToolCallbackProvider(ToolCallbackProvider delegate, ToolGuard toolGuard, AuditService auditService) { + this.delegate = delegate; + this.toolGuard = toolGuard; + this.auditService = auditService; + } + + @Override + public ToolCallback[] getToolCallbacks() { + return Arrays.stream(delegate.getToolCallbacks()) + .map(callback -> new GuardedToolCallback(callback, toolGuard, auditService)) + .toArray(ToolCallback[]::new); + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolAccessDeniedException.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolAccessDeniedException.java new file mode 100644 index 0000000..23d7e01 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolAccessDeniedException.java @@ -0,0 +1,11 @@ +package me.iiv.agent.mcp; + +/** + * 工具访问被拒绝(token 的作用域并集不包含该工具,或工具已停用)。 + */ +public class ToolAccessDeniedException extends RuntimeException { + + public ToolAccessDeniedException(String message) { + super(message); + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolObject.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolObject.java new file mode 100644 index 0000000..1f59410 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolObject.java @@ -0,0 +1,8 @@ +package me.iiv.agent.mcp; + +/** + * 标记接口:实现类为 MCP 工具持有者(方法上带 @Tool + @XqTool), + * 用于 ToolSyncService 启动同步与 McpToolConfig 注册。 + */ +public interface ToolObject { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolSyncService.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolSyncService.java new file mode 100644 index 0000000..94b9387 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolSyncService.java @@ -0,0 +1,64 @@ +package me.iiv.agent.mcp; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import me.iiv.agent.entity.svc.Tool; +import me.iiv.agent.mapper.svc.ToolMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Method; +import java.util.List; + +/** + * 工具注册同步(设计文档 §3.1):启动时把 @Tool + @XqTool 注解声明的工具 + * 同步进 tool 表(name/category/kind/description/status),只插不删—— + * 已存在的工具(含被管理员停用的)保持原状。 + */ +@Component +public class ToolSyncService implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(ToolSyncService.class); + + final ToolMapper toolMapper; + final List toolObjects; + + public ToolSyncService(ToolMapper toolMapper, List toolObjects) { + this.toolMapper = toolMapper; + this.toolObjects = toolObjects; + } + + @Override + public void run(ApplicationArguments args) { + int inserted = 0; + for (Object toolObject : toolObjects) { + for (Method method : toolObject.getClass().getMethods()) { + org.springframework.ai.tool.annotation.Tool toolAnnotation = + method.getAnnotation(org.springframework.ai.tool.annotation.Tool.class); + if (toolAnnotation == null) { + continue; + } + XqTool xqTool = method.getAnnotation(XqTool.class); + String name = toolAnnotation.name().isBlank() ? method.getName() : toolAnnotation.name(); + Long exists = toolMapper.selectCount(new LambdaQueryWrapper() + .eq(Tool::getSoftDelete, 0) + .eq(Tool::getName, name)); + if (exists != null && exists > 0) { + continue; + } + Tool tool = new Tool(); + tool.setName(name); + tool.setCategory(xqTool == null ? "uncategorized" : xqTool.category()); + tool.setDescription(toolAnnotation.description()); + tool.setKind(xqTool == null ? "read" : xqTool.kind()); + tool.setStatus((byte) 1); + toolMapper.insert(tool); + inserted++; + log.info("工具注册同步:新增 tool {}", name); + } + } + log.info("工具注册同步完成,新增 {} 个", inserted); + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/XqTool.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/XqTool.java new file mode 100644 index 0000000..ab2ceb3 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/XqTool.java @@ -0,0 +1,21 @@ +package me.iiv.agent.mcp; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 工具元数据声明:分类(category)与读写属性(kind)是代码内客观属性, + * 启动时同步进 tool 表,管理页面只读(设计文档 §5.2)。 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface XqTool { + + /** 业务域分类,如 customer / system / warehouse */ + String category(); + + /** read=读 write=写 */ + String kind() default "read"; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/AgentTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/AgentTools.java index f9c90e3..c25629b 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/AgentTools.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/AgentTools.java @@ -2,25 +2,42 @@ package me.iiv.agent.mcp.tool; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import me.iiv.agent.entity.m0.Agent; +import me.iiv.agent.entity.m0.User; import me.iiv.agent.mapper.m0.AgentMapper; +import me.iiv.agent.mcp.ToolObject; +import me.iiv.agent.mcp.XqTool; +import me.iiv.agent.permission.PermissionService; +import me.iiv.agent.security.AuthContextHolder; import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import java.text.SimpleDateFormat; +import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; /** - * 代理商域 demo 工具:证明 m0 数据源连通(真实查库)。 + * 代理商域工具:agent.count(demo)+ agent.list.query(行级权限验证用基础版, + * 语义照搬老系统 AgentController.listByPage)。 */ @Component -public class AgentTools { +public class AgentTools implements ToolObject { + + private static final int DEFAULT_ROWS = 20; + private static final int MAX_ROWS = 100; final AgentMapper agentMapper; + final PermissionService permissionService; - public AgentTools(AgentMapper agentMapper) { + public AgentTools(AgentMapper agentMapper, PermissionService permissionService) { this.agentMapper = agentMapper; + this.permissionService = permissionService; } + @XqTool(category = "system", kind = "read") @Tool(name = "agent.count", description = "统计 m0 库 agent 表中 soft_delete=0 的代理商总数") public Map agentCount() { Long count = agentMapper.selectCount( @@ -29,4 +46,75 @@ public class AgentTools { result.put("count", count); return result; } + + /** + * 查询代理商列表。行权限照搬老系统 AgentController.listByPage: + * 权限组 isagent=1 时只查直属下级(supervisor_id = 用户 agentId);isagent=0 不过滤。 + * 入参传 id 时精确查询(可查到自身,此时不加 supervisor 限制)。 + */ + @XqTool(category = "customer", kind = "read") + @Tool(name = "agent.list.query", description = "查询代理商列表(分页)。代理商身份的用户只能查到直属下级代理;" + + "总部身份看全部。传 id 时为精确查询。") + public Map agentListQuery( + @ToolParam(required = false, description = "代理商 id,精确查询(可查到自身)") Long id, + @ToolParam(required = false, description = "代理商名称,模糊匹配") String name, + @ToolParam(required = false, description = "级别:0总部/1一级/2二级") Integer level, + @ToolParam(required = false, description = "渠道编号:Z/F/D") String channelNo, + @ToolParam(required = false, description = "页码,默认 1") Integer page, + @ToolParam(required = false, description = "每页条数,默认 20,上限 100") Integer rows) { + User user = AuthContextHolder.get().getUser(); + int pageNo = page == null || page < 1 ? 1 : page; + int pageSize = rows == null || rows < 1 ? DEFAULT_ROWS : Math.min(rows, MAX_ROWS); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(Agent::getSoftDelete, 0); + if (id != null) { + wrapper.eq(Agent::getId, id); + } else { + if (StringUtils.hasText(name)) { + wrapper.like(Agent::getName, name); + } + if (level != null) { + wrapper.eq(Agent::getLevel, level); + } + if (StringUtils.hasText(channelNo)) { + wrapper.eq(Agent::getChannelNo, channelNo); + } + if (permissionService.isAgentAuthority(user.getAuthorityId())) { + wrapper.eq(Agent::getSupervisorId, user.getAgentId()); + } + } + + Long total = agentMapper.selectCount(wrapper); + wrapper.select(Agent::getId, Agent::getName, Agent::getLevel, Agent::getChannelNo, + Agent::getSerialnumber, Agent::getProvince, Agent::getCity, + Agent::getContact, Agent::getPhone, Agent::getCreateTime) + .orderByDesc(Agent::getId) + .last("LIMIT " + (long) (pageNo - 1) * pageSize + "," + pageSize); + List agents = agentMapper.selectList(wrapper); + + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + List> records = new ArrayList<>(); + for (Agent agent : agents) { + Map record = new LinkedHashMap<>(); + record.put("id", agent.getId()); + record.put("name", agent.getName()); + record.put("level", agent.getLevel()); + record.put("channelNo", agent.getChannelNo()); + record.put("serialnumber", agent.getSerialnumber()); + record.put("province", agent.getProvince()); + record.put("city", agent.getCity()); + record.put("contact", agent.getContact()); + record.put("phone", agent.getPhone()); + record.put("createTime", agent.getCreateTime() == null ? null : sdf.format(agent.getCreateTime())); + records.add(record); + } + + Map result = new LinkedHashMap<>(); + result.put("total", total); + result.put("page", pageNo); + result.put("rows", pageSize); + result.put("records", records); + return result; + } } diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/SysTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/SysTools.java index 5e77aab..92475f1 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/SysTools.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/SysTools.java @@ -1,5 +1,7 @@ package me.iiv.agent.mcp.tool; +import me.iiv.agent.mcp.ToolObject; +import me.iiv.agent.mcp.XqTool; import org.springframework.ai.tool.annotation.Tool; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.StringRedisTemplate; @@ -14,7 +16,7 @@ import java.util.Map; * 系统级 demo 工具:证明 MCP 链路连通。 */ @Component -public class SysTools { +public class SysTools implements ToolObject { @Value("${spring.application.name}") String appName; @@ -28,6 +30,7 @@ public class SysTools { this.redisTemplate = redisTemplate; } + @XqTool(category = "system", kind = "read") @Tool(name = "sys.ping", description = "服务自检:返回服务名、版本、当前时间与 Redis 连通性") public Map ping() { Map result = new LinkedHashMap<>(); diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java new file mode 100644 index 0000000..b7e0255 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java @@ -0,0 +1,106 @@ +package me.iiv.agent.permission; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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.m2.WarehouseAgentAuth; +import me.iiv.agent.mapper.m0.AgentMapper; +import me.iiv.agent.mapper.m0.AuthorityMapper; +import me.iiv.agent.mapper.m2.WarehouseAgentAuthMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 行级数据权限引擎:移植老系统 WrapperUtils.getByIsAgent 系列语义 + * (xiaoqu-franchisee utils/WrapperUtils.java :652-731 及 :382-427)。 + * 输出中性的 id 集合,调用方自行拼 wrapper.in("agent_id", ids);不做跨库 JOIN。 + */ +@Component +public class PermissionService { + + private static final Logger log = LoggerFactory.getLogger(PermissionService.class); + + /** 老系统 Redis db5 字典 hash key(Constants.Dictionary) */ + private static final String DICTIONARY_KEY = "Dictionary"; + private static final String DICT_FIELD_AUTH_DEPT_ID = "authDeptId"; + private static final String DICT_FIELD_AUTH_DEPT_POST_ID = "authDeptPostId"; + + final AuthorityMapper authorityMapper; + final AgentMapper agentMapper; + final WarehouseAgentAuthMapper warehouseAgentAuthMapper; + final StringRedisTemplate redisTemplate; + + public PermissionService(AuthorityMapper authorityMapper, AgentMapper agentMapper, + WarehouseAgentAuthMapper warehouseAgentAuthMapper, StringRedisTemplate redisTemplate) { + this.authorityMapper = authorityMapper; + this.agentMapper = agentMapper; + this.warehouseAgentAuthMapper = warehouseAgentAuthMapper; + this.redisTemplate = redisTemplate; + } + + /** 权限组 isagent=1 → 代理商视角(对应 AuthorityServiceImpl.checkAuthorityIsagent) */ + public boolean isAgentAuthority(Long authorityId) { + if (authorityId == null) { + return false; + } + Authority authority = authorityMapper.selectById(authorityId); + return authority != null && authority.getIsagent() != null && authority.getIsagent() == 1; + } + + /** + * 可见 agent id 集合。返回 null 表示不过滤(isagent != 1,总部/管理员看全部)。 + * isagent=1 时:自己 agentId + 直属下级代理(agent.supervisor_id=自己,只取一层) + * + m2 warehouse_agent_auth 中该用户被授权的 manager_agent_id(代管扩展)。 + */ + public List visibleAgentIds(User user) { + if (!isAgentAuthority(user.getAuthorityId())) { + return null; + } + Set ids = new LinkedHashSet<>(); + if (user.getAgentId() != null) { + List subordinates = agentMapper.selectList(new LambdaQueryWrapper() + .eq(Agent::getSoftDelete, 0) + .eq(Agent::getSupervisorId, user.getAgentId())); + ids.addAll(subordinates.stream().map(Agent::getId).collect(Collectors.toList())); + ids.add(user.getAgentId()); + } + List auths = warehouseAgentAuthMapper.selectList(new LambdaQueryWrapper() + .eq(WarehouseAgentAuth::getSoftDelete, 0) + .eq(WarehouseAgentAuth::getUserId, user.getId())); + ids.addAll(auths.stream().map(WarehouseAgentAuth::getManagerAgentId).collect(Collectors.toList())); + return new ArrayList<>(ids); + } + + /** + * 销售部门非负责人判定(老系统 getByIsAgent 内 saleFlag 逻辑): + * Redis db5 Dictionary hash 的 authDeptId 命中 ",deptId," 且 authDeptPostId 不命中 "postId,"。 + * 字典缺失时按"不限制"处理并告警(与老系统 NPE 直接报错的行为不同,属于有意收敛)。 + */ + public boolean isSalesNonLeader(User user) { + if (user.getDeptId() == null || user.getPostId() == null) { + return false; + } + Object authDeptId = redisTemplate.opsForHash().get(DICTIONARY_KEY, DICT_FIELD_AUTH_DEPT_ID); + Object authDeptPostId = redisTemplate.opsForHash().get(DICTIONARY_KEY, DICT_FIELD_AUTH_DEPT_POST_ID); + if (authDeptId == null || authDeptPostId == null) { + log.warn("Redis Dictionary 缺少 {}/{} 字段,销售部门限制未生效", DICT_FIELD_AUTH_DEPT_ID, DICT_FIELD_AUTH_DEPT_POST_ID); + return false; + } + return authDeptId.toString().contains("," + user.getDeptId() + ",") + && !authDeptPostId.toString().contains(user.getPostId() + ","); + } + + /** 物业用户(enterprise_type=2):老系统语义为再限 property_id=自己 */ + public boolean isPropertyUser(User user) { + return user.getEnterpriseType() != null && user.getEnterpriseType() == 2; + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/package-info.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/package-info.java index 7509614..7b72d4d 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/package-info.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/package-info.java @@ -1,4 +1,4 @@ /** - * 行级数据权限引擎,移植getByIsAgent规则(P2) + * 行级数据权限引擎(移植老系统 WrapperUtils.getByIsAgent 系列语义)。 */ package me.iiv.agent.permission; diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AkAuthFilter.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AkAuthFilter.java new file mode 100644 index 0000000..4a79400 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AkAuthFilter.java @@ -0,0 +1,69 @@ +package me.iiv.agent.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * /mcp 通道 ak 鉴权:Authorization: Bearer <ak> → AuthContext(ThreadLocal)。 + * 无 ak / ak 无效 → 401 JSON。 + */ +@Component +public class AkAuthFilter extends OncePerRequestFilter { + + private static final String MCP_PATH = "/mcp"; + + final AuthService authService; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + public AkAuthFilter(AuthService authService) { + this.authService = authService; + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return !request.getRequestURI().startsWith(MCP_PATH); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String authorization = request.getHeader("Authorization"); + String ak = null; + if (authorization != null && authorization.startsWith("Bearer ")) { + ak = authorization.substring(7).trim(); + } + AuthContext context = ak == null || ak.isEmpty() ? null : authService.resolve(ak); + if (context == null) { + writeUnauthorized(response); + return; + } + try { + AuthContextHolder.set(context); + filterChain.doFilter(request, response); + } finally { + AuthContextHolder.clear(); + } + } + + private void writeUnauthorized(HttpServletResponse response) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + Map body = new LinkedHashMap<>(); + body.put("error", "unauthorized"); + body.put("message", "缺少有效的 Authorization: Bearer ,或 ak 已禁用/过期"); + response.getWriter().write(objectMapper.writeValueAsString(body)); + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AuthContext.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AuthContext.java new file mode 100644 index 0000000..860d30e --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AuthContext.java @@ -0,0 +1,35 @@ +package me.iiv.agent.security; + +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.entity.svc.AgentApiKey; + +/** + * 一次请求的鉴权上下文:命中的 token + token 背后的人。 + * 行级数据权限永远跟着"人"走,token/scope 只决定能力面。 + */ +public class AuthContext { + + private final AgentApiKey token; + private final User user; + + public AuthContext(AgentApiKey token, User user) { + this.token = token; + this.user = user; + } + + public AgentApiKey getToken() { + return token; + } + + public User getUser() { + return user; + } + + public Long getUserId() { + return user == null ? null : user.getId(); + } + + public Long getTokenId() { + return token == null ? null : token.getId(); + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AuthContextHolder.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AuthContextHolder.java new file mode 100644 index 0000000..c7a5e3a --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AuthContextHolder.java @@ -0,0 +1,26 @@ +package me.iiv.agent.security; + +/** + * AuthContext 的 ThreadLocal 持有器。 + * 实测结论:spring.ai.mcp.server.type=SYNC 时,MCP 工具回调在 servlet 请求线程上同步执行, + * ThreadLocal 有效(AkAuthFilter 请求结束时负责清理)。 + */ +public final class AuthContextHolder { + + private static final ThreadLocal HOLDER = new ThreadLocal<>(); + + private AuthContextHolder() { + } + + public static void set(AuthContext context) { + HOLDER.set(context); + } + + public static AuthContext get() { + return HOLDER.get(); + } + + public static void clear() { + HOLDER.remove(); + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AuthService.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AuthService.java new file mode 100644 index 0000000..225cd09 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AuthService.java @@ -0,0 +1,96 @@ +package me.iiv.agent.security; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.entity.svc.AgentApiKey; +import me.iiv.agent.mapper.m0.UserMapper; +import me.iiv.agent.mapper.svc.AgentApiKeyMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Date; + +/** + * ak 解析:ak → SHA-256 → agent_api_key → 人(Redis 热缓存优先,回源 m0.user)。 + */ +@Component +public class AuthService { + + private static final Logger log = LoggerFactory.getLogger(AuthService.class); + + /** 老系统写入的用户热缓存 key 前缀(fastjson camelCase JSON) */ + private static final String USER_CACHE_KEY = "entity:User:"; + + final AgentApiKeyMapper agentApiKeyMapper; + final UserMapper userMapper; + final StringRedisTemplate redisTemplate; + + private final ObjectMapper objectMapper = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + public AuthService(AgentApiKeyMapper agentApiKeyMapper, UserMapper userMapper, StringRedisTemplate redisTemplate) { + this.agentApiKeyMapper = agentApiKeyMapper; + this.userMapper = userMapper; + this.redisTemplate = redisTemplate; + } + + /** + * 解析 ak,返回 AuthContext;ak 无效/禁用/过期或人不存在时返回 null。 + */ + public AuthContext resolve(String ak) { + AgentApiKey token = agentApiKeyMapper.selectOne(new LambdaQueryWrapper() + .eq(AgentApiKey::getKeyHash, sha256Hex(ak)) + .eq(AgentApiKey::getSoftDelete, 0)); + if (token == null || token.getStatus() == null || token.getStatus() != 1) { + return null; + } + if (token.getExpireTime() != null && token.getExpireTime().before(new Date())) { + return null; + } + User user = loadUser(token.getUserId()); + if (user == null || (user.getSoftDelete() != null && user.getSoftDelete() == 1)) { + return null; + } + // 更新最后使用时间(自有库,读写) + AgentApiKey update = new AgentApiKey(); + update.setId(token.getId()); + update.setLastUsedTime(new Date()); + agentApiKeyMapper.updateById(update); + return new AuthContext(token, user); + } + + /** 用户资料优先读 Redis db5 entity:User:{id} 热缓存(变更即时生效),读不到回源 m0.user */ + private User loadUser(Long userId) { + try { + String json = redisTemplate.opsForValue().get(USER_CACHE_KEY + userId); + if (json != null) { + return objectMapper.readValue(json, User.class); + } + } catch (Exception e) { + log.warn("redis 用户缓存读取失败 userId={}: {}", userId, e.getMessage()); + } + return userMapper.selectById(userId); + } + + public static String sha256Hex(String raw) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(raw.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(64); + for (byte b : hash) { + sb.append(Character.forDigit((b >> 4) & 0xF, 16)); + sb.append(Character.forDigit(b & 0xF, 16)); + } + return sb.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/ToolGuard.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/ToolGuard.java new file mode 100644 index 0000000..4c87fc1 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/ToolGuard.java @@ -0,0 +1,72 @@ +package me.iiv.agent.security; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import me.iiv.agent.entity.svc.AgentScope; +import me.iiv.agent.entity.svc.ScopeTool; +import me.iiv.agent.entity.svc.TokenScope; +import me.iiv.agent.mapper.svc.AgentScopeMapper; +import me.iiv.agent.mapper.svc.ScopeToolMapper; +import me.iiv.agent.mapper.svc.TokenScopeMapper; +import me.iiv.agent.mapper.svc.ToolMapper; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 工具级访问校验(设计文档 §3.2 第 3 步): + * tool 必须在 token 的作用域并集内,且 scope / tool 均启用。 + */ +@Component +public class ToolGuard { + + final TokenScopeMapper tokenScopeMapper; + final ScopeToolMapper scopeToolMapper; + final ToolMapper toolMapper; + final AgentScopeMapper agentScopeMapper; + + public ToolGuard(TokenScopeMapper tokenScopeMapper, ScopeToolMapper scopeToolMapper, + ToolMapper toolMapper, AgentScopeMapper agentScopeMapper) { + this.tokenScopeMapper = tokenScopeMapper; + this.scopeToolMapper = scopeToolMapper; + this.toolMapper = toolMapper; + this.agentScopeMapper = agentScopeMapper; + } + + public boolean isAllowed(Long tokenId, String toolName) { + List scopeIds = enabledScopeIds(tokenId); + if (scopeIds.isEmpty()) { + return false; + } + List scopeTools = scopeToolMapper.selectList(new LambdaQueryWrapper() + .eq(ScopeTool::getSoftDelete, 0) + .in(ScopeTool::getScopeId, scopeIds)); + if (scopeTools.isEmpty()) { + return false; + } + List toolIds = scopeTools.stream().map(ScopeTool::getToolId).distinct().collect(Collectors.toList()); + Long count = toolMapper.selectCount(new LambdaQueryWrapper() + .eq(me.iiv.agent.entity.svc.Tool::getSoftDelete, 0) + .eq(me.iiv.agent.entity.svc.Tool::getStatus, 1) + .eq(me.iiv.agent.entity.svc.Tool::getName, toolName) + .in(me.iiv.agent.entity.svc.Tool::getId, toolIds)); + return count != null && count > 0; + } + + /** token 勾选的、且处于启用状态的作用域 id 集合 */ + private List enabledScopeIds(Long tokenId) { + List tokenScopes = tokenScopeMapper.selectList(new LambdaQueryWrapper() + .eq(TokenScope::getSoftDelete, 0) + .eq(TokenScope::getTokenId, tokenId)); + if (tokenScopes.isEmpty()) { + return Collections.emptyList(); + } + List scopeIds = tokenScopes.stream().map(TokenScope::getScopeId).distinct().collect(Collectors.toList()); + List scopes = agentScopeMapper.selectList(new LambdaQueryWrapper() + .eq(AgentScope::getSoftDelete, 0) + .eq(AgentScope::getStatus, 1) + .in(AgentScope::getId, scopeIds)); + return scopes.stream().map(AgentScope::getId).collect(Collectors.toList()); + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/package-info.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/package-info.java index 29418f7..bde06e9 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/package-info.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/package-info.java @@ -1,4 +1,4 @@ /** - * ak鉴权Filter、身份上下文、scope校验(P2) + * ak 鉴权(/mcp 通道)、身份上下文(ThreadLocal)、scope 工具级校验。 */ package me.iiv.agent.security; diff --git a/xiaoqu-agent-service/src/main/resources/application-dev.yml b/xiaoqu-agent-service/src/main/resources/application-dev.yml index 0d0350c..1f42474 100644 --- a/xiaoqu-agent-service/src/main/resources/application-dev.yml +++ b/xiaoqu-agent-service/src/main/resources/application-dev.yml @@ -17,6 +17,11 @@ app: jdbc-url: jdbc:mysql://192.168.1.181:3306/xiaoqu_franchisee_d?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: ${M2_DB_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: root + password: ${SVC_DB_PASSWORD:kaixinjiuhao} spring: data: diff --git a/xiaoqu-agent-service/src/main/resources/application.yml b/xiaoqu-agent-service/src/main/resources/application.yml index 714f67f..7b2b142 100644 --- a/xiaoqu-agent-service/src/main/resources/application.yml +++ b/xiaoqu-agent-service/src/main/resources/application.yml @@ -14,6 +14,8 @@ spring: # Streamable HTTP 传输(Spring AI 1.1.8 已修复 Boot 3.5.x 下的 streamable NPE, # 见 spring-projects/spring-ai#6274),默认端点 /mcp protocol: STREAMABLE + # SYNC:工具回调在 servlet 请求线程上同步执行,ThreadLocal 鉴权上下文有效(实测验证) + type: SYNC logging: level: diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/codegen/CodeGenerator.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/codegen/CodeGenerator.java index e3aa15a..4b2acd6 100644 --- a/xiaoqu-agent-service/src/test/java/me/iiv/agent/codegen/CodeGenerator.java +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/codegen/CodeGenerator.java @@ -14,6 +14,7 @@ import java.util.Arrays; * 表白名单制: * m0(xiaoqu_comples_d): user, agent, authority, properyt, agent_manage_district_auth * m2(xiaoqu_franchisee_d): warehouse_agent_auth + * svc(xiaoqu_agent_svc_d): agent_api_key, agent_scope, tool, scope_tool, token_scope, audit_log, admin_user * * 生成物直接落到 src/main/java 对应分包(entity.m0/mapper.m0 等),生成后提交进仓库。 * @@ -34,6 +35,8 @@ public class CodeGenerator { "user", "agent", "authority", "properyt", "agent_manage_district_auth"); generate("xiaoqu_franchisee_d", "m2", "warehouse_agent_auth"); + generate("xiaoqu_agent_svc_d", "svc", + "agent_api_key", "agent_scope", "tool", "scope_tool", "token_scope", "audit_log", "admin_user"); } private static void generate(String schema, String module, String... tables) {