Compare commits

..
Author SHA1 Message Date
jiashumeng dfe14b29e3 docs(sync): refresh review branch status 2026-09-15 19:25:15 +09:30
jiashumeng 8b5d531f3a feat(sync): add pre-delivery contract verification 2026-09-15 19:23:08 +09:30
jiashumeng a1c6ac10e5 docs(sync): record MCP delivery boundary 2026-09-15 18:51:34 +09:30
jiashumeng e3b6edeb7b fix(sync): align public change stream schema 2026-09-15 18:49:27 +09:30
jiashumeng 4cd95c61ab docs: separate MCP CRM integration progress 2026-09-15 18:24:13 +09:30
wangheng 76d461a7ec Merge pull request 'feat(mcp): add warehouse inbound query tools' (#15) from feat/warehouse-in-mcp-tools into main 2026-09-15 16:34:57 +08:00
jiashumengandxiaoqu e2835362e9 feat(mcp): add warehouse inbound query tools 2026-09-15 16:31:40 +08:00
wangheng b008a5754a Merge pull request 'feat(mcp): add warehouse outbound query tools' (#14) from feat/warehouse-out-mcp-tools into main 2026-09-15 15:30:44 +08:00
jiashumeng f559012a4c feat(mcp): add warehouse outbound query tools 2026-09-15 16:56:49 +09:30
jiashumeng e064e86def feat(admin): add managed Open API client keys 2026-09-15 12:05:26 +09:30
wangheng 6cf1c0f4a0 Merge pull request 'docs(mcp): CRM 接入手册 (REQ-20260911-0013)' (#13) from docs/REQ-5628-crm-guide into main 2026-09-14 12:21:39 +08:00
wangheng f95de7eceb docs(mcp): CRM 接入手册 (REQ-20260911-0013) 2026-09-14 12:21:26 +08:00
wangheng e408768fe5 Merge pull request 'feat(mcp): 增量同步框架与 users/properties/contracts changes 工具 (REQ-20260911-0013)' (#12) from feat/REQ-5628 into main 2026-09-14 12:20:04 +08:00
wangheng 6639b93c45 feat(mcp): 增量同步框架与 users/properties/contracts changes 工具 (REQ-20260911-0013)
- 通用 ChangesEngine:snapshot 钉住 data_cutoff_at、(modify_time,id) 复合游标、
  仅最后一页签发 next_sync_token、软删 tombstone;sync_token/cursor 为
  HMAC-SHA256 无状态令牌(SyncTokenCodec),过期/篡改/跨快照/数据域变更
  统一 SYNC_CURSOR_EXPIRED
- 注册式实体扩展:SyncEntity/Registry + users(m0.user)/properties(m0.properyt)/
  contracts(m2.contract_new) 三个实现;生效修改时间 COALESCE(modify_time, create_time)
- 硬约束:GuardedToolCallback 对 *.changes.list 拒绝 tenant_id/scope 入参,
  数据域只从 Token 推导;行级权限复用 PermissionService 与 QUERY 工具同一 ACL
- 资源隔离:RateLimitService SYNC/QUERY 分档限流(Redis 固定分钟窗)、
  limit≤200 截断、SyncQueryTimeoutInterceptor 慢查询超时(三套 SqlSessionFactory 接入)
- contract.list.query 补齐 12 字段、contract.detail.query 补 version(ACL 不变)
- V5 migration:tool.description 512→1024(含 sql/rollback 回滚文件)
- seed-dev.sql:三个 changes 工具绑定进 sync 作用域
- 修复:properyt.customerId 为 NULL 时 Collectors.toMap NPE(冒烟命中),
  改为 put 逐条装填(ContractsSyncEntity/ContractTools)
2026-09-14 12:19:36 +08:00
wangheng 949945a195 Merge pull request 'feat(mcp): 服务 Token 生命周期与开放通道域绑定 (REQ-20260911-0014)' (#11) from feat/REQ-5629 into main 2026-09-14 09:22:10 +08:00
xiaoqu a0bae500bc feat(mcp): 服务 Token 生命周期与开放通道域绑定 (REQ-20260911-0014)
- agent_api_key 增加 principal_type/subject_name(V4 Flyway + rollback),integration.context.get 按列判定主体类型\n- Open API 支持 clients 服务密钥→作用域绑定;service ensure get-or-create/无密文轮换,旧密钥禁签 service\n- 服务 Token 吊销/轮换;人员停用后 user Token 失效(60s 本地缓存);AkAuthFilter 401 返回 UNAUTHENTICATED\n- ensure 入口约束:service 仅 sync 作用域、user 禁 sync;sync 作用域预建并绑定 integration.context.get\n- 单测 157 通过;本机 test-up 冒烟通过(跨域/旧密钥/user-sync 403,service context 自检,吊销立即 401,吊销后再签发)
2026-09-14 09:20:48 +08:00
wangheng c596aa93b5 Merge pull request 'feat(mcp): 新增身份与能力自检工具 integration.context.get (REQ-20260911-0012)' (#10) from feat/REQ-5627 into main 2026-09-11 23:11:23 +08:00
xiaoqu 795a433f35 feat(mcp): 新增 integration.context.get 身份与能力自检工具 (REQ-20260911-0012)
- 实时取 AkAuthFilter 鉴权上下文(ThreadLocal AuthContext)+ ToolGuard 同源能力计算,不缓存权限
- tenant_id 映射启用作用域编码;principal_type 集中在 IntegrationContextService.resolvePrincipalType
  判定(当前按 token 绑定用户推导 user_delegated,5629 给 agent_api_key 加 principal_type 列后仅改该方法)
- data_scope_version = token_scope 绑定集合最大 modify_time 的 epoch 毫秒,无状态计算
- capability_levels 数据驱动:能力集合含 *.changes.list 工具即具备 SYNC,当前返回 QUERY
- seed-dev.sql operation 作用域白名单加入该工具
2026-09-11 23:10:19 +08:00
wangheng 7b69c8668f Merge pull request 'feat(mcp): 统一输出规范与错误码体系 (REQ-20260911-0011)' (#9) from feat/REQ-5626 into main 2026-09-11 22:50:06 +08:00
xiaoqu f2ac17731a feat(mcp): 统一输出规范与错误码体系——schema_version 双跑信封 + 8 错误码收敛 + 工具只读声明 + 日志脱敏 (REQ-20260911-0011) 2026-09-11 22:48:14 +08:00
xiaoqu 5daecc8ae8 docs(agent-service): 更新生产 HTTPS 部署文档(agent.ququclean.com 上线) 2026-09-11 10:34:09 +08:00
wangheng 73bdbf9519 Merge pull request 'feat(mcp): 新增合同查询工具 contract.list/detail.query (REQ-20260911-0002)' (#8) from feat/REQ-20260911-0002-contract-tools into main 2026-09-11 10:22:34 +08:00
xiaoqu 52bb9faa27 feat(mcp): 新增合同查询工具 contract.list/detail.query (REQ-20260911-0002) 2026-09-11 10:21:18 +08:00
wangheng d5bb8646e1 Merge pull request 'feat(mcp): 新增出纸机查询工具 hardware.list/detail.query (REQ-20260910-0012)' (#7) from feat/REQ-20260910-0012-hardware-tools into main 2026-09-10 16:03:06 +08:00
xiaoqu ff452d89d8 feat(mcp): 新增出纸机查询工具 hardware.list/detail.query (REQ-20260910-0012) 2026-09-10 15:27:14 +08:00
wangheng 812c8e87f4 Merge pull request 'feat(mcp): 新增商城订单查询工具 mall.order.list/detail/logistics.query (REQ-20260909-0004)' (#6) from feat/REQ-20260909-0004-mall-order-tools into main 2026-09-09 19:05:16 +08:00
xiaoqu af22fd0e45 fix(mcp): 修复商城订单工具 CR 问题——时区固定/严格日期校验/权限单测 (REQ-20260909-0004)
- OrderTools: startOrderTime/endOrderTime 解析与输出时间格式化统一固定 Asia/Shanghai;
  endOrderTime 边界改为 < 次日零点(LocalDate.atStartOfDay),不再用 23:59:59 写法
- 日期入参严格校验:正则 ^\d{4}-\d{2}-\d{2}$ 全串匹配 + ISO_LOCAL_DATE/STRICT 解析,
  拒绝 "2026-09-09junk" 尾部垃圾字符与 2026-02-30 等非法日历日
- 新增 PermissionServiceOrderRowFilterTest:applyOrderRowFilter 直接单测,
  覆盖总部不过滤/代理自己+下级+代管/代管物业 OR 分支/销售非负责人收窄/agentId 缺失拒绝
2026-09-09 18:57:09 +08:00
xiaoqu 3d6d70604d feat(mcp): 新增商城订单查询工具 mall.order.list/detail/logistics.query (REQ-20260909-0004)
- OrderTools: 订单列表(9个过滤字段+分页)、订单详情(订单+明细+收货地址合并)、订单物流(主表+轨迹)
- 新增 m1 商城库 5 个 Entity/Mapper(首次启用 m1 mapper 包)
- PermissionService.applyOrderRowFilter: 移植老接口 getSaleCompany 行级权限语义
- OrderToolsTest 6 用例;seed-dev.sql 绑定 operation scope;文档第 7~9 节
2026-09-09 18:06:59 +08:00
xiaoqu 0d342a6e30 chore: 移除 Gitea CI workflow 2026-09-09 11:46:38 +08:00
xiaoqu 1e42323697 feat(mcp): 新增代理/物业详情工具(含发票信息)+ 本机测试环境脚本
xiaoqu-agent-service CI / test (push) Canceled after 0s
- agent.detail.query / property.detail.query:主体详情 + invoice_info
  全量抬头列表(默认条优先),行权限复用列表过滤语义
- 新增 InvoiceQueries 共享发票查询助手;InvoiceInfo 实体/Mapper
  按线上库 codegen 生成(m0 白名单加 invoice_info)
- Properyt.customerId 补 @TableField("customerId"):库中驼峰列名,
  否则映射到不存在的 customer_id(prod 曾报 Unknown column)
- seed-dev.sql 补两个详情工具的 scope 绑定;PermissionServiceTest
  补详情行权限断言(合并远端 district 测试冲突)
- 新增 deploy/test-up.sh:本机测试环境一键脚本(构建→重启→刷新
  scope 绑定→自检);.env.example 修正引导变量名 APP_ 前缀误写
2026-09-09 11:41:35 +08:00
wangheng 40e61cd300 Merge pull request #5: [REQ-20260908-0006] 新增站点列表与详情工具
xiaoqu-agent-service CI / test (push) Canceled after 0s
合并站点列表、站点详情 MCP 工具及 CI 工作流
2026-09-09 10:20:40 +08:00
xqzp2026 0692320dea fix(cicd): 在分支推送时运行测试门禁 (REQ-20260908-0006)
xiaoqu-agent-service CI / test (push) Canceled after 0s
2026-09-09 11:42:10 +09:30
xqzp2026 7c2b159aba chore(cicd): 添加 Gitea Actions 测试门禁 (REQ-20260908-0006)
xiaoqu-agent-service CI / test (pull_request) Canceled after 0s
2026-09-09 11:39:20 +09:30
xqzp2026 aa4cc26416 fix(mcp): 修正站点楼座数字段映射 (REQ-20260908-0006) 2026-09-09 11:20:03 +09:30
xqzp2026 686ce2daf8 feat(mcp): 新增站点列表与详情工具 (REQ-20260908-0006) 2026-09-08 19:25:12 +09:30
xiaoqu b8cc1992b7 fix(entity): 按线上库结构重建实体,修复 prod 查询 500
- CodeGenerator 支持 CODEGEN_DB_BASE_URL/CODEGEN_SCHEMA_M0/M2/SVC
  环境变量覆盖(默认仍 dev 库),并开启文件覆盖
- m0/m2 实体按生产 xiaoqu_complex/xiaoqu_franchisee 重新生成:
  User 移除线上不存在的 emailMask/mobileEnc/mobileBidx/mobileMask/
  idcardEnc/idcardBidx/idcardMask/tenantResolveStatus/idcardResolveStatus
  等 9 个字段;Agent/Properyt 字段对齐线上实际列
- svc 实体无实质变化(Flyway 管理,各环境一致)
- 修复 /api/admin/users 在 prod 报 Unknown column 'email_mask' 的 500
2026-09-08 15:48:07 +08:00
xiaoqu 6b2edcf06c fix(deploy): 修复远程部署配置解析并支持 remote-restart
- remote.conf.example: 含空格的值加引号,避免 source 时截断报错
- deploy.sh: 抽出 run_remote_script 公共远程流程(deploy/restart 复用),
  新增 remote-restart 命令(不重新构建直接重启)
- 启动前 source 服务器 app.env 注入环境变量(app.env 值需加引号,
  JDBC URL 含 & 否则被截断)
2026-09-08 14:48:27 +08:00
xiaoqu da60b0ab99 feat(deploy): 支持远程服务器部署
- deploy.sh 新增 push/remote-deploy/remote-stop/remote-status/remote-log 命令,
  远程流程: 构建→上传→停旧→备份→启动→/mcp 健康检查
- 新增 deploy/remote.conf.example 模板(SSH/目录/JDK路径/JVM参数),
  实际 remote.conf 已 gitignore
- 远程启动前 source 服务器上的 app.env 注入环境变量
- 新增 application-prod.yml,敏感值全部走环境变量,不写明文
2026-09-08 14:07:15 +08:00
wangheng 0009c47abb docs(mcp): align operations web integration design (#4) 2026-09-07 17:51:24 +08:00
xqzp2026 dc2367e1cf docs(mcp): align operations web integration design 2026-09-07 19:16:58 +09:30
186 changed files with 14335 additions and 361 deletions
+1
View File
@@ -9,5 +9,6 @@ front/dist/
# 本地部署凭据与运行态
deploy/.env.dev
deploy/remote.conf
deploy/app.pid
logs/
+17 -3
View File
@@ -57,6 +57,7 @@ xiaoqu-agent-service/
- **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 按作用域过滤**(2026-09-04 起):MCP SDK 的工具清单在启动时静态注册,无法按请求过滤,故 `ToolsListScopeFilter` 在 servlet 层拦截 POST /mcp 的 tools/list 响应并重写 `result.tools`(只保留该 ak 作用域并集内、且启用状态的工具)。`tools/call` 路径不变:越权调用仍返回可读拒绝信息并落 denied 审计。
- client_type 识别:`X-Client-Type` 头优先,否则 User-Agent 含 kimi→mcp-kimi / claude→mcp-claude / curl→cli,其余 unknown。
- **统一输出规范**(REQ-20260911-0011 起):schema_version 双跑——`tools/call` 默认(v1)返回原有 content text 不变;请求头 `X-Mcp-Schema-Version: 2` 时 `McpToolCallEnvelopeFilter`(@Order(15))在 servlet 层缓冲响应并写入 `result.structuredContent` 信封(`schema_version`/`request_id`/`data|error`),版本值非法直接返回 `SCHEMA_VERSION_UNSUPPORTED`。所有工具异常在 `GuardedToolCallback` 收敛为 8 个统一错误码(`ToolCallException`,message 即 `{"error":{code,message,request_id}}` JSON),裸异常细节脱敏后只落服务端日志。`tools/list` 重写时同步注入只读/幂等/非破坏性 annotations(`ToolKindRegistry`,源自 `@XqTool.kind`),v2 请求额外注入信封 outputSchema。审计与日志不记录 ak、手机号脱敏(`MaskingUtil`)。
## 行级数据权限引擎(permission/PermissionService)
@@ -80,6 +81,9 @@ xiaoqu-agent-service/
| `agent.count` | system | read | 查 m0 库 `agent` 表 `soft_delete=0` 总数 |
| `agent.list.query` | customer | read | 代理商分页查询。入参 id/name/level/channelNo/province/city/serialnumber/page/rows;出参 id/name/level/channelNo/serialnumber/supervisorName/province/city/countyseat/contact/phone/agencyarea/memo/createTime。行权限:isagent=1 只查直属下级,id 精确查可看自身。supervisorName 批量 IN 富化 |
| `property.list.query` | customer | read | 物业(客户)分页查询(m0 properyt 表)。入参 name/agentId/serialnumber/province/city/contact/phone/page/rows;出参 id/name/serialnumber/agentName/createName/province/city/countyseat/address/contact/phone/createTime。行权限走 PermissionService 完整版 getByIsAgent 语义(不含 enterprise_type=2 限制,与老 /property/listByPage 一致)。agentName/createName 批量 IN 富化 |
| `integration.context.get` | integration | read | 身份与能力自检(REQ-20260911-0012)。无入参;返回 tenant_id(作用域编码映射)/principal_type/subject_id/scopes(可调用工具集合,与强制校验同源实时计算)/data_scope_version(token_scope 绑定最大修改时间 epoch 毫秒)/capability_levels(QUERY/SYNC,按 *.changes.list 数据驱动)/server_time |
| `warehouse.in.list.query` | warehouse | read | 入库单分页查询;入库/申请日期独立过滤,主单代理、涉及仓库、流程参与人三类权限取并集 |
| `warehouse.in.detail.query` | warehouse | read | 入库单详情;支持调拨单,物料汇总、可选 SN、流程、附件和关联出库索引 |
分页统一规范:page 从 1 开始,rows 默认 20、上限 100,超出截断为 100 并在返回 `notice` 字段提示。
@@ -128,6 +132,15 @@ cp deploy/.env.example deploy/.env.dev # 填写 dev 凭据(已 gitignore,
- `.env.dev` 只需管理员引导和 Flyway 开关;数据库/Redis 已走配置文件。日志在 `logs/agent-service.log`,pid 在 `deploy/app.pid`。
- 注意应用启动较慢(多数据源初始化,约 60-90s),脚本健康检查窗口已放宽到 180s。
### 生产部署(prd,HTTPS 已于 2026-09-11 上线)
- 服务器:SSH 别名 `xiaoqu-prd`(123.57.245.202,Rocky 8),部署目录 `/data/xiaoqu-agent-service`(jar、`app.env`、`logs/`、`app.pid`)。连接参数在 `deploy/remote.conf`(已 gitignore)。
- 发布:`./deploy/deploy.sh remote-deploy`(本地构建 → 上传 → 远程停旧起新 → 健康检查);只重启用 `remote-restart`。
- 对外地址:MCP 端点 `https://agent.ququclean.com/mcp`,管理后台 `https://agent.ququclean.com/`。
- TLS 由 Nginx 终结(配置 `/etc/nginx/conf.d/agent.ququclean.com.conf`,转发 `127.0.0.1:8088`):`/mcp` 单独 location,`proxy_buffering off` + 1 小时读写超时(SSE 流式必需);全站传递 `X-Real-IP` / `X-Forwarded-For` / `X-Forwarded-Proto`;80 端口 301 跳 HTTPS;firewalld 已永久放行 http/https。
- 证书:阿里云一年期 DV 证书(DigiCert),文件在 `/etc/nginx/ssl/agent.ququclean.com/`(pem 644 / key 600),**2027-03-11 到期,无自动续期**。到期前在阿里云重新申请下载,替换这两个文件后 `nginx -s reload`。
- prd 的 `app.env` 相比 dev 必须多出两项:`ADMIN_COOKIE_SECURE=true`(会话 cookie 仅经 HTTPS 下发)、`SERVER_FORWARD_HEADERS_STRATEGY=framework`(认 X-Forwarded 头,审计 `client_ip` 才能取到真实来源 IP,否则全是 127.0.0.1)。
## 测试 ak(仅 dev)
仓库不提供固定 ak。每位开发者在本地生成随机 ak,只把 SHA-256 写入 `agent_api_key`,再用 `token_scope` 关联需要的作用域;明文只保存在本机客户端配置(如 `.kimi-code/mcp.json`,已 gitignore)中,不提交版本库。仓库历史中曾出现的 dev ak 必须视为已泄露并在数据库中禁用。
@@ -173,7 +186,7 @@ agent.list.query 抽测:channel_no='D' + province='北京市' → 总部 11 /
## 客户端接入(dev 阶段裸跑 HTTP)
dev 服务地址示例:`http://192.168.4.236:8088/mcp`(本机内网 IP;本机自测可用 localhost)。生产必须 HTTPS(Nginx 终结 TLS)。
dev 服务地址示例:`http://192.168.4.236:8088/mcp`(本机内网 IP;本机自测可用 localhost)。生产地址 `https://agent.ququclean.com/mcp`(Nginx 终结 TLS,部署细节见上文「生产部署」)。
**Kimi Code** — 项目级 `.kimi-code/mcp.json`(ak 明文直写;该文件已加入 `.gitignore`,不会提交版本库):
@@ -201,7 +214,7 @@ claude mcp add --transport http xiaoqu http://192.168.4.236:8088/mcp \
## Open API(服务间接口,/api/open/**)
供其他系统在服务端到服务端场景下获取用户 Token。鉴权为静态服务间密钥(header `X-Open-Key`,dev 明文配置在 `application-dev.yml` 的 `app.open.api-key`;配置为空 = 接口关闭)。
供其他系统在服务端到服务端场景下获取用户或服务 Token。鉴权使用 header `X-Open-Key`:生产优先在管理后台「服务密钥」页面创建并绑定作用域,明文只展示一次、数据库仅存 SHA-256 哈希;原有 `app.open.api-key` / `app.open.clients` 环境配置继续兼容。
**`POST /api/open/token/ensure`** —— 按 手机号/邮箱 + 作用域编码 确保 Token 存在(get-or-create):
@@ -217,12 +230,13 @@ curl -X POST http://localhost:8088/api/open/token/ensure \
- 已存在但无密文(历史数据)→ 自动轮换:禁用旧把、签发新把,按 `created=true` 返回新明文。
- 不存在 → 自动创建永久 Token → `{"created":true,"token":"xqak_...(明文)",...}`。
- 调用写 `audit_log`(`open.token.ensure` / client_type=open-api,不记录明文)。
- 管理后台支持服务密钥列表、创建、编辑作用域、吊销/启用和轮换;吊销或轮换后旧密钥立即无法通过 `/api/open/**` 鉴权。
**签发规则(2026-09-04 起)**:一作用域一 Token。后台签发时选 N 个作用域会生成 N 把 token,每把独立绑定单作用域;编辑存量 token 时作用域为单选。
## codegen(run-once)
表白名单: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` 对应分包并提交进仓库。
表白名单: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, app_open_client, app_open_client_scope`(注:物业表真实表名为老系统遗留拼写 `properyt`)。生成物落在 `src/main/java` 对应分包并提交进仓库。
```bash
# 生成(依赖已下载后执行;连 dev 库)
+3 -3
View File
@@ -2,9 +2,9 @@
# 数据库/Redis 连接信息已明文写在 application-dev.yml,无需在此配置
# 后台管理员首次引导(仅首次启动且 admin_user 为空时建号,之后可删除)
APP_ADMIN_BOOTSTRAP_ACCOUNT=admin
APP_ADMIN_BOOTSTRAP_PASSWORD=
# APP_ADMIN_BOOTSTRAP_NAME=系统管理员
ADMIN_BOOTSTRAP_ACCOUNT=admin
ADMIN_BOOTSTRAP_PASSWORD=
# ADMIN_BOOTSTRAP_NAME=系统管理员
# 默认拒绝为非空库自动建基线;仅已有 V1 库首次接入时临时改为 true
SVC_FLYWAY_BASELINE_ON_MIGRATE=false
+100 -1
View File
@@ -90,6 +90,99 @@ do_status() {
fi
}
# ---------- 远程部署 ----------
REMOTE_CONF="$APP_HOME/deploy/remote.conf"
load_remote_conf() {
if [ ! -f "$REMOTE_CONF" ]; then
echo "[ERROR] 缺少 $REMOTE_CONF,请复制 deploy/remote.conf.example 并填写" >&2
exit 1
fi
set -a; source "$REMOTE_CONF"; set +a
: "${REMOTE_SSH_HOST:?未配置 REMOTE_SSH_HOST}"
: "${REMOTE_DIR:?未配置 REMOTE_DIR}"
REMOTE_SSH_PORT="${REMOTE_SSH_PORT:-22}"
REMOTE_PROFILE="${REMOTE_PROFILE:-prod}"
REMOTE_PORT="${REMOTE_PORT:-8088}"
REMOTE_JAVA_OPTS="${REMOTE_JAVA_OPTS:-}"
REMOTE_SPRING_ARGS="${REMOTE_SPRING_ARGS:-}"
REMOTE_JAVA_BIN="${REMOTE_JAVA_BIN:-java}"
}
ssh_remote() { ssh -p "$REMOTE_SSH_PORT" "$REMOTE_SSH_HOST" "$@"; }
do_push() {
[ -f "$JAR" ] || { echo "[ERROR] 未找到 jar,先执行: $0 build" >&2; exit 1; }
load_remote_conf
ssh_remote "mkdir -p '$REMOTE_DIR/logs'"
scp -P "$REMOTE_SSH_PORT" "$JAR" "$REMOTE_SSH_HOST:$REMOTE_DIR/xiaoqu-agent-service.jar.new"
echo "[OK] 已上传到 $REMOTE_SSH_HOST:$REMOTE_DIR/xiaoqu-agent-service.jar.new"
}
# 在远程执行 停旧→(可选)换包→启动→健康检查;$1: 1=换上新传的 jar,0=仅重启现有 jar
run_remote_script() {
load_remote_conf
ssh_remote "REMOTE_DIR='$REMOTE_DIR' REMOTE_PROFILE='$REMOTE_PROFILE' REMOTE_PORT='$REMOTE_PORT' REMOTE_JAVA_OPTS='$REMOTE_JAVA_OPTS' REMOTE_SPRING_ARGS='$REMOTE_SPRING_ARGS' REMOTE_JAVA_BIN='$REMOTE_JAVA_BIN' DEPLOY_NEW='$1' bash -s" <<'EOS'
set -euo pipefail
cd "$REMOTE_DIR"
if [ -f app.pid ] && kill -0 "$(cat app.pid)" 2>/dev/null; then
kill "$(cat app.pid)"
for i in $(seq 1 15); do kill -0 "$(cat app.pid)" 2>/dev/null || break; sleep 1; done
kill -0 "$(cat app.pid)" 2>/dev/null && kill -9 "$(cat app.pid)"
fi
if [ "$DEPLOY_NEW" = "1" ]; then
[ -f xiaoqu-agent-service.jar ] && cp xiaoqu-agent-service.jar "xiaoqu-agent-service.jar.bak.$(date +%Y%m%d%H%M%S)"
mv -f xiaoqu-agent-service.jar.new xiaoqu-agent-service.jar
fi
if [ -f app.env ]; then
set -a; source app.env; set +a
else
echo "[WARN] 未找到 $REMOTE_DIR/app.env,环境变量未注入,prd 可能启动失败" >&2
fi
nohup "$REMOTE_JAVA_BIN" $REMOTE_JAVA_OPTS -jar xiaoqu-agent-service.jar --spring.profiles.active="$REMOTE_PROFILE" $REMOTE_SPRING_ARGS >> logs/agent-service.log 2>&1 &
echo $! > app.pid
echo "[OK] 远程已启动 (pid $(cat app.pid)),等待健康检查..."
for i in $(seq 1 90); do
if ! kill -0 "$(cat app.pid)" 2>/dev/null; then
echo "[ERROR] 远程进程已退出,最后 40 行日志:" >&2
tail -40 logs/agent-service.log >&2 || true
exit 1
fi
code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "http://127.0.0.1:$REMOTE_PORT/mcp" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"deploy-check","version":"0"}}}' || true)
if [ "$code" = "200" ] || [ "$code" = "401" ]; then
echo "[OK] 远程服务已就绪 (HTTP $code, /mcp)"
exit 0
fi
sleep 2
done
echo "[ERROR] 远程健康检查超时,日志: $REMOTE_DIR/logs/agent-service.log" >&2
exit 1
EOS
}
do_remote_deploy() {
do_build
do_push
run_remote_script 1
}
do_remote_restart() {
run_remote_script 0
}
do_remote_stop() {
load_remote_conf
ssh_remote "cd '$REMOTE_DIR' && if [ -f app.pid ] && kill -0 \$(cat app.pid) 2>/dev/null; then kill \$(cat app.pid) && rm -f app.pid && echo '[OK] 远程已停止'; else echo '[SKIP] 远程未在运行'; fi"
}
do_remote_status() {
load_remote_conf
ssh_remote "cd '$REMOTE_DIR' && if [ -f app.pid ] && kill -0 \$(cat app.pid) 2>/dev/null; then echo \"远程运行中 (pid \$(cat app.pid))\"; else echo '远程未运行'; exit 1; fi"
}
case "${1:-}" in
build) do_build ;;
start) do_start ;;
@@ -97,7 +190,13 @@ case "${1:-}" in
restart) do_stop; do_start ;;
status) do_status ;;
log) tail -f "$LOG_FILE" ;;
push) do_push ;;
remote-deploy) do_remote_deploy ;;
remote-restart) do_remote_restart ;;
remote-stop) do_remote_stop ;;
remote-status) do_remote_status ;;
remote-log) load_remote_conf; ssh_remote "tail -f '$REMOTE_DIR/logs/agent-service.log'" ;;
*)
echo "用法: $0 {build|start|stop|restart|status|log}" >&2
echo "用法: $0 {build|start|stop|restart|status|log|push|remote-deploy|remote-restart|remote-stop|remote-status|remote-log}" >&2
exit 1 ;;
esac
@@ -0,0 +1,32 @@
# 远程部署配置(本文件为模板;实际使用复制为 remote.conf,已 gitignore,不入库)
# 用法: cp deploy/remote.conf.example deploy/remote.conf,填好后执行 ./deploy/deploy.sh remote-deploy
# --- SSH 连接(必填)---
# 推荐先在 ~/.ssh/config 配置别名和密钥,这里直接填别名,避免明文密码
# 例如: REMOTE_SSH_HOST=xiaoqu-prd 或 REMOTE_SSH_HOST=deploy@192.168.1.100
REMOTE_SSH_HOST=
REMOTE_SSH_PORT=22
# --- 远程目录(必填)---
# 远程服务器上的部署目录,jar、pid、日志都放在这里
REMOTE_DIR=/opt/xiaoqu/agent-service
# --- 运行参数 ---
# Spring profile(对应 application-<profile>.yml;若远程没有对应 yml,
# 用下面的 REMOTE_SPRING_ARGS 覆盖具体配置项)
REMOTE_PROFILE=prod
# 服务端口(健康检查用)
REMOTE_PORT=8088
# JVM 参数(可选)
REMOTE_JAVA_OPTS="-Xms512m -Xmx1024m"
# java 命令路径(可选,默认用 PATH 里的 java;远程有多个 JDK 时建议显式指定 17)
# REMOTE_JAVA_BIN=/usr/lib/jvm/jre-17-openjdk/bin/java
REMOTE_JAVA_BIN=
# 追加给 Spring Boot 的启动参数(可选),用于覆盖远程环境配置,例如:
# REMOTE_SPRING_ARGS=--spring.datasource.url=jdbc:mysql://xxx:3306/db --spring.datasource.password=xxx
REMOTE_SPRING_ARGS=
# 远程服务器要求: JDK 17+、curl(健康检查用)
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# 本机即测试环境:一键 构建 → 重启 → 刷新 scope 工具绑定 → 自检
# 用法:
# ./deploy/test-up.sh # 全量(含 Maven 构建,约 7 分钟)
# ./deploy/test-up.sh --skip-build # 跳过构建,直接重启+刷新绑定
# 首次运行会自动生成 deploy/.env.dev(含随机 admin 初始密码,注意看输出)
set -euo pipefail
APP_HOME="$(cd "$(dirname "$0")/.." && pwd)"
DEPLOY="$APP_HOME/deploy/deploy.sh"
ENV_FILE="$APP_HOME/deploy/.env.dev"
SEED_SQL="$APP_HOME/sql/seed-dev.sql"
MYSQL="${MYSQL_BIN:-mysql}"
# dev svc 库(与 application-dev.yml 保持一致)
SVC_DB_HOST=192.168.1.181
SVC_DB_USER=root
SVC_DB_PASSWORD=kaixinjiuhao
SVC_DB_NAME=xiaoqu_agent_svc_d
# 1. 首启引导配置(不存在才生成;admin 仅首次启动且 admin_user 为空时建号)
if [ ! -f "$ENV_FILE" ]; then
ADMIN_PWD="Dev@$(openssl rand -hex 6)"
cat > "$ENV_FILE" <<EOF
# dev 环境变量(本机使用,已 gitignore)
ADMIN_BOOTSTRAP_ACCOUNT=admin
ADMIN_BOOTSTRAP_PASSWORD=$ADMIN_PWD
ADMIN_BOOTSTRAP_NAME=系统管理员
SVC_FLYWAY_BASELINE_ON_MIGRATE=false
EOF
echo "[INIT] 已生成 $ENV_FILE"
echo "[INIT] admin 初始密码: $ADMIN_PWD (仅首次启动建号生效,请记录)"
fi
# 2. 构建
if [ "${1:-}" = "--skip-build" ]; then
echo "[SKIP] 跳过构建"
else
"$DEPLOY" build
fi
# 3. 重启(deploy.sh 内含 /mcp 健康检查)
"$DEPLOY" restart
# 4. 刷新 scope-工具绑定(tool 表已由启动时 ToolSyncService 同步;seed 幂等)
"$MYSQL" -h "$SVC_DB_HOST" -u"$SVC_DB_USER" -p"$SVC_DB_PASSWORD" "$SVC_DB_NAME" \
--default-character-set=utf8mb4 < "$SEED_SQL" > /dev/null
echo "[OK] scope 工具绑定已刷新(operation)"
# 5. 自检:后台登录接口存活(不验密码对错,只验服务可登录)
code=$(curl -s --noproxy '*' -o /dev/null -w "%{http_code}" -m 10 -X POST \
http://127.0.0.1:8088/api/admin/auth/login \
-H 'Content-Type: application/json' \
-d '{"account":"__selfcheck__","password":"__selfcheck__"}')
if [ "$code" = "401" ] || [ "$code" = "200" ]; then
echo "[OK] 登录接口存活 (HTTP $code)"
else
echo "[WARN] 登录接口异常 (HTTP $code),请查看: $DEPLOY log" >&2
fi
echo
echo "本机测试环境就绪: http://localhost:8088/ (admin 账号见 deploy/.env.dev)"
echo "日常命令: $DEPLOY {start|stop|restart|status|log}"
@@ -106,14 +106,14 @@ xiaoqu-ai Android/iOS/Web ─► Gateway/Agent ─────┤
### 5.1 MCP(主形态)
- Streamable HTTP 传输,端点 `https://mcp.<域名>/mcp`。
- Streamable HTTP 传输,端点 `https://agent.ququclean.com/mcp`(生产,2026-09-11 上线,Nginx 终结 TLS)。
- 客户端配置(Kimi Code `~/.kimi-code/mcp.json` 或项目级 `.kimi-code/mcp.json`,ak 明文直写;项目级文件已加入 `.gitignore`):
```json
{
"mcpServers": {
"xiaoqu": {
"url": "https://mcp.<域名>/mcp",
"url": "https://agent.ququclean.com/mcp",
"headers": {
"Authorization": "Bearer <ak>"
}
@@ -122,7 +122,7 @@ xiaoqu-ai Android/iOS/Web ─► Gateway/Agent ─────┤
}
```
- Claude Code:`claude mcp add --transport http xiaoqu https://mcp.<域名>/mcp --header "Authorization: Bearer <ak>"`。
- Claude Code:`claude mcp add --transport http xiaoqu https://agent.ququclean.com/mcp --header "Authorization: Bearer <ak>"`。
- ak 明文只出现在服务端签发/ensure 响应和运行时;服务端保存 SHA-256 哈希及 AES-GCM 加密密文(二次获取用),含 ak 的客户端配置文件不得提交版本库。
### 5.2 Web 管理后台(四个页面)
@@ -151,7 +151,7 @@ xiaoqu-ai Android/iOS/Web ─► Gateway/Agent ─────┤
供其他业务系统/智能体平台在服务端到服务端场景下获取用户 Token,路径 `/api/open/**`,与 ak 体系、后台 session 体系完全分离。
- **鉴权**:静态服务间密钥,header `X-Open-Key`,配置项 `app.open.api-key`(dev 明文写 `application-dev.yml`;配置为空 = 接口关闭)。常量时间比较防时序侧信道。
- **鉴权**:服务间密钥放在 header `X-Open-Key`。生产密钥由现有管理后台创建,数据库只存 SHA-256 哈希并绑定作用域;旧 `app.open.api-key` / `app.open.clients` 配置保留兼容,其中环境变量明文使用常量时间比较。
- **`POST /api/open/token/ensure`**(get-or-create):
- 入参:`mobile`、`email`(至少一个,手机号优先)、`scopeCode`(作用域编码,必填)。
- 人员只认企业账号(`enterprise_type` 0/1,NULL 保留,与后台用户列表同口径);查不到 → 404。
@@ -159,6 +159,15 @@ xiaoqu-ai Android/iOS/Web ─► Gateway/Agent ─────┤
- 已存在但**无密文**(V3 之前的历史数据)→ 自动轮换:禁用旧把、签发新把,按 `created=true` 返回新明文;解密失败 → 500,不回退。
- **不存在** → 自动创建永久 Token(一作用域一 Token,memo 标记 `open-api 自动签发`),`created=true` 返回明文。
- 全程写 `audit_log`(`tool_name=open.token.ensure`,`client_type=open-api`,参数摘要脱敏,不记录明文)。
- **服务 Token(principal_type=service,REQ-20260911-0014)**:
- `agent_api_key.principal_type` 区分 `user`(用户委托,绑定 `user_id`,行级权限跟随人员)与 `service`(租户服务,`user_id` 为 NULL,`subject_name` 标识服务主体,行级权限不适用,数据范围 = 绑定作用域全量)。
- ensure 入参补 `principalType` / `serviceName`:`principalType=service` 时按 `serviceName + scopeCode` get-or-create 服务 Token,语义与 user 路径一致(存在解密回传、无密文自动轮换、不存在签发永久 Token)。
- **服务密钥 → 数据域绑定**:管理后台「服务密钥」页面创建调用方并勾选作用域,鉴权时按密钥确定可操作的数据域,越域 403;密钥明文仅在创建/轮换时展示,吊销/轮换立即生效。`app.open.clients` 作为环境变量兼容通道;旧 `app.open.api-key` 保留为不限域密钥,仅存量 user ensure 可用,**不可签发 service Token**。
- 吊销:后台 `PATCH /api/admin/tokens/{id}/status` 置 0,`AkAuthFilter` 现有 status 校验天然生效,吊销后立即 401;轮换:`POST /api/admin/tokens/{id}/rotate` 禁用旧把并按原主体/作用域/过期时间签新把,明文仅本次响应返回。
- **人员停用失效**:`AuthService` 校验 user 类型 Token 时检查 m0.user `soft_delete`(注意 `work_status` 是考勤状态,不是在职状态),人员状态本地缓存 TTL 60s(→ Redis 热缓存 → m0 库三级),停用最长 60s 后 ak 调用返回 401/UNAUTHENTICATED。
- **SYNC 工具白名单**:个人 Token 禁调 SYNC 工具不新造拦截代码,靠 `token_scope` 白名单——SYNC 工具(`*.changes.list`,后续需求 5628 注册)只绑进 `sync` 作用域,个人 Token 不勾该作用域即天然 PERMISSION_DENIED;`sync` 作用域由 `sql/seed-dev.sql` 预建。ensure 侧同时做入口约束:`principalType=service` 只允许 `scopeCode=sync`,user ensure 不允许 `sync`。
- **手机号变更调研结论(REQ-20260911-0014 范围 5)**:`mobile` 是 `m0.user` 的普通属性,变更手机号不改 `user.id`;Token 与行级权限均按 `user.id` 生效,因此手机号变更后仍保持同一个 userId,无需额外处理。
- **5629 冒烟清单**:① scoped key 跨域 ensure → 403;② 旧 `app.open.api-key` 签发 service → 403;③ `principalType=service` + `scopeCode=sync` ensure 成功,拿 ak 调 `integration.context.get` 返回 `principal_type=tenant_service`;④ 后台吊销该 Token 后立即 401;⑤ rotate 后旧 ak 401、新明文可用;⑥ 人员停用后等待 >60s(本地缓存 TTL)再调 → 401/UNAUTHENTICATED;⑦ user ensure `scopeCode=sync` → 403。
## 6. 技术栈与工程结构
@@ -194,6 +203,8 @@ xiaoqu-agent-service/
| `tool` | name、category、description、kind(read/write)、status | 工具注册表,启动时同步;category 代码声明 |
| `audit_log` | user_id、token_id、tool_name、params_digest、result_status、cost_ms、client_ip、client_type | 审计;client_type 区分 mcp-kimi/mcp-claude/cli |
| `admin_user` | account、password_hash | 后台管理员 |
| `app_open_client` | name、key_prefix、key_hash、status、last_used_time | 管理后台创建的 Open API 服务密钥调用方(不存明文) |
| `app_open_client_scope` | client_id、scope_id | 服务密钥-作用域关系 |
均带标准审计五件套(create/modify id/time、soft_delete)。
@@ -211,7 +222,7 @@ xiaoqu-agent-service/
| P1 骨架 | 工程初始化、版本组合验证、三数据源连通、codegen 跑通 | demo 工具经 MCP 调通 |
| P2 权限 | ak 鉴权 Filter、scope 校验、数据权限引擎、审计 | 不同权限组的人查询结果隔离正确 |
| P3 首批工具 | 代理商查询 + 客户(物业)查询(定义见 §9.1)+ tool 表同步 | Kimi/Claude 双端实测 |
| P4 管理后台 | 用户/token、工具、作用域、审计四页面(Vue3 内嵌) | 管理员全流程自助配置 |
| P4 管理后台 | 用户/token、服务密钥、工具、作用域、审计五页面(Vue3 内嵌) | 管理员全流程自助配置 |
| P5 加固 | 限流、ES 接入(按需)、写工具评审开放、CLI(Go 单二进制) | 生产上线 |
### 9.1 首期工具定义(P3,均已确认)
@@ -13,6 +13,40 @@
| 行级数据权限 | 跟 ak 所属人走,照搬老系统 `getByIsAgent` 语义(见设计文档 §3.3),与作用域无关 |
| 分页 | 列表类工具统一 `page`(从 1 开始,默认 1)、`rows`(默认 20,**上限 100**,超出截断为 100 并在返回 `notice` 字段提示) |
| 分页出参 | 统一含 `total`(总数)、`page`、`rows`、`records`(本页记录)、`notice`(仅截断时出现) |
| 工具声明 | `tools/list` 中每个工具带 MCP annotations:只读(`readOnlyHint=true`)、幂等(`idempotentHint=true`)、非破坏性(`destructiveHint=false`)(REQ-20260911-0011 起) |
### 0.1 schema_version 双跑(REQ-20260911-0011 起)
- **默认(v1,兼容面)**:`tools/call` 返回的 `result.content[0].text` 即各工具原有 JSON,无 `structuredContent`,老客户端零改动。
- **显式声明新版本**:请求头加 `X-Mcp-Schema-Version: 2`,`tools/call` 响应额外写入 `result.structuredContent` 统一信封,`content` 文本保持不变:
```json
{ "schema_version": 2, "request_id": "<服务端生成 UUID>", "data": { "…": "各工具原有 JSON" } }
```
- 版本头只接受 `1`/`2`(缺省按 1);其他值在 `tools/call` 上直接返回 `SCHEMA_VERSION_UNSUPPORTED` 错误,不执行工具。
- v2 请求下 `tools/list` 的每个工具额外带 `outputSchema`(信封结构);业务字段契约仍以本文档各工具章节为准。
### 0.2 统一错误码(REQ-20260911-0011 起)
工具执行失败返回 `isError=true`,`content[0].text` 为结构化 JSON(不再是裸异常文本);v2 下同样的错误出现在 `structuredContent.error`:
```json
{ "error": { "code": "PERMISSION_DENIED", "message": "人类可读说明", "request_id": "…" } }
```
| code | 含义 |
|---|---|
| UNAUTHENTICATED | 缺少有效 ak 或鉴权上下文丢失 |
| PERMISSION_DENIED | token 作用域不含该工具或工具已停用 |
| NOT_FOUND_OR_FORBIDDEN | 资源不存在或无权查看(不区分,防探测) |
| RATE_LIMITED | 触发限流(预留) |
| UPSTREAM_UNAVAILABLE | 上游数据源(DB/Redis)暂时不可用 |
| SYNC_CURSOR_EXPIRED | 增量同步游标过期(预留) |
| SCHEMA_VERSION_UNSUPPORTED | 声明的 schema_version 不受支持 |
| DATA_INTEGRITY_ERROR | 数据不一致或未预期异常(兜底;细节只落服务端日志,凭 request_id 排查) |
日志与审计不记录 Token/ak,手机号一律脱敏为 `138****5678` 形式。
---
@@ -119,7 +153,66 @@
---
## 4. sys.ping
## 4. district.list.query
分页查询站点列表,对应老系统 `/district/districtlistByPage`,但使用固定轻量字段契约。
- 分类:customer | 类型:读
- 数据来源:m0 库 `district_district_strategy` 视图,固定 `soft_delete = 0`、`id DESC`
- 行级权限:总部看全部;代理商按自己、直属下级、代管代理及授权物业过滤;销售部门非负责人沿用老接口的互斥物业范围。筛选条件只能缩小权限结果。
**入参**:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| id | number | 否 | 站点 id,正整数,精确匹配 |
| districtName | string | 否 | 站点名称,模糊匹配 |
| districtState | number | 否 | 0=测试 1=履约 2=演示 3=撤机 |
| province | string | 否 | 省份,模糊匹配 |
| city | string | 否 | 城市,模糊匹配 |
| agentId | number | 否 | 代理商 id,正整数,精确匹配 |
| propertyId | number | 否 | 物业 id,正整数,精确匹配 |
| page | number | 否 | 页码,从 1 开始,默认 1 |
| rows | number | 否 | 每页条数,默认 20,上限 100 |
**出参 records 字段**:
`id`、`districtName`、`districtNumber`、`propertyId`、`propertyName`、`agentId`、`agentName`、`province`、`city`、`countyseat`、`address`、`districtState`、`onlineState`、`hardwareCount`、`isSmart`。
`propertyName`、`agentName` 均为批量富化,查不到时返回 `未查询到`。
---
## 5. district.detail.get
按站点 id 获取扩展详情。站点 id 与当前用户行权限在同一个数据库查询条件中组合。
- 分类:customer | 类型:读
- 入参:`id`(number,必填,正整数)
- 找到:`{ "found": true, "data": { ... } }`
- 不存在或无权限:`{ "found": false, "message": "站点不存在或无权访问" }`
**data 字段**:
| 分组 | 字段 |
|---|---|
| 归属 | id、districtName、districtNumber、propertyId、propertyName、agentId、agentName、salesId、salesName、serviceCompanyId、serviceCompany |
| 地址联系人 | province、city、countyseat、address、contact、phone、mail |
| 站点属性 | businessFormat、attributeId、attributeName、installType、isSmart、lnfluenceLevel、buildCount、hardwareCount、airdetectorFlag |
| 状态周期 | districtState、onlineState、testDate、honourDate、weaningDate、testDays |
| 设备耗材 | deviceSalesType、paperSalesType、consumablesType、posDevicePaperNorm、toiletDevicePaperNorm |
| 功能开关 | pointState、assignmentWorkState、airState、messageFlag、reportFlag、lockMachineFlag、eventRepairFlag、rfidUseFlag、calibratedFlag、illegalPaperDisable |
| 语音配置 | lowerPaperState、noPaperState、powerWarning、waitTimes |
| 报警配置 | warning、warning2、warning3、warningTime、paperPercentWarn、maintainOrderWarning、foamCountWarning、foamPowerWarning |
| 业务配置 | pointDeduction、effectiveUseTime、consumablesPrice、billDate、billType、billTypeStr、fineMoney |
| 运行配置 | districtRefresh(Redis 未配置默认 5)、forbidFunction(Redis 未配置默认 0) |
| 审计信息 | createId、createName、createTime、modifyTime、memo |
固定不返回 `softDelete`、`enterpriseId`、`modifyId`、内部服务 URL、重复 `districtId`、语义未确认的 `supervisorId` 和 SKU 列表。
---
## 6. sys.ping
服务自检(demo 工具,证明链路连通)。
@@ -138,6 +231,322 @@
---
## 7. mall.order.list.query
查询商城订单列表(分页),对应老系统 `POST /mallGoodsOrder/listByPage`。
- 分类:customer | 类型:读
- 数据来源:m1 商城库(`xiaoqu_mall_d`)`mall_goods_order` 表
- 行级权限(`PermissionService.applyOrderRowFilter`,移植老接口 `WrapperUtils.getSaleCompany` 语义):
- 总部(isagent=0):全部;
- 代理商(isagent=1):`sale_company_id IN (自己 + 直属下级 + warehouse_agent_auth 代管代理)`,有代管物业授权时再 `OR property_id IN (agent_manage_district_auth 代管物业)`;
- 销售部门非负责人:`property_id IN (自己创建的物业 + 代管代理名下物业)`,与上一条互斥。
**入参**:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| orderNo | string | 否 | 订单编号,模糊匹配 |
| agentId | number | 否 | 代理商 id,精确匹配 |
| propertyId | number | 否 | 物业 id,精确匹配 |
| orderType | number | 否 | 订单类型:0=代理 1=物业 |
| orderState | number | 否 | 订单状态(单选):0=待付款 1=待出库 2=待发货 3=已发货 4=已签收 5=已收货 6=已取消 7=已删除 |
| payState | number | 否 | 回款状态:0=待付款 1=已付款 2=付款中 |
| startOrderTime | string | 否 | 下单时间起,`yyyy-MM-dd` |
| endOrderTime | string | 否 | 下单时间止,`yyyy-MM-dd`(含当天) |
| page | number | 否 | 页码,从 1 开始,默认 1 |
| rows | number | 否 | 每页条数,默认 20,上限 100(超出截断为 100 并在 notice 提示) |
**出参 records 字段**(按 id 倒序):
| 字段 | 说明 |
|---|---|
| id / orderNo / orderTime | 订单 id / 编号 / 下单时间(`yyyy-MM-dd HH:mm:ss`) |
| orderType / agentName / propertyName | 订单类型 / 代理名称 / 物业名称 |
| saleCompany | 销售公司 |
| payMoney / paidMoney / unPayMoney | 应付 / 已付 / 未付金额 |
| orderState / payState / warehouseState | 订单状态 / 回款状态 / 出库状态(0=未出库 1=已出库 2=部分出库) |
| customerName | 客户名称:orderType=0 取代理金蝶名称,否则取物业金蝶名称(批量富化,同老接口) |
---
## 8. mall.order.detail.query
按订单号查询商城订单详情(订单 + 商品明细 + 收货地址一次返回,合并老系统三个接口)。
- 分类:customer | 类型:读
- 数据来源:m1 库 `mall_goods_order` + `mall_order_details` + `mall_shipping_address`
- 行级权限:与 `mall.order.list.query` 一致;订单不存在或无权查看时 `record=null` 并在 notice 说明(两者不区分,不泄露订单是否存在)。
**入参**:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| orderNo | string | 是 | 订单编号 |
**出参**:
- `record`:订单关键字段(金额类 payMoney/paidMoney/unPayMoney/totalMoney/goodsMoney/postageMoney/discountMoney,状态类 orderState/payState/orderPayState/warehouseState/orderInvoiceState,时间类 orderTime/payTime/receiptTime/signTime/createTime,及 accountsType/paymentType/goodsType/saleCompany/payNo/memo 等)+ 收货信息(reveiveName/reveiveNumber/province/city/countyseat/address,由 `address_id` 关联 `mall_shipping_address`)。
- `items`:商品明细列表,每行含 spuNo/spuName/skuNo/count/price/payMoney/payState/warehouseState。
---
## 9. mall.order.logistics.query
按订单号查询商城订单物流(发货信息 + 物流轨迹),对应老系统 `GET /mallGoodsOrder/getMallLogistics`。
- 分类:customer | 类型:读
- 数据来源:m1 库 `mall_order_logistics` + `mall_order_logistics_detai`
- 行级权限:与 `mall.order.list.query` 一致;订单不存在或无权查看时 `logistics=null` 并在 notice 说明(两者不区分)。
- **注意**:轨迹表 `logistics_order_no` 字段老系统写入时塞的是订单号而非快递单号,故按 orderNo 查询(照搬老系统语义)。物流轨迹为系统内业务节点手工写入的记录,非实时快递查询,可能不全(菜鸟定时同步已停用)。
**入参**:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| orderNo | string | 是 | 订单编号 |
**出参**:
- `logistics`(无物流记录时为 null):logisticsName(快递公司)/ logisticsOrderNo(快递单号)/ sendMode(0=快递 1=自送)/ state(0=已出单 1=已出库 2=已发货 3=已揽件 4=运输中 5=待取件 6=派送中 7=已签收)/ deliverName / deliverNumber / sendTime / receivingGoodsName / reveiveName / reveiveNumber / address。
- `tracks`:轨迹列表,按时间正序,每行含 memo(轨迹描述)/ state / picList / createTime。
---
## 10. hardware.list.query
分页查询出纸机列表,对应老系统 `POST /hardware/listByPage`(HardwareViewController)。
- 分类:hardware | 类型:读
- 数据来源:m0 库 `hardware_view` 视图,固定 `soft_delete = 0`、`id DESC`
- 行级权限(`PermissionService.applyHardwareRowFilter`,与站点同一 `getByIsAgent(propertyFlag=true, className="property_id")` 变体):
- 总部(isagent=0):全部;
- 代理商(isagent=1):`agent_id IN (自己 + 直属下级 + warehouse_agent_auth 代管代理)`,有代管物业授权时再 `OR property_id IN (agent_manage_district_auth 代管物业)`;
- 销售部门非负责人:`property_id IN (自己创建的物业 + 代管代理名下物业)`,与上一条互斥。
- 富化(修正老接口 N+1,按页批量):`agentName`/`propertyName`/`districtName` 走 Redis key `name:Agent:{id}` / `name:Properyt:{id}` / `name:District:{id}` multiGet;断纸状态 `piStatus` 批量读 `Assignment:{snCode}:29`(无 key 返回 0);本页非空 `imsi` 一次性 IN 查 `networkcard` 表。返回 `hardware_view` 全字段,不做字段级权限裁剪。
**入参**:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| snCode | string | 否 | sn码,模糊匹配 |
| deviceType | number | 否 | 设备类型:0=厕纸机 1=擦手纸机 |
| status | number | 否 | 设备状态:1=在线 2=故障 3=离线 |
| online | number | 否 | 上下线状态:1=上线 2=下线 |
| lockMachineState | number | 否 | 锁机状态:0=未锁机 1=已锁机 |
| agentId | number | 否 | 代理商 id,正整数,精确匹配 |
| propertyId | number | 否 | 物业 id,正整数,精确匹配 |
| districtId | number | 否 | 站点 id,正整数,精确匹配 |
| buildingId | number | 否 | 楼 id,正整数,精确匹配 |
| floor | string | 否 | 楼层,精确匹配 |
| imsi | string | 否 | imsi码,精确匹配 |
| page | number | 否 | 页码,从 1 开始,默认 1 |
| rows | number | 否 | 每页条数,默认 20,上限 100(超出截断为 100 并在 notice 提示) |
**出参 records 字段**:`hardware_view` 全字段(snCode、deviceType、status/online/lockMachineState 状态四元组、sysversion/firmwareVersion/voiceVersion/advertiseVersion、imsi、电量/纸量/电池族、各功能开关、memo 等,时间格式 `yyyy-MM-dd HH:mm:ss`)+ 富化字段 `agentName`/`propertyName`/`districtName`/`piStatus` + 物联网卡字段 `factoryType`/`carrier`/`dataBalance`/`dataUsage`/`iccid`/`msisdn`(按 imsi 匹配,无卡为 null)。
---
## 11. hardware.detail.query
按 snCode 或 id 查询出纸机详情(二选一必填),行级权限与 `hardware.list.query` 一致。
- 分类:hardware | 类型:读
- 数据来源:m0 库 `hardware_view` + `district_district_strategy`(站点信息)+ `networkcard`(物联网卡)
- 设备不存在或无权查看时 `record=null` 并在 notice 说明(两者不区分,不泄露设备是否存在)。
**入参**:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| snCode | string | 条件必填 | sn码,精确匹配;与 id 二选一 |
| id | number | 条件必填 | 设备 id,正整数,精确匹配;与 snCode 二选一 |
**出参**:
- `record`:hardware_view 全字段 + 与列表同一套富化(agentName/propertyName/districtName、piStatus、物联网卡 factoryType/carrier/dataBalance/dataUsage/iccid/msisdn)。
- `record.site`(关联站点,district 表现有字段):`districtId`、`districtName`、`province`、`city`、`countyseat`、`address`、`contact`、`phone`、`buildingId`、`floor`。注意 district 表无经纬度和楼名字段,楼座/楼层取 hardware_view。
---
## 12. contract.list.query
分页查询合同列表,对应老系统 `/contract/listByPage` 合同列表页(权限库 `webpage` id=394 `contractList`,页面字段定义 `ContractListOutput`)。
- 分类:customer | 类型:读
- 数据来源:m2 库 `contract_new` 表,固定 `soft_delete = 0`、`id DESC`
- 行级权限(`PermissionService.applyContractRowFilter`,与站点/出纸机同一 `getByIsAgent(propertyFlag=true)` 变体,agent 维度落 `contract_agent_id`,property 维度落 `property_id`):
- 总部(isagent=0):全部;
- 代理商(isagent=1):`contract_agent_id IN (自己 + 直属下级 + warehouse_agent_auth 代管代理)`,有代管物业授权时再 `OR property_id IN (agent_manage_district_auth 代管物业)`;
- 销售部门非负责人:`property_id IN (自己创建的物业 + 代管代理名下物业)`,与上一条互斥。
- 富化(按页批量 IN 查询,消除 N+1):`contractAgentName`/`customerAgentName` 查 m0 `agent`,`createName` 查 m0 `user`;`saleName`/`propertyName`/`customerName`/`customerNo` 为合同表冗余快照,直接返回。
**入参**:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| contractNo | string | 否 | 合同编号,模糊匹配 |
| contractName | string | 否 | 合同名称,模糊匹配 |
| propertyName | string | 否 | 物业名称,模糊匹配 |
| customerName | string | 否 | 客户名称(金蝶客户名称快照),模糊匹配 |
| customerNo | string | 否 | 客户编号(=物业 serialnumber),精确匹配 |
| contractAgentId | number | 否 | 合同所属公司 id,精确匹配 |
| customerAgentId | number | 否 | 客户所属公司 id,精确匹配 |
| propertyId | number | 否 | 所属物业 id,精确匹配 |
| contractStatus | number | 否 | 合同状态:0=待审 1=驳回 2=正常 3=过期 4=终止 |
| expireRemind | number | 否 | 到期提醒开关:0=关 1=开 |
| endDateFrom | string | 否 | 合同结束日期起,格式 yyyy-MM-dd(查即将到期合同用) |
| endDateTo | string | 否 | 合同结束日期止,格式 yyyy-MM-dd(含当天) |
| page | number | 否 | 页码,从 1 开始,默认 1 |
| rows | number | 否 | 每页条数,默认 20,上限 100(超出截断为 100 并在 notice 提示) |
**出参 records 字段**:`contract_new` 主要字段(id、contractNo、contractName、contractAgentId/customerAgentId、propertyId/propertyName、customerNo/customerName、amount、contact、startDate/endDate(`yyyy-MM-dd`)、expireRemind/remindDays/remindTime/reminded、saleId/saleName、contractStatus、stopReasonType/stopReason、createId、createTime)+ 富化字段 `contractAgentName`/`customerAgentName`/`createName` + 状态文案 `contractStatusText`。
---
## 13. contract.detail.query
按 id 或 contractNo 查询合同详情(二选一必填),行级权限与 `contract.list.query` 一致。
- 分类:customer | 类型:读
- 数据来源:m2 库 `contract_new` + `contract_file`(附件)+ `contract_process`(流程/待办/操作记录)
- 合同不存在或无权查看时 `record=null` 并在 notice 说明(两者不区分,不泄露合同是否存在);id/contractNo 都未传时 notice 提示"必须传一个"。
**入参**:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| id | number | 条件必填 | 合同 id;与 contractNo 二选一 |
| contractNo | string | 条件必填 | 合同编号,精确匹配;与 id 二选一 |
**出参**:
- `record`:与列表同一套字段(含富化和 `contractStatusText`)。
- `files`:合同附件列表,`id`/`fileName`/`fileUrl`/`createTime`,按 id 正序。
- `processes`:审批流程记录,按 id 正序,字段 `nodeType`(0=创建 1=部门内审核 2=层级审核 3=终止 4=抄送)、`auditLevel`、`operationType`(0=创建 1=审核通过 2=审核驳回 3=终止 4=修改再提交 5=抄送)、`userId`/`userName`、`scheduleState`(0=无需待办 1=待办 2=已办)、`remark`、`operateTime`,均附 `nodeTypeText`/`operationTypeText`/`scheduleStateText` 文案。
---
## 14. integration.context.get
身份与能力自检(REQ-20260911-0012):返回当前 Token 的集成上下文。所有数据每次调用实时取库,不缓存权限。
- 分类:integration | 类型:读
- 数据来源:svc 库 `agent_api_key` / `token_scope` / `agent_scope` + ToolGuard 实时能力计算(与 tools/call 强制校验同源)
**入参**:无
**出参**:
| 字段 | 类型 | 说明 |
|---|---|---|
| tenant_id | string | Token 所属数据域标识 = 启用状态的作用域编码(排序后逗号拼接;当前签发规则为一作用域一 Token) |
| principal_type | string | `user_delegated`(用户委托)/ `tenant_service`(服务主体)。当前按 token 绑定用户推导;`agent_api_key.principal_type` 列由后续需求(5629)补充后切换为读列 |
| subject_id | number | 用户 ID 或服务主体 ID |
| scopes | string[] | 该 Token 允许调用的工具名集合(排序),与 tools/call 强制校验结果严格一致 |
| data_scope_version | number | 数据权限版本:token_scope 绑定集合最大 modify_time(空取 create_time)的 epoch 毫秒;无绑定为 0 |
| capability_levels | string[] | `QUERY` / `SYNC`:能力集合中含 `*.changes.list` 工具时具备 SYNC(数据驱动);无能力时为空数组 |
| server_time | string | 带时区的服务器时间(ISO_OFFSET_DATE_TIME) |
---
## 15. warehouse.out.list.query
分页查询出库单,参考旧系统 `POST /myWarehouseOut/allListByPage`,但修复其出库日期误映射为申请日期、
部分渠道未过滤和逐单查询明细的问题。
- 分类:warehouse | 类型:读
- 数据来源:m2 `warehouse_out`、`warehouse_out_details`、`warehouse_out_audit_process`
- 行级权限:总部看全部;代理身份可看主单所属代理为自己/直属下级/授权代管代理的出库单,或明细涉及
上述代理所属/负责管理仓库的出库单,或本人参与审核/处理的出库单。三类条件取并集;筛选条件只能缩小结果。
- 列表关联数据按本页主单 ID 批量查询,不逐单 N+1 查询。
**入参**:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| outOrderNo / allocationOrderNo / mallOrderNo | string | 否 | 出库/调拨/商城单号,模糊匹配 |
| deliveryOrderNo / installOrderNo / pickupOrderNo | string | 否 | 送货/装机/取货工单号,模糊匹配 |
| agentId / warehouseId / warehouseTypeId / applyUserId | number | 否 | 所属代理/涉及仓库/出库类型/申请人 ID,精确匹配 |
| orderState | number | 否 | 0=待审核 1=被驳回 2=待出库 3=已完成 4=已撤销 5=作废 |
| sendMode | number | 否 | 0=快递公司 1=自行派送 |
| outSynchronizeState | number | 否 | 0=未同步 1=已同步 2=无需同步 |
| assetSynchronizeState | number | 否 | -1=无需同步 0=未同步 1=已同步 |
| useCustomerType / useCustomerId | number | 否 | 客户类型(0内部/1渠道商/2直营)和客户 ID |
| useCustomerName | string | 否 | 客户名称,模糊匹配 |
| outDateFrom / outDateTo | string | 否 | 出库日期范围,`yyyy-MM-dd`,结束日期含当天 |
| applyDateFrom / applyDateTo | string | 否 | 申请日期范围,`yyyy-MM-dd`,结束日期含当天 |
| page / rows | number | 否 | 页码默认 1;每页默认 20、上限 100 |
**出参 records 字段**:主单 `id/outOrderNo/allocationOrderNo/mallOrderNo`、`sourceType`
(NORMAL/ALLOCATION/MALL)、代理和出库类型、`warehouseIds/warehouseNames`、出库/商品数量、状态码与文案、
申请人/时间、客户、`schedulePeople`、`warehouseOutPeople/warehouseOutTimes`、发货物流、同步状态、
关联送货/装机/取货单号、备注及创建/修改时间。
---
## 16. warehouse.out.detail.query
按主键或出库单号查询完整出库单详情;调拨出库单可直接查询,以 `sourceType=ALLOCATION` 标识。
- 分类:warehouse | 类型:读
- 行级权限与 `warehouse.out.list.query` 完全一致,并加入同一个主表查询。
- 不存在或无权访问统一返回 `NOT_FOUND_OR_FORBIDDEN`,不泄露记录是否存在。
**入参**:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| id | number | 条件必填 | 出库单主键;与 outOrderNo 至少传一个 |
| outOrderNo | string | 条件必填 | 出库单号;与 id 至少传一个;同时传入时必须匹配同一条记录 |
| includeSerials | boolean | 否 | 是否返回 SN 子行,默认 false |
| serialLimit | number | 否 | SN 返回上限,默认 100、最大 500 |
**出参**:
- `record`:主单、来源类型、状态文案、客户、同步状态及关联工单号。
- `warehouses`:物料汇总行涉及的仓库去重集合。
- `items`:`parent_id=0` 物料汇总行,含物料、仓库、数量、价格和出库确认信息。
- `serials/serialTotal/serialTruncated`:可选 SN 子行、完整总数和截断标记;未请求 SN 时
`serials=[]`、`serialTruncated=false`。
- `processes`:已处理记录(`schedule_state=2`),按操作时间、ID 正序,状态码与文案同时返回。
- `address/logistics`:m1 收货地址及发货物流;无地址时 `address=null`。
- `files.application/files.confirmation`:申请/确认图片,返回 `id/fileName/bucket/objectKey`;服务不持有
OSS 签名密钥,不生成临时访问 URL。
- `certificate`:出库凭证备注及凭证文件;均不存在时为 null。
详细设计与取舍见 `warehouse-out-mcp-tools-design.md`。
---
## 17. warehouse.in.list.query
分页查询入库单,参考旧系统 `POST /myWarehouseIn/allListByPage`,但将入库日期与申请日期分别过滤,
并按本页主单 ID 批量加载仓库和待办信息。
- 分类:warehouse | 类型:读
- 数据来源:m2 `warehouse_in`、`warehouse_in_details`、`warehouse_in_audit_process`
- 行级权限:总部看全部;代理身份可看所属代理范围、所属或负责仓库涉及、或本人参与处理的入库单。
- 单号支持入库/调拨/关联出库/商城/送货/装机/取货/撤机;另支持代理、仓库、入库类型、申请人、
状态、同步状态、物流和入库/申请日期范围筛选。
- 返回 `total/page/rows/records`;每条记录含 `sourceType`(ALLOCATION/OUTBOUND_LINKED/MALL/NORMAL)、
状态文案、仓库集合、待办人、实际入库人和入库日期。
## 18. warehouse.in.detail.query
按 `id` 或 `inOrderNo` 查询详情,两者至少传一个;同时传入时必须匹配同一条可见记录。调拨入库单可直接查询。
- 行级权限与列表完全一致;不存在和无权统一返回 `NOT_FOUND_OR_FORBIDDEN`。
- 返回 `record/sceneType/warehouses/items/processes/files/relatedOutOrder`。
- `items` 按物料定义、物料索引和仓库汇总;`includeSerials` 默认 false,`serialLimit` 默认 100、上限 500,
同时返回 `serialTotal/serialTruncated`。
- `files.application/files.confirmation` 仅返回 `id/fileName/bucket/objectKey`。
- `relatedOutOrder` 仅给出关联出库单号和查询工具名,不内嵌出库单内容。
详细设计见 `warehouse-in-mcp-tools-design.md`。
---
## 附录:在线查看工具清单
```bash
@@ -0,0 +1,143 @@
# CRM 接入小趣 MCP 手册
> 适用版本:REQ-20260911-0013(增量同步框架)交付后
> 端点:`https://agent.ququclean.com/mcp`(生产)/ `http://192.168.4.236:8088/mcp`(内网)
> 协议:MCP Streamable HTTP(JSON-RPC),所有响应为 SSE,取 `data:` 行
>
> 当前生产可用性、阻塞项和下一步以[对接进度](crm-sync-integration-progress.md)为准;本手册描述目标调用方式,不代表发布门禁已经通过。
## 0. 边界(先读这段)
小趣 MCP 只做三件事:**供数据、供身份、守权限**。
到期规则计算、AI 决策、推送、通知负责人、经营事项流转——全部是 CRM / 小趣 AI 系统的职责,MCP 不提供也不预留任何相关字段。
## 1. 一次性开通
| 步骤 | 操作方 | 说明 |
|---|---|---|
| 1 | 小趣管理员 | 登录 `https://agent.ququclean.com/`,进入「服务密钥」→「创建服务密钥」 |
| 2 | 小趣管理员 | 调用方名称填 `crm`,勾选 `sync` 作用域,保存并立即复制只展示一次的 `X-Open-Key` |
| 3 | CRM 后端 | 用下面的请求换取**长期服务 Token**(`xqak-...`),安全保管 |
```bash
curl -X POST 'https://agent.ququclean.com/api/open/token/ensure' \
-H 'X-Open-Key: <管理后台创建后显示的服务密钥>' \
-H 'Content-Type: application/json' \
-d '{"scopeCode":"sync","principalType":"service","serviceName":"crm"}'
```
响应的 `token` 就是后续放进 MCP `Authorization: Bearer <token>` 的服务 Token。`X-Open-Key` 不是 MCP Token,不能直接放进 Authorization 头。
完成后 CRM 持有两样东西:**服务 Token**(后台扫描用)、**服务密钥**(重新 ensure 获取 Token 或给销售换个人 Token 用)。若还需要给销售换 QUERY 个人 Token,创建/编辑服务密钥时还要勾选对应 QUERY 作用域;只绑定 `sync` 的密钥不能跨域签发个人 Token。
密钥泄露时,管理员在同一页面点「轮换」(旧密钥立即失效)或「吊销」。数据库只保存密钥哈希,关闭创建/轮换结果弹窗后无法找回明文。
## 2. 每次调用前的身份自检
```
tools/call integration.context.get
```
返回 `principal_type` / `scopes` / `capability_levels` / `data_scope_version`。
- 后台扫描任务启动时必须确认 `principal_type=tenant_service` 且 capability 含 SYNC;拿错 Token(user_delegated)时**拒绝执行扫描**,防止串权。
- `data_scope_version` 变化说明管理员改了授权范围,建议触发一次全量重建。
## 3. 后台通道:每日定时扫描(增量同步)
三个变化流工具:`users.changes.list` / `properties.changes.list` / `contracts.changes.list`。
入参只有三个(**禁止传 tenant_id/scope**,传了直接 PERMISSION_DENIED):
```json
{ "syncToken": null, "cursor": null, "limit": 200 }
```
### 协议语义
- **快照**:每轮同步签发 `snapshot_id` + `data_cutoff_at`,同一快照内翻页结果稳定,页间不会有数据漂移。
- **游标**:页内按 `(modify_time, id)` 复合游标推进;`has_more=true` 时用 `next_cursor` 翻下一页。
- **续传**:只有最后一页(`has_more=false`)才签发 `next_sync_token`。中断/宕机后从本地保存的 `sync_token + cursor` 继续,**不重不漏**。
- **tombstone**:`deleted_at` 非空的条目是被删除的数据,CRM 本地镜像应标记删除,不进 AI。
- **limit**:上限 200,传更大会被截断到 200 并带 notice。
### CRM 侧定时任务伪代码
```
token, cursor = load_local() # 首次均为 null → 全量
loop:
resp = call("contracts.changes.list", {syncToken: token, cursor: cursor, limit: 200})
for item in resp.items:
if item.deleted_at: 本地镜像标记删除(item.contract_id)
else: 本地镜像 upsert(item)
save_local(token, resp.next_cursor) # 每页都存,断点恢复
if resp.has_more: cursor = resp.next_cursor; continue
save_local(resp.next_sync_token, null) # 本轮结束,钉死新游标
break
```
### 错误分支(必须实现)
| 错误码 | 含义 | CRM 动作 |
|---|---|---|
| `SYNC_CURSOR_EXPIRED` | 快照/游标过期或被篡改 | 清空本地镜像,syncToken=null 全量重建 |
| `RATE_LIMITED` | 触发 SYNC 限流档 | 指数退避重试(建议 1s/2s/4s…上限 60s) |
| `UNAUTHENTICATED` | Token 被吊销/人员停用 | 重新 ensure 取 Token,仍失败则告警人工介入 |
### 两个错误用法
- 不要拿 `version` 字段做并发控制——它由 `modify_time` 派生,只用于调试核对。
- 不要把扫描任务和销售在线查询混用同一个 Token——服务 Token 调 QUERY 工具会被拒(scope 白名单),这是设计如此。
### 发送前复核(不能省略)
日同步只负责发现候选。CRM 在晨报、午报或日结真正入推送队列前,必须使用同一个服务 Token 批量调用:
```text
tools/call contracts.current.batch_get
arguments: {"contractIds":["1001","1002"]}
```
- 每批 1—200 个稳定合同 ID,返回 `checked_at/count/items/not_found_ids`。
- `items` 使用与合同变化流相同的公开字段;软删合同返回删除标记,查无记录进入 `not_found_ids`。
- CRM 必须逐项复核状态、到期日、负责人、客户归属、提醒配置和续约结果。任一变化、缺失、删除、响应不完整、证据时间异常或服务不可用,对应合同本次都不得发送。
- 该工具属于 SYNC 能力和限流档,调用方传 `tenant_id/tenantId/scope` 会被拒绝;数据域只能来自服务 Token。
## 4. 前台通道:销售在线查询(实时 ACL)
```
1. 销售登录 CRM → CRM 后端调 /api/open/token/ensure {mobile: 销售手机号}
幂等,同一人永远拿到同一个个人 Token;缓存 "CRM用户 ↔ xqak ↔ userId" 映射。
2. 销售查合同时,带他的个人 Token 调:
- contract.list.query (列表,12 字段,自动按实时 ACL 行过滤)
- contract.detail.query (详情,含 version、files、processes)
3. 个人 Token 调 SYNC 工具直接被 scope 白名单拦截——销售永远扫不到全量数据。
```
个人 Token 缓存在 CRM 侧即可;收到 `UNAUTHENTICATED`(如人员被停用,60 秒内生效)时清缓存重取。
## 5. 两条通道速查
| | 前台(销售点查) | 后台(定时扫描) |
|---|---|---|
| Token 类型 | user_delegated | tenant_service |
| 可用工具 | contract.list/detail.query 等 QUERY | *.changes.list + contracts.current.batch_get + context.get |
| 数据范围 | 实时 ACL 行过滤 | 管理员授权的 sync scope |
| 限流 | 常规档 | SYNC 独立档,互不占配额 |
| tenant_id 入参 | 不允许 | 不允许,从 Token 推导 |
## 6. 通用输出规范
所有工具返回统一信封 `structuredContent`:
```json
{ "schema_version": 2, "request_id": "...", "data": { ... } }
```
- 请求头 `X-Mcp-Schema-Version: 2` 启用新信封;不带则返回老结构(v1,兼容期内有效)。
- 所有请求把 `request_id` 记入 CRM 日志,排查时两边对得上。
- **Token 和手机号明文不要落日志**(MCP 侧已对手机号打码,CRM 侧请同样处理)。
## 7. 后续扩展
同步协议是通用框架:以后新增设备/订单/工单同步,CRM 侧代码不变,只是多调一个 `xxx.changes.list`、多存一对 `sync_token/cursor`。输出信封与错误码也不变。
@@ -0,0 +1,140 @@
# SaaS MCP → 小趣 AI 经营系统对接进度
> MCP 系统独立进度入口。最后核对:2026-09-15;代码位于`codex/crm-contract-sync-review`本地评审分支。
>
> 本文只记录 `xiaoqu_mcp_service` 的责任和状态。小趣 AI / CRM 的规则、事项、AI、页面与推送进度,以其仓库 `PROJECT.md` 为准。
## 1. 两个系统的边界
| 系统 | 必须负责 | 明确不负责 |
|---|---|---|
| SaaS MCP | 服务身份、源租户/数据域、工具授权、源系统行级权限、用户/物业/合同标准变化流、稳定游标、审计与标准错误 | 到期规则、经营优先级、AI决策、经营事项状态机、销售页面、APNs推送 |
| 小趣 AI / CRM | 租户连接、数据镜像、身份与客户映射、确定性规则、仅把命中数据交给AI、事项生成/流转、展示、操作、反馈、推送前复核 | 猜测源租户、绕过MCP权限、兼容不符合冻结Schema的错误字段 |
双方唯一连接面是版本化接口契约:`integration.context.get`、三条 `*.changes.list`、`contracts.current.batch_get`、统一错误信封,以及真实端到端验收。任何一侧内部实现都不能成为另一侧的隐式依赖。
## 2. 当前结论
当前状态:**协议可连接,公开字段Schema已在本地修正并通过回归;可靠同步与服务身份仍未通过,不能用于真实销售合同到期推送。**
交付状态:修正位于独立分支`codex/crm-contract-sync-review`,本地提交已形成;当前执行环境没有`wangheng/xiaoqu_mcp_service`写权限,尚未推送、合并或部署,生产服务仍是旧行为。
已经具备:
- MCP Streamable HTTP 会话、SSE响应和Schema v2信封。
- `X-Open-Key` 管理、服务Token签发、Token作用域、工具白名单、审计和限流。
- `integration.context.get`。
- `users.changes.list`、`properties.changes.list`、`contracts.changes.list`及统一分页框架。
- `contracts.current.batch_get`发送前批量回源复核已在本地实现;沿用SYNC身份、权限、限流和审计边界。
- 基于 `(modify_time, id)` 的分页、软删标记、错误码和基础单元测试。
尚未通过的不是“小趣AI页面问题”,而是MCP公开数据契约与可靠同步语义。小趣AI侧不得通过放宽校验来掩盖这些问题。
## 3. 已确认问题与处理顺序
### P0-1 服务身份与源数据域语义混淆
代码核对结论:当前SaaS业务库没有统一的`tenant_id`字段。人员、物业、合同处于同一个业务数据域,在线行权限由代理商、物业和销售关系计算;合同负责人由`contract_new.sale_id`表达。不能为了对接新增一个虚假的行级租户字段。
现状仍有两个独立问题:`integration.context.get.tenant_id`由作用域编码拼接得到,`sync`被误当成数据域;服务Token又按`serviceName + scope`全局复用,创建它的Open Client身份没有进入Token。作用域只代表“能用什么工具”,不是数据来源或调用方身份。
影响:消费者无法稳定识别数据来自哪套SaaS实例;不同Open Client使用相同`serviceName`时可能复用服务Token。它不会替代合同负责人分发,但会影响连接隔离、吊销和审计。
标准改造:
1. 为每套MCP部署配置稳定的`source_domain_id`,例如生产SaaS数据域标识;它来自服务端配置,不由请求体、scope或名称推断。
2. 服务Token持久化`open_client_id + subject_name`,ensure按调用方、服务主体和scope组合幂等签发;不同Open Client不共享Token。
3. `integration.context.get`返回稳定`source_domain_id`。若为保持既有契约继续输出`tenant_id`,必须明确该字段在当前单数据域SaaS中表示“源数据域”,不能再返回`sync`。
4. SYNC服务Token读取该单一数据域的完整变化流;记录按合同`owner_user_id`分发。在线个人查询继续使用现有人员ACL,两条通道不混用。
5. 小趣AI的租户连接负责把自己的`tenant_id`显式绑定到该`source_domain_id`,禁止同一个源数据域被误配给多个目标租户。
验收:两个Open Client即使使用相同`serviceName=crm`也得到不同服务Token;吊销其中一个不影响另一个;上下文返回稳定源数据域;小趣AI连接错配源数据域时在同步前失败关闭。
### P0-2 三条变化流公开字段Schema(本地已修正,待部署联调)
原实现与冻结契约的差异:
| 流 | 当前输出 | 必须输出 |
|---|---|---|
| users | `mobile`;数值型`user_id/organization_ids` | `verified_mobile`;字符串型稳定ID;状态使用公开枚举 |
| properties | 数值型`property_id` | 字符串型稳定ID;状态使用公开枚举 |
| contracts | 数值型`contract_id/property_id/owner_user_id`、数值状态`0..4`、数值`expire_remind`,缺`remind_days` | 字符串型稳定ID、`PENDING/REJECTED/ACTIVE/EXPIRED/TERMINATED/VOID`、布尔提醒标记、实际提醒天数 |
已完成:新增独立公开DTO,将三类稳定ID统一为字符串;`mobile`改为`verified_mobile`;合同状态转换为公开枚举;`expire_remind`转换为布尔值;补齐`remind_days`;变化页使用统一`stream`字段。未知合同状态按`DATA_INTEGRITY_ERROR`失败关闭,不伪造业务语义。
验证:新增发送前复核后,Java编译、管理前端构建及MCP服务全量`225`项测试通过,`0`失败、`0`错误。尚需部署后的真实数据样例和小趣Gateway严格消费者契约联调,完成前仍不算生产通过。
验收:用真实样例对三条流执行消费者契约测试;Gateway无需兼容代码即可全部通过,未知字段、未知状态和错误类型必须失败。
### P0-3 当前“快照”不能保证重放稳定
现状:令牌只保存`cutoff`,每页仍直接查询持续变化的业务表。分页期间,`cutoff`之前的记录被修改、删除或权限变化时,相同`sync_token + cursor + limit`可能得到不同结果;秒级`modify_time`也可能漏掉同一行在边界秒内的再次修改。
影响:合同规模扩大或同步期间发生业务写入时,存在漏数、重复、记录消失和重放不一致风险,达不到文档声称的“不重不漏”。
标准改造:建立通用变更日志/Outbox(每次变更拥有单调递增`change_seq`,保存实体ID、操作类型、版本和必要投影),同步窗口按`change_seq`冻结;游标按`change_seq`推进。若使用CDC,也必须落成可保留、可重放的变化流,不能直接分页扫活表冒充快照。
验收:同步中并发更新、删除、负责人变更、权限变更以及相同请求重放时,旧快照结果不变,新变化只进入下一轮;压测覆盖成千上万合同。
### P0-4 交接Token错误复用了24小时快照TTL
现状:轮内快照Token、页游标和已完成轮的`next_sync_token`统一执行24小时TTL。
影响:每日任务只要稍晚、停机或积压,就可能被迫全量重建;数据量增大后会形成周期性全量压力。
标准改造:只给轮内快照/游标设置短TTL;交接Token按变更日志保留期校验,或保存持久化消费位点。过期时返回可观测的最早可用位置及标准错误,消费者执行受控重建。
验收:间隔超过24小时仍可正常增量;只有位点早于服务端保留期才触发重建。
### P0-5 数据权限版本不能覆盖真实权限变化
现状:`data_scope_version`仅取`token_scope`关系的修改时间;Open Client身份关联、scope-tool变化、源数据域ACL变化不会稳定反映。
影响:权限变化后旧同步位点可能继续使用,消费者无法判断需要重建或停止派发。
标准改造:维护数据域授权版本,任何调用方身份关联、工具授权、行级ACL语义变更都原子递增;上下文与同步Token使用同一个版本。
验收:修改任一影响可见数据的授权后,旧轮立即失败关闭,新轮在新数据域版本下重建。
### P0-6 发送前权威复核(本地已实现,待部署联调)
已实现`contracts.current.batch_get`:一次读取1—200份合同当前事实,按输入顺序返回已找到记录,并明确返回`not_found_ids`;软删记录不会伪装成“查无数据”。该工具沿用SYNC服务身份,禁止调用方覆盖数据域,使用SYNC独立限流档并进入统一审计。
小趣AI侧已经按同一契约实现短时发送凭证:只有状态、到期日、负责人、客户归属、提醒字段及续约结果与本地候选一致才允许R04进入推送;缺失、删除、字段变化、响应不完整或MCP不可用均失败关闭,R03不受影响。
尚未完成:本分支未推送和部署;生产`sync`作用域还必须绑定该新工具;需要用真实合同验证负责人变更、删除、到期日修改和服务不可用四类反向场景。
## 4. 依赖顺序
1. 冻结服务端`source_domain_id`配置、单数据域范围和合同状态/提醒字段字典。
2. 完成Open Client与服务Token身份关联,补迁移、ensure和上下文测试。
3. 建立通用变更日志/Outbox与保留策略,再迁移三条变化流;不继续强化活表分页方案。
4. 使用独立公开DTO完成三条流字段映射和消费者契约测试。
5. 修正交接Token生命周期与统一数据权限版本。
6. 在测试环境用真实用户、物业、合同及变更/删除数据完成MCP侧验收。
7. 交付Open Client、稳定源数据域ID和凭证给小趣AI侧,联合验证“同步→映射→规则→事项→展示→推送前复核”。
## 5. 状态看板
| 工作项 | 状态 | 责任系统 | 下一步 |
|---|---|---|---|
| MCP协议与工具发现 | 已完成 | MCP | 保持兼容并纳入回归 |
| 服务密钥与Token基础生命周期 | 已完成 | MCP | 在租户模型完成后升级幂等键 |
| 源数据域标识与服务主体隔离 | 未完成,P0阻塞 | MCP | 增加稳定部署数据域标识和Open Client关联 |
| 三流公开字段Schema | 本地代码完成,待部署联调 | MCP | 部署后用真实三流样例跑Gateway严格契约测试 |
| 发送前合同当前状态批量复核 | 本地代码完成,待部署联调 | MCP + 小趣AI | 绑定sync作用域并完成四类真实反向用例 |
| 可靠变化日志与稳定快照 | 未完成,P0阻塞 | MCP | 设计Outbox/CDC及保留策略 |
| 权限版本与交接位点生命周期 | 未完成,P0阻塞 | MCP | 与租户/变化日志模型一起实现 |
| 小趣AI连接与消费代码 | 已完成,待部署联调 | 小趣AI | 见小趣AI仓库`PROJECT.md` |
| 真实销售合同到期推送 | 未开放 | 双方联合验收 | 所有P0通过后才允许从Shadow转Live |
## 6. 发布门禁
以下条件全部满足前,不得把MCP同步标记为生产可用,也不得向真实销售发送合同提醒:
- 稳定源数据域、服务主体隔离和连接错配反向用例通过。
- 三条变化流通过冻结Schema及真实数据样例校验。
- 并发写入、删除、断点续传、超过24小时续传和权限变化测试通过。
- 凭证不入库明文、不入日志;轮换和吊销测试通过。
- 小趣AI侧完成负责人、客户双映射和发送前实时复核。
- 完整链路具有`request_id/snapshot_id/source_domain_id`审计证据,可定位每条事项和推送的数据来源。
@@ -0,0 +1,59 @@
# 入库单 MCP 工具设计
## 1. 范围与评审结论
参考旧系统 `POST /myWarehouseIn/allListByPage` 与 `GET /myWarehouseIn/lookDetail`,新增:
| 工具 | 分类 | 用途 |
|---|---|---|
| `warehouse.in.list.query` | warehouse/read | 分页查询入库单 |
| `warehouse.in.detail.query` | warehouse/read | 按主键或入库单号查询详情 |
自评审通过,确认采用以下产品取舍:调拨入库单允许直接查询详情;关联出库单仅返回索引,
不内嵌完整出库信息。工具只读 m2,不新增业务表或 Flyway migration;启动后由
`ToolSyncService` 自动登记,需由管理员绑定 Scope 后才可调用。
## 2. 列表
- 单号:入库、调拨、关联出库、商城、送货、装机、取货、撤机单号。
- 归属:代理、仓库、入库类型、申请人。
- 状态与物流:订单状态、入库同步状态、物流公司和运单号。
- 日期:`inDateFrom/inDateTo` 精确过滤 `in_date`;`applyDateFrom/applyDateTo`
过滤 `apply_date`,结束日期含当天。不会复制旧接口将入库日期改写成申请日期的异常逻辑。
- 分页:page 默认 1,rows 默认 20、上限 100。
返回主单、来源类型、状态文案、涉及仓库、待办人、实际入库人及日期、物流和关联单号。
本页明细与待办按主单 ID 批量查询,避免 N+1。
`sourceType` 的优先级为 `ALLOCATION`、`OUTBOUND_LINKED`、`MALL`、`NORMAL`。
## 3. 详情
`id`、`inOrderNo` 至少传一个;同时传入时必须匹配同一条可见记录。详情返回:
- `record`、`sceneType`、涉及仓库;
- 按物料定义、物料索引和仓库汇总的 `items`;
- 可选 `serials` 以及完整 `serialTotal`、`serialTruncated`;
- `schedule_state=2` 的已处理流程;
- `file_type=0/1` 的申请、确认入库附件,仅返回 OSS 对象定位,不生成签名 URL;
- `relatedOutOrder`:仅出库单号和建议调用的 `warehouse.out.detail.query` 工具名。
## 4. 权限与安全
列表和详情强制复用 `PermissionService.applyWarehouseInRowFilter`:
- 总部身份查看全部;
- 代理身份可见范围是主单所属代理范围、所属或负责仓库涉及、本人参与处理三类并集;
- 代理范围包含自己、直属下级和 `warehouse_agent_auth` 授权代管代理;
- 权限组异常或代理用户未关联代理商时失败关闭;
- 详情不存在与无权访问统一返回 `NOT_FOUND_OR_FORBIDDEN`。
权限使用主查询中的 `EXISTS`,所有业务筛选均只能缩小结果。关联出库不提前判断是否可访问,
避免形成旁路探测;调用方若需要内容,必须再调用出库详情工具并接受其独立鉴权。
## 5. 验收
- 列表分页、日期校验、仓库过滤、批量富化和状态文案;
- 详情双条件、调拨单、物料汇总、SN 默认关闭/截断、附件分类、出库索引;
- 总部、代理范围、仓库负责人、流程参与人和异常配置失败关闭;
- 固定 `TZ=Asia/Shanghai` 执行全量后端测试,不连接生产数据库、不执行部署。
@@ -0,0 +1,91 @@
# 出库单 MCP 工具设计
## 1. 背景与范围
参考旧系统 `POST /myWarehouseOut/allListByPage` 和 `GET /myWarehouseOut/lookDetail`,在
`xiaoqu-agent-service` 新增两个只读工具:
| 工具 | 分类 | 用途 |
|---|---|---|
| `warehouse.out.list.query` | warehouse/read | 分页查询出库单 |
| `warehouse.out.detail.query` | warehouse/read | 按主键或出库单号查询完整详情 |
只读取 m0/m1/m2,主要数据表为 m2 的 `warehouse_out`、`warehouse_out_details`、
`warehouse`、`warehouse_out_audit_process`、`warehouse_in_out_pic`、
`warehouse_out_certificate`;收货地址和物流信息按需读取 m1。
本次不返回关联入库单详情,不修改旧模块和业务表,不新增 Flyway migration。新工具由现有
`ToolSyncService` 自动登记,初始不属于任何 Scope,发布后需在管理后台授权。
## 2. 列表工具
### 2.1 入参
- 单号模糊查询:`outOrderNo`、`allocationOrderNo`、`mallOrderNo`、`deliveryOrderNo`、
`installOrderNo`、`pickupOrderNo`。
- 归属精确查询:`agentId`、`warehouseId`、`warehouseTypeId`、`applyUserId`。
- 状态精确查询:`orderState`、`sendMode`、`outSynchronizeState`、`assetSynchronizeState`。
- 客户查询:`useCustomerType`、`useCustomerId` 精确查询,`useCustomerName` 模糊查询。
- 日期:`outDateFrom/outDateTo` 过滤 `out_date`;`applyDateFrom/applyDateTo` 过滤
`apply_date`。日期格式固定为 `yyyy-MM-dd`,业务时区为 `Asia/Shanghai`,结束日期包含当天。
- 分页:`page` 默认 1;`rows` 默认 20、上限 100,超出上限截断并返回 `notice`。
旧接口错误地把出库日期条件改写为申请日期条件,本工具不复制该行为。
### 2.2 出参
顶层统一返回 `total/page/rows/records`。每条记录包含主单号、来源类型、代理商、出库类型、
仓库集合、数量、状态及状态文案、申请人及申请时间、客户、待办人、出库确认人及日期、物流、
同步状态、关联工单号和备注。
`sourceType`:有调拨单号为 `ALLOCATION`,否则有商城订单号为 `MALL`,其余为 `NORMAL`。
`orderState`:0=待审核、1=被驳回、2=待出库、3=已完成、4=已撤销、5=作废。
列表页的仓库、待办人、出库人均按本页主单 ID 批量查询和内存聚合,禁止逐单 N+1 查询。
## 3. 详情工具
### 3.1 入参
- `id`、`outOrderNo` 至少传一个;同时传入时必须指向同一条可见记录。
- `includeSerials` 默认 `false`。
- `serialLimit` 默认 100、最大 500;只限制返回数量,`serialTotal` 始终返回完整数量。
### 3.2 出参
- `record`:主单字段、来源、状态文案、客户、同步状态和关联工单号。
- `warehouses`:出库明细涉及的仓库 ID/名称去重集合。
- `items`:`parent_id=0` 的物料汇总行,含物料编号、名称、分类、规格、仓库、数量、单价、
总额、出库人及时间。
- `serials`:仅 `includeSerials=true` 返回 SN 子行;另返回 `serialTotal/serialTruncated`。
- `processes`:`schedule_state=2` 的已处理记录,按操作时间、ID 正序,状态码与文案同时返回。
- `address`:商城订单地址或主单地址。
- `logistics`:发货方式、快递公司、运单号、配送人和收货信息。
- `files.application/files.confirmation`:申请图片和确认出库图片。服务不持有 OSS 签名密钥,
返回文件 ID、文件名及存储对象定位信息,不生成临时访问 URL。
- `certificate`:凭证备注与 `file_type=4/in_out_allocation_type=99` 的凭证文件。
调拨出库单允许直接查询,并以 `sourceType=ALLOCATION` 标识。
## 4. 行级权限
列表和详情使用完全相同的主查询权限条件,业务筛选只能缩小结果:
- 总部身份(权限组 `isagent=0`):不过滤。
- 代理商身份(权限组 `isagent=1`):可见范围为以下条件的并集:
- 主单 `agent_id` 属于自己、直属下级或 `warehouse_agent_auth` 授权代管代理;
- 出库明细涉及的仓库,其 `warehouse.agent_id` 或 `warehouse.manager_agent_id` 属于上述代理范围;
- 当前用户存在该主单的未删除审核/处理记录。
- 代理商用户未关联代理商、权限组不存在或配置异常时失败关闭。
详情不存在与无权访问统一抛出 `NOT_FOUND_OR_FORBIDDEN`,防止通过 ID/单号探测数据。
权限通过主表查询中的关联 `EXISTS` 条件完成,不先查询大量 ID 再拼接 `IN`。
## 5. 测试与验收
- 列表:分页上限、各类日期/状态/仓库条件、批量聚合和状态文案。
- 详情:ID/单号参数校验、双条件一致性、调拨单、SN 默认关闭与截断、附件/凭证分组。
- 权限:总部、代理范围、负责管理仓库、处理人、无代理失败关闭;详情查询必须复用相同过滤器。
- 构建:固定 `TZ=Asia/Shanghai` 执行后端测试;不连接生产数据库、不执行部署。
@@ -10,7 +10,7 @@ xiaoqu-ai iOS / Android / Web
▼
xiaoqu-ai Gateway / Agent Runtime ───┐
│ 用户 MCP Token(仅服务端持有)
xiaoqu-operations-ai iOS / Android │
xiaoqu-operations-ai iOS / Android / Web
│ 产品登录 Token │
▼ │
ops-assistant-service ───────────────┤
@@ -28,7 +28,7 @@ ops-assistant-service ───────────────┤
|---|---|---|---|
| 总体 | xiaoqu-mcp | 当前仓库 `xiaoqu-agent-service` | 本文 |
| 详细设计 | xiaoqu-ai iOS、Android、Web、服务端 Agent | `/Users/tonylai/xiaoquai/xiaoqu-ai`、`/Users/tonylai/jiaxk/xiaoqu-ai-android` | [`xiaoqu-ai-mcp-integration-design.md`](./xiaoqu-ai-mcp-integration-design.md) |
| 详细设计 | xiaoqu-operations-ai iOS、Android、服务端 Agent | `/Users/tonylai/jiaxk/xiaoqu-operations-ai` | [`xiaoqu-operations-ai-mcp-integration-design.md`](./xiaoqu-operations-ai-mcp-integration-design.md) |
| 详细设计 | xiaoqu-operations-ai iOS、Android、Web、服务端 Agent | `/Users/tonylai/wangheng/xiaoqu-operations-ai` | [`xiaoqu-operations-ai-mcp-integration-design.md`](./xiaoqu-operations-ai-mcp-integration-design.md) |
本文只定义两个产品必须共同遵守的架构和安全契约。项目代码落点、配置、Capability 映射和项目级验收只写在对应详细设计中。
@@ -124,7 +124,7 @@ Accept: application/json, text/event-stream
|---|---|---|
| 1 | 确认两个产品的用户映射与实际 scope | scope 已存在并绑定所需工具,不依赖客户端身份字段 |
| 2 | 完成 xiaoqu-ai 服务端接入 | iOS、Android、Web 经同一服务端适配器调用 |
| 3 | 完成 xiaoqu-operations-ai 服务端接入 | 运营 iOS、Android 经 ops-assistant-service 调用 |
| 3 | 完成 xiaoqu-operations-ai 服务端接入 | 运营 iOS、Android、Web 经 ops-assistant-service 调用 |
| 4 | 跨用户、跨租户和跨产品回归 | 无 Token 泄露、串号、越权或副作用自动重放 |
## 8. 总体验收与回退
@@ -2,111 +2,180 @@
## 1. 目标与边界
本文是 [`xiaoqu 多产品 Agent 统一接入 xiaoqu-mcp 总体设计`](./xiaoqu-mcp-multi-product-agent-design.md) 的运营项目落地设计。
本文是 [`xiaoqu 多产品 Agent 统一接入 xiaoqu-mcp 总体设计`](./xiaoqu-mcp-multi-product-agent-design.md) 的运营项目落地设计,代码基线为 `/Users/tonylai/wangheng/xiaoqu-operations-ai`。
`ops-assistant-service` 是运营项目唯一 MCP Client,同时服务运营 iOS 和 Android,并保留现有编排:
运营 iOS、Android、Web 共用 `ops-assistant-service` 中的一套 MCP Client:
```text
运营 iOS / Android
iOS / Android / Web
│ 产品登录 Token
▼
Router → Planner → Executor → Answer
│
▼
xiaoqu-mcp
ops-assistant-service REST
│
▼
Router → Resolver → Planner → Executor → Answer
│
▼
Capability Registry
│ 固定 adapter_id
▼
xiaoqu-mcp Adapter
│ 用户 MCP Token(仅服务端持有)
▼
xiaoqu-agent-service /mcp
```
客户端不新增 MCP 代码。项目目录:`/Users/tonylai/jiaxk/xiaoqu-operations-ai`。
WebSocket 只负责会话进度和完成通知,不承载 MCP 调用。客户端不新增 MCP 代码,不保存用户 MCP Token、MCP Session ID 或 `X-Open-Key`。
## 2. 当前代码依据
- `services/ops-assistant-service` 是 FastAPI + Python 3.12 服务。
- 当前依赖中没有 MCP SDK,也没有 `tools/list` / `tools/call` 客户端。
- `src/ops_assistant_service/capability_registry.py` 维护静态 `CAPABILITY_REGISTRY`。
- `src/ops_assistant_service/executor.py` 当前执行 `customer_issues.*`、`smartclean.query` 等本地 Capability。
- 用户身份来自 Gateway JWT,并通过 `ops_identity_mappings` 解析租户、用户和 `scope_tags`。
- 最小接入点是 Executor 的工具适配层,不新增第二套编排器。
- `web/ops-assistant/src/api.ts` 通过 `/api/v1/ops-assistant/*` REST 提交消息和读取业务数据。
- `web/ops-assistant/src/ConversationPage.tsx` 使用 WebSocket 订阅会话进度,消息提交仍走 REST。
- `services/ops-assistant-service` 是 FastAPI + Python 3.12 服务,现有 `httpx` 足够实现 MCP HTTP Client。
- `worker.py` 同时运行旧流水线和 `contextual_fulfillment_v2`;新流水线包含 Router、Resolver、Planner、Recovery、Executor、Answer。
- `capability_registry.py` 维护静态 `CAPABILITY_REGISTRY`、冻结的 Capability Contract 和 `adapter_id` 白名单。
- 旧 `executor.py` 与新 `contextual_execution.py` 最终都调用 `execute_registered_tool()`。
- 当前 Registry 包含 `customer_issues.*`、`vision_analyze`、`work_items.list`、`read_only_todos.list`、`knowledge_entries.search`,已不包含 `smartclean.query`。
- Gateway JWT 在入口解析为共享身份,再通过 `ops_identity_mappings` 映射为运营 `tenant_id`、`user_id` 和 DataScope;后台 Worker 执行时使用运营 UUID。
## 3. 配置设计
因此不在 Web、路由层或某一条流水线中增加专用 MCP 分支。唯一接入点是两套流水线共用的 Capability Adapter 执行边界。
在 `src/ops_assistant_service/config.py` 的 `Settings` 中增加以下 `OPS_ASSISTANT_` 配置:
## 3. 设计决策
### 3.1 统一 Adapter
在 `CAPABILITY_REGISTRY` 中注册稳定的本地 Capability 和固定 `adapter_id`,由 `execute_registered_tool()` 将 MCP Adapter ID 路由到 `XiaoquMcpAdapter`。
```text
Planner 选择本地 Capability
→ 校验冻结的 Capability Contract
→ 校验输入 schema、scope_tags、DataScope
→ execute_registered_tool(adapter_id)
→ XiaoquMcpAdapter
→ MCP tools/call
```
不得把远端 `tools/list` 原样注册给 Planner。`tools/list` 只用于启动或联调时验证远端工具是否与本地冻结契约一致。
### 3.2 显式依赖传递
`XiaoquMcpRuntime` 由 `worker.build_pipeline_runtime()` 创建,并通过显式参数传给旧 `run_executor_tick` 和新 `run_contextual_executor_tick`。禁止用模块级全局变量或从请求上下文隐式读取用户。
`PipelineRuntime.close()` 负责关闭 MCP HTTP Client 和清理进程内 Session 缓存。未启用 MCP 时传入 `None`,现有本地 Capability 保持原行为。
## 4. 配置设计
在 `src/ops_assistant_service/config.py` 的 `Settings` 增加以下 `OPS_ASSISTANT_` 配置:
| 变量 | 默认值 | 说明 |
|---|---|---|
| `XQ_MCP_ENABLED` | `false` | 是否启用 MCP Capability |
| `XQ_MCP_URL` | unset | xiaoqu-mcp 内网地址 |
| `XQ_MCP_OPEN_KEY` | unset | Token 初始化凭证,类型使用 `SecretStr` |
| `XQ_MCP_SCOPE_CODE` | `operation` | 服务端固定 scope,禁止由 Planner 或客户端选择 |
| `XQ_MCP_OPEN_KEY` | unset | Token 初始化凭证,使用 `SecretStr` |
| `XQ_MCP_SCOPE_CODE` | `operation` | 服务端固定 scope,禁止由客户端或模型选择 |
| `XQ_MCP_TIMEOUT_SECONDS` | `10` | MCP 请求超时 |
启用时必须同时提供 URL、Open Key 和 scope。`operation` 已存在于 dev 种子,其他环境启用前仍需核对实际 scope 和工具绑定;生产配置不得写入仓库。
启用时 URL、Open Key 和 scope 必填。`operation` 已存在于 xiaoqu-mcp dev 种子;development、test、staging、production 均须分别核对真实 scope 和工具绑定。凭证只由部署 Secret 注入。
## 4. 最小模块
## 5. 最小模块
只新增两个模块:
### 5.1 `mcp_token_provider.py`
- `src/ops_assistant_service/mcp_client.py`
- 使用现有 `httpx` 完成 JSON-RPC、Session ID、Bearer Header、超时和错误映射。
- 按租户、`user_id` 和 scope 隔离 MCP Session。
- `src/ops_assistant_service/mcp_token_provider.py`
- 接收已验证的 `SharedIdentity`。
- 从受信任用户资料源取得 mobile/email 并调用 `ensure`。
- 按租户、`user_id` 和 scope 缓存 Token;401 时只刷新当前用户一次。
- 不接受客户端或 Planner 传入的身份字段作为覆盖参数。
输入为 Worker 已持有的运营 `tenant_id`、`user_id` 和固定 scope:
不引入通用 MCP 框架、不新增 Agent,也不直连 xiaoqu-mcp 数据库。
1. 从 `ops_identity_mappings` 反查可信的 `shared_tenant_id`、`shared_user_id`。
2. 通过现有只读共享用户 Port 获取权威 mobile;权威数据源存在 email 时可作为备用。
3. 使用固定 `scopeCode` 调用 `/api/open/token/ensure`。
4. 按运营租户、运营用户、共享用户和 scope 缓存 Token。
## 5. Capability 映射
现有 `SharedUserPort` 尚未暴露 mobile/email,需要最小扩展其只读 DTO 和 PostgreSQL Adapter;不得复制用户表,也不得使用 Web、Planner、工具参数或模型输出中的身份字段。
在 `capability_registry.py` 注册固定的本地 Capability,不把远端 `tools/list` 原样暴露给 Planner:
### 5.2 `mcp_client.py`
| 本地 Capability | MCP 工具 | 首期权限 |
|---|---|---|
| `xiaoqu.agent_count` | `agent.count` | read |
| `xiaoqu.agent_list` | `agent.list.query` | read |
| `xiaoqu.property_list` | `property.list.query` | read |
使用现有 `httpx.Client` 管理:
Planner 只能选择注册表中的名称和 schema。Executor 在调用前执行本地 Capability、用户 `scope_tags`、租户/DataScope 校验;xiaoqu-mcp 再执行 Token scope 和服务端数据权限校验。
- `initialize`、`notifications/initialized`、`tools/list`、`tools/call`。
- Bearer Header、`Mcp-Session-Id`、JSON-RPC ID 和响应校验。
- 连接、读取、写入和总超时。
- 按运营租户、运营用户和 scope 隔离 Session。
- 关闭时释放 HTTP Client,不持久化 MCP Session。
远端结果经过字段白名单投影后再进入 Answer。Token、Authorization Header 和未授权字段不得进入 execution trace。
### 5.3 `mcp_adapter.py`
## 6. Executor 接入
`XiaoquMcpAdapter` 只接收已经验证的 Capability Contract、运营身份和参数:
在 `executor.py` 增加 MCP 只读分支:
- 根据固定映射选择远端 MCP 工具。
- 调用 Token Provider 和 MCP Client。
- 校验远端响应并执行字段白名单投影。
- 返回现有 Executor/Answer 可消费的字典,不返回 Token、Header 或原始未授权字段。
不引入通用 MCP 框架或第二套 Agent 编排器。
## 6. Capability 映射
首期只注册只读 Capability:
| 本地 Capability | `adapter_id` | MCP 工具 | 权限 |
|---|---|---|---|
| `xiaoqu.agent_count` | `mcp.xiaoqu.agent_count.v1` | `agent.count` | read |
| `xiaoqu.agent_list` | `mcp.xiaoqu.agent_list.v1` | `agent.list.query` | read |
| `xiaoqu.property_list` | `mcp.xiaoqu.property_list.v1` | `property.list.query` | read |
每项必须在 `CAPABILITY_REGISTRY` 中声明输入/输出 schema、风险、审批、超时重试、审计事件和结果模板,并将精确 `adapter_id` 加入 `EXECUTION_ADAPTER_IDS`。已有本地 Capability 不改名、不改 Adapter、不改变执行语义。
## 7. 身份与权限
最终授权取交集:
```text
validate_tool_input
→ 校验本地 Capability 和参数白名单
→ 读取 SharedIdentity / DataScope
→ mcp_token_provider 获取当前用户 Token
→ mcp_client 调用 tools/call
→ 结果字段投影
→ 现有 execution_trace / Answer
本地 Capability allowlist
∩ 当前运营用户 scope_tags / DataScope
∩ MCP Token scope
∩ xiaoqu-mcp 用户、租户和行级数据范围
```
现有 `customer_issues.*` 和 `smartclean.query` 保持不变。MCP 不可用时返回稳定的 `TOOL_UNAVAILABLE`,不得回退公共 Token 或数据库直连。
- REST 和 WebSocket 只负责验证产品登录态并建立运营身份。
- Planner 只能选择 Registry 中的 Capability,不决定用户、租户或 scope。
- Worker 必须在每次执行前确认 `ops_identity_mappings` 仍有效,不能仅依赖消息入库时的旧快照。
- `tools/list` 与 `tools/call` 使用同一用户 Token 和 MCP Session;服务端在两个入口都强制鉴权。
## 7. 开发顺序
## 8. 异步执行与生命周期
1. 核对目标环境中的 `operation` scope、工具绑定和用户资料映射。
2. 增加 Settings 配置及启用校验。
3. 实现 `mcp_token_provider.py` 与 `mcp_client.py`,覆盖 JSON-RPC 和 Session 单元测试。
4. 注册 3 个只读 Capability 并接入 Executor。
5. 增加用户隔离、scope 拒绝、401 重连、Token 脱敏和 MCP 不可用测试。
6. 在 development/test 环境联调,通过后再进入 staging。
Web、iOS、Android 提交消息后获得 `202`;Worker 在后台完成 Agent 和工具执行,再由消息查询或 WebSocket 返回进度与结果。
## 8. 项目验收
- `build_pipeline_runtime()` 创建一个进程级 `XiaoquMcpRuntime`,但 Token 和 Session 按用户隔离。
- Worker 重启后 Session 不恢复;首次调用重新 `ensure` 和 `initialize`。
- 用户映射失效、scope 版本变化或 MCP 401 时,只清理该用户缓存。
- MCP 401:重新 `ensure` 和连接一次;只读调用最多重试一次。
- MCP 403:直接返回权限不足,不刷新 Token。
- 超时、协议错误或服务不可用:映射为稳定的 `TOOL_UNAVAILABLE`,并让当前 Execution Attempt 进入可审计失败状态。
- 不自动重放写操作;首期不接写工具。
- 运营 iOS、Android 继续只调用 `ops-assistant-service`,客户端无 MCP Token。
## 9. 开发顺序
1. 核对各环境 `operation` scope、三项工具绑定和用户映射数据源。
2. 扩展只读 SharedUser Port,完成运营 UUID 到 mobile/email 的可信解析测试。
3. 增加 Settings 配置、启用校验和 `XiaoquMcpRuntime` 生命周期。
4. 实现 Token Provider、MCP Client 和 Adapter 的协议单元测试。
5. 注册三项本地 Capability,并让旧 Executor 与 contextual v2 共用同一 Adapter。
6. 覆盖用户隔离、租户隔离、scope 拒绝、401、403、超时、脱敏和 Worker 重启测试。
7. 分别从 iOS、Android、Web 提交对话请求,验证异步进度和最终结果一致。
## 10. 项目验收
- iOS、Android、Web 均只调用 `ops-assistant-service`,客户端没有 MCP Token、Session ID 或 Open Key。
- Web 消息走 REST,WebSocket 仅订阅进度/完成通知。
- 旧流水线和 `contextual_fulfillment_v2` 对同一 MCP Capability 使用同一个 Adapter。
- 用户 A/B、不同租户和 scope 的 Token 与 MCP Session 不互用。
- Planner 不能选择未注册的远程工具,Executor 不能绕过 `scope_tags` 和 DataScope。
- `tools/list`、`tools/call` 使用同一用户 Session 和 Bearer Token。
- MCP 401 只刷新当前用户;只读请求最多重试一次。
- 测试、日志、trace、配置和 Git 历史中均无凭证明文。
- Planner 不能选择未注册工具;冻结 Contract、`scope_tags`、DataScope 或 MCP scope 任一不满足即拒绝。
- 现有七项本地 Capability 的契约和行为不受影响。
- MCP 401 只刷新当前用户;MCP 不可用时不回退公共 Token、数据库直连或 xiaoqu-ai Agent。
- 测试、日志、trace、配置和 Git 历史中无凭证明文。
## 9. 当前不做
## 11. 当前不做
- 不把 MCP Client 放进运营 iOS 或 Android。
- 不在运营 iOS、Android、Web 中实现 MCP Client。
- 不动态注册全部远程工具。
- 不在首期引入写工具;写工具需另行设计审批、幂等和补偿协议。
- 不复制 xiaoqu-mcp 数据库,不调用 xiaoqu-ai Agent 代查数据。
- 不恢复或假定已删除的 `smartclean.query`。
- 不复制 xiaoqu-mcp 或共享用户数据库。
- 不在首期引入写工具;写工具需另行审批幂等、确认和补偿协议。
+128
View File
@@ -5,6 +5,7 @@ import { api, setCsrf } from './api'
const menus = [
{ id: 'tokens', label: '用户与 Token', icon: '👥' },
{ id: 'clients', label: '服务密钥', icon: '🔐' },
{ id: 'tools', label: '工具管理', icon: '🔧' },
{ id: 'scopes', label: '作用域管理', icon: '🧩' },
{ id: 'audit', label: '审计日志', icon: '📋' },
@@ -20,6 +21,7 @@ const userPage = reactive({ page: 1, pageSize: 20, total: 0 })
const selectedUser = ref(null)
const permission = ref(null)
const tokens = ref([])
const openClients = ref([])
const scopes = ref([])
const tools = ref([])
const audits = ref([])
@@ -28,6 +30,9 @@ const auditFilter = reactive({ person: '', tokenPrefix: '', tool: '', result: ''
const tokenDialog = ref(false)
const tokenForm = reactive({ id: null, memo: '', scopeIds: [], scopeId: null, expireTime: null })
const issuedTokens = ref([])
const clientDialog = ref(false)
const clientForm = reactive({ id: null, name: '', memo: '', scopeIds: [] })
const issuedOpenKey = ref(null)
const scopeDialog = ref(false)
const scopeForm = reactive({ id: null, name: '', code: '', memo: '' })
const scopeToolsDialog = ref(false)
@@ -41,6 +46,20 @@ const groupedTools = computed(() => tools.value.reduce((groups, tool) => {
;(groups[category] ||= []).push(tool)
return groups
}, {}))
const issuedKeyCurl = computed(() => {
if (!issuedOpenKey.value) return ''
const payload = JSON.stringify({
scopeCode: 'sync', principalType: 'service', serviceName: issuedOpenKey.value.client.name,
})
return [
"curl -X POST 'https://agent.ququclean.com/api/open/token/ensure' \\",
` -H 'X-Open-Key: ${issuedOpenKey.value.plaintext}' \\`,
" -H 'Content-Type: application/json' \\",
` -d ${shellQuote(payload)}`,
].join('\n')
})
function shellQuote(value) { return `'${value.replaceAll("'", `'"'"'`)}'` }
function qs(values) {
const params = new URLSearchParams()
@@ -76,6 +95,7 @@ async function logout() {
async function switchMenu(id) {
current.value = id
if (id === 'tokens') await loadUsers()
if (id === 'clients') await loadOpenClients()
if (id === 'tools') await loadTools()
if (id === 'scopes') await loadScopes()
if (id === 'audit') await Promise.all([loadAudit(), api('/tools').then(data => { tools.value = data })])
@@ -146,6 +166,54 @@ async function deleteToken(token) {
}).catch(() => {})
}
async function loadOpenClients() {
;[openClients.value, scopes.value] = await Promise.all([api('/open-clients'), api('/scopes')])
}
function openClient(client = null) {
Object.assign(clientForm, client ? {
id: client.id, name: client.name, memo: client.memo || '', scopeIds: [...client.scopeIds],
} : { id: null, name: '', memo: '', scopeIds: [] })
clientDialog.value = true
}
async function saveClient() {
if (!clientForm.name.trim()) return ElMessage.warning('请填写调用方名称')
if (!clientForm.scopeIds.length) return ElMessage.warning('至少选择一个作用域')
await run(async () => {
const body = JSON.stringify({ name: clientForm.name, memo: clientForm.memo, scopeIds: clientForm.scopeIds })
if (clientForm.id) await api(`/open-clients/${clientForm.id}`, { method: 'PUT', body })
else issuedOpenKey.value = await api('/open-clients', { method: 'POST', body })
clientDialog.value = false
await loadOpenClients()
ElMessage.success(clientForm.id ? '服务密钥配置已保存' : '服务密钥已创建')
}).catch(() => {})
}
async function setClientStatus(client) {
if (client.status === 1) {
try { await ElMessageBox.confirm(`吊销后 ${client.name} 将立即无法用此密钥换取 Token,确认继续?`, '吊销服务密钥', { type: 'warning' }) }
catch { return }
}
await run(async () => {
await api(`/open-clients/${client.id}/status`, {
method: 'PATCH', body: JSON.stringify({ status: client.status === 1 ? 0 : 1 }),
})
await loadOpenClients()
ElMessage.success(client.status === 1 ? '服务密钥已吊销' : '服务密钥已启用')
}).catch(() => {})
}
async function rotateClient(client) {
try { await ElMessageBox.confirm(`轮换后 ${client.name} 的旧密钥立即失效,确认继续?`, '轮换服务密钥', { type: 'warning' }) }
catch { return }
await run(async () => {
issuedOpenKey.value = await api(`/open-clients/${client.id}/rotate`, { method: 'POST' })
await loadOpenClients()
ElMessage.success('服务密钥已轮换')
}).catch(() => {})
}
async function loadTools() {
tools.value = await api(`/tools${qs(toolFilter)}`)
}
@@ -212,6 +280,7 @@ async function copyText(value) { await navigator.clipboard.writeText(value); ElM
async function copyAllTokens() {
await copyText(issuedTokens.value.map(item => `${item.token.scopeNames.join('/')}: ${item.plaintext}`).join('\n'))
}
function clearIssuedOpenKey() { issuedOpenKey.value = null }
onMounted(async () => {
try {
@@ -242,6 +311,7 @@ onMounted(async () => {
<div class="logo"><span>趣</span> 智能体服务后台</div>
<nav aria-label="主导航">
<button :class="['menu-item', { active: current === 'tokens' }]" data-testid="admin-menu-tokens" @click="switchMenu('tokens')">👥 用户与 Token</button>
<button :class="['menu-item', { active: current === 'clients' }]" data-testid="admin-menu-clients" @click="switchMenu('clients')">🔐 服务密钥</button>
<button :class="['menu-item', { active: current === 'tools' }]" data-testid="admin-menu-tools" @click="switchMenu('tools')">🔧 工具管理</button>
<button :class="['menu-item', { active: current === 'scopes' }]" data-testid="admin-menu-scopes" @click="switchMenu('scopes')">🧩 作用域管理</button>
<button :class="['menu-item', { active: current === 'audit' }]" data-testid="admin-menu-audit" @click="switchMenu('audit')">📋 审计日志</button>
@@ -311,6 +381,31 @@ onMounted(async () => {
</div>
</template>
<template v-else-if="current === 'clients'">
<article class="card">
<div class="card-title">
<span>服务密钥调用方</span>
<el-button type="primary" data-testid="admin-btn-new-client" @click="openClient()">+ 创建服务密钥</el-button>
</div>
<el-alert title="密钥明文只在创建或轮换成功后展示一次;已通过环境变量配置的存量密钥继续有效,但不在本页展示。" type="info" :closable="false" show-icon />
<el-table :data="openClients" class="client-table" data-testid="admin-table-clients" empty-text="暂无服务密钥">
<el-table-column prop="name" label="调用方" min-width="140" />
<el-table-column label="密钥前缀" min-width="145"><template #default="{ row }"><code>{{ row.keyPrefix }}…</code></template></el-table-column>
<el-table-column prop="memo" label="用途备注" min-width="180" />
<el-table-column label="允许作用域" min-width="200"><template #default="{ row }"><el-tag v-for="(name, index) in row.scopeNames" :key="row.scopeIds[index]" size="small" effect="plain">{{ name }}({{ row.scopeCodes[index] }})</el-tag></template></el-table-column>
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">{{ row.status === 1 ? '正常' : '已吊销' }}</el-tag></template></el-table-column>
<el-table-column label="最后使用" min-width="155"><template #default="{ row }">{{ date(row.lastUsedTime) }}</template></el-table-column>
<el-table-column label="更新时间" min-width="155"><template #default="{ row }">{{ date(row.modifyTime) }}</template></el-table-column>
<el-table-column label="操作" width="205" fixed="right"><template #default="{ row }">
<el-button link type="primary" :data-testid="`admin-client-edit-${row.id}`" @click="openClient(row)">编辑</el-button>
<el-button link type="warning" :data-testid="`admin-client-rotate-${row.id}`" @click="rotateClient(row)">轮换</el-button>
<el-button link :type="row.status === 1 ? 'danger' : 'success'" :data-testid="`admin-client-status-${row.id}`" @click="setClientStatus(row)">{{ row.status === 1 ? '吊销' : '启用' }}</el-button>
</template></el-table-column>
</el-table>
<p class="hint">服务密钥用于调用 <code>/api/open/token/ensure</code> 换取业务 Token;作用域决定该密钥能为哪些数据域签发或获取 Token。</p>
</article>
</template>
<template v-else-if="current === 'tools'">
<article class="card">
<div class="toolbar">
@@ -416,6 +511,25 @@ onMounted(async () => {
<template #footer><el-button data-testid="admin-scope-cancel" @click="scopeDialog = false">取消</el-button><el-button type="primary" data-testid="admin-scope-save" @click="saveScope">保存</el-button></template>
</el-dialog>
<el-dialog v-model="clientDialog" :title="clientForm.id ? '编辑服务密钥' : '创建服务密钥'" width="560px" destroy-on-close>
<el-form label-width="92px">
<el-form-item label="调用方名称"><el-input v-model="clientForm.name" maxlength="64" placeholder="如 crm" aria-label="服务密钥调用方名称" data-testid="admin-client-name" /></el-form-item>
<el-form-item label="用途备注"><el-input v-model="clientForm.memo" type="textarea" maxlength="255" placeholder="如:CRM 合同增量同步" aria-label="服务密钥用途备注" data-testid="admin-client-memo" /></el-form-item>
<el-form-item label="允许作用域">
<el-checkbox-group v-model="clientForm.scopeIds" data-testid="admin-client-scopes">
<el-checkbox
v-for="scope in scopes"
:key="scope.id"
:value="scope.id"
:disabled="scope.status !== 1 && !clientForm.scopeIds.includes(scope.id)"
>{{ scope.name }}({{ scope.code }})<template v-if="scope.status !== 1"> · 已停用</template></el-checkbox>
</el-checkbox-group>
<div class="hint" style="margin:4px 0 0">CRM 后台增量同步请选择 sync;其他调用方按实际用途授权。</div>
</el-form-item>
</el-form>
<template #footer><el-button data-testid="admin-client-cancel" @click="clientDialog = false">取消</el-button><el-button type="primary" data-testid="admin-client-save" @click="saveClient">保存</el-button></template>
</el-dialog>
<el-dialog v-model="scopeToolsDialog" :title="`配置工具 · ${activeScope?.name || ''}`" width="680px" destroy-on-close>
<el-checkbox-group v-model="selectedToolIds" class="tool-groups" data-testid="admin-scope-tool-options">
<section v-for="(items, category) in groupedTools" :key="category" class="tool-group"><h3>{{ category }}</h3><el-checkbox v-for="tool in items" :key="tool.id" :value="tool.id" :data-testid="`admin-scope-tool-${tool.id}`"><code>{{ tool.name }}</code><small>{{ tool.description }}</small></el-checkbox></section>
@@ -432,4 +546,18 @@ onMounted(async () => {
</div>
<template #footer><el-button type="primary" data-testid="admin-copy-all-tokens" @click="copyAllTokens">全部复制</el-button><el-button data-testid="admin-close-token" @click="issuedTokens = []">我已保存</el-button></template>
</el-dialog>
<el-dialog :model-value="!!issuedOpenKey" title="服务密钥已生成" width="720px" :close-on-click-modal="false" @close="clearIssuedOpenKey">
<el-alert title="明文只展示这一次,请立即复制并交给对应系统安全保存。关闭后后台无法找回,只能轮换。" type="warning" :closable="false" show-icon />
<template v-if="issuedOpenKey">
<div class="issued-row">
<code class="token-secret" data-testid="admin-issued-client-key">{{ issuedOpenKey.plaintext }}</code>
<el-button link type="primary" data-testid="admin-copy-client-key" @click="copyText(issuedOpenKey.plaintext)">复制密钥</el-button>
</div>
<p class="field-label key-example-label">CRM 换取服务 Token 示例</p>
<pre class="key-example" data-testid="admin-client-curl">{{ issuedKeyCurl }}</pre>
<p class="hint">如果绑定的不是 sync 作用域,请按实际作用域修改请求中的 scopeCode 和主体参数。</p>
</template>
<template #footer><el-button type="primary" data-testid="admin-copy-client-curl" @click="copyText(issuedKeyCurl)">复制调用示例</el-button><el-button data-testid="admin-close-client-key" @click="clearIssuedOpenKey">我已保存</el-button></template>
</el-dialog>
</template>
+3
View File
@@ -62,4 +62,7 @@ code { font-family: Menlo, Consolas, monospace; font-size: 12px; }
.token-secret { display: block; margin-top: 14px; padding: 14px; word-break: break-all; border-radius: 4px; background: #f4f4f5; font-size: 13px; }
.issued-row { display: flex; align-items: center; gap: 10px; margin-top: 12px; }
.issued-row .token-secret { flex: 1; margin-top: 0; padding: 9px 12px; }
.client-table { margin-top: 16px; }
.key-example-label { margin: 18px 0 7px; }
.key-example { margin: 0; padding: 14px; overflow: auto; color: #e6edf3; background: #1f2328; border-radius: 5px; font: 12px/1.6 Menlo, Consolas, monospace; white-space: pre-wrap; word-break: break-all; }
@media (max-width: 1200px) { .token-layout { grid-template-columns: 300px minmax(640px, 1fr); } }
@@ -9,11 +9,15 @@ test('prototype interactions expose stable test ids', async () => {
'admin-input-password',
'admin-btn-login',
'admin-menu-tokens',
'admin-menu-clients',
'admin-menu-tools',
'admin-menu-scopes',
'admin-menu-audit',
'admin-input-user-search',
'admin-btn-issue-token',
'admin-btn-new-client',
'admin-table-clients',
'admin-issued-client-key',
'admin-table-tools',
'admin-btn-new-scope',
'admin-table-scopes',
@@ -0,0 +1,15 @@
-- 回滚 V4:删除 principal_type / subject_name 列,user_id 恢复 NOT NULL。
-- 回滚后服务 Token 无法被识别(AuthService 不再区分主体类型),存量 principal_type='service'
-- 的 Token 会因 user_id 为空无法解析人员而 401,等于全部失效;
-- 回滚前应先禁用或删除服务 Token(UPDATE agent_api_key SET status=0 WHERE principal_type='service')。
-- 残余 user_id 为 NULL 的行回填 0 以通过 NOT NULL 约束(这些行已不再参与鉴权)。
UPDATE agent_api_key SET user_id = 0 WHERE user_id IS NULL;
ALTER TABLE agent_api_key
DROP COLUMN principal_type;
ALTER TABLE agent_api_key
DROP COLUMN subject_name;
ALTER TABLE agent_api_key
MODIFY COLUMN user_id BIGINT NOT NULL COMMENT '所属人(m0.user.id)';
@@ -0,0 +1,6 @@
-- 回滚 V5__tool_description_length.sql
-- 先截断超长描述再收窄列,避免 Data truncation
UPDATE tool SET description = LEFT(description, 512) WHERE CHAR_LENGTH(description) > 512;
ALTER TABLE tool
MODIFY COLUMN description VARCHAR(512) NULL COMMENT '工具描述';
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS app_open_client_scope;
DROP TABLE IF EXISTS app_open_client;
+28 -2
View File
@@ -14,10 +14,36 @@ 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 / property.list.query
-- 作用域-工具:sys.ping / agent.count / agent.list.query / property.list.query / agent.detail.query / property.detail.query
-- / mall.order.list.query / mall.order.detail.query / mall.order.logistics.query
-- / contract.list.query / contract.detail.query / integration.context.get
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', 'property.list.query')
WHERE s.code = 'operation' AND t.name IN ('sys.ping', 'agent.count', 'agent.list.query', 'property.list.query', 'agent.detail.query', 'property.detail.query', 'mall.order.list.query', 'mall.order.detail.query', 'mall.order.logistics.query', 'contract.list.query', 'contract.detail.query', 'integration.context.get')
AND NOT EXISTS (SELECT 1 FROM scope_tool st WHERE st.scope_id = s.id AND st.tool_id = t.id);
-- 作用域:hardware(出纸机查询)
INSERT INTO agent_scope (name, code, memo, status, create_id)
SELECT '出纸机', 'hardware', 'dev 种子:出纸机查询作用域', 1, 0
WHERE NOT EXISTS (SELECT 1 FROM agent_scope WHERE code = 'hardware');
-- 作用域-工具:hardware.list.query / hardware.detail.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 = 'hardware' AND t.name IN ('hardware.list.query', 'hardware.detail.query')
AND NOT EXISTS (SELECT 1 FROM scope_tool st WHERE st.scope_id = s.id AND st.tool_id = t.id);
-- 作用域:sync(SYNC 增量同步;changes 工具由 REQ-20260911-0013 注册并绑定到本作用域)
-- 个人 Token 禁调 SYNC 工具靠 token_scope 白名单天然生效:个人 Token 不绑定本作用域即 PERMISSION_DENIED,
-- 无需任何拦截代码。只为服务 Token(principal_type=service)勾选 sync 作用域。
INSERT INTO agent_scope (name, code, memo, status, create_id)
SELECT '数据同步', 'sync', 'SYNC 增量同步作用域(5628 注册 *.changes.list 工具后绑定,仅服务 Token 使用)', 1, 0
WHERE NOT EXISTS (SELECT 1 FROM agent_scope WHERE code = 'sync');
-- sync 作用域-工具:服务 Token 自检、三条变化流及发送前合同复核
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 = 'sync' AND t.name IN ('integration.context.get', 'users.changes.list', 'properties.changes.list', 'contracts.changes.list', 'contracts.current.batch_get')
AND NOT EXISTS (SELECT 1 FROM scope_tool st WHERE st.scope_id = s.id AND st.tool_id = t.id);
COMMIT;
@@ -156,6 +156,12 @@ public class AdminConsoleController {
tokenService.delete(id, adminId(session));
}
/** 轮换:禁用旧把并按原主体/作用域签新把,明文仅在本次响应返回(REQ-20260911-0014)。 */
@PostMapping("/tokens/{id}/rotate")
public List<AdminTokenService.IssuedToken> rotateToken(@PathVariable Long id, HttpSession session) {
return tokenService.rotate(id, adminId(session));
}
@GetMapping("/tools")
public List<ToolView> tools(@RequestParam(defaultValue = "") String category,
@RequestParam(defaultValue = "") String kind,
@@ -0,0 +1,65 @@
package me.iiv.agent.admin;
import jakarta.servlet.http.HttpSession;
import me.iiv.agent.open.OpenClientService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** 复用现有管理员会话的服务密钥管理接口。 */
@RestController
@RequestMapping("/api/admin/open-clients")
public class AdminOpenClientController {
private final OpenClientService service;
public AdminOpenClientController(OpenClientService service) {
this.service = service;
}
@GetMapping
public List<OpenClientService.ClientView> list() {
return service.list();
}
@PostMapping
public OpenClientService.IssuedClient create(@RequestBody OpenClientService.ClientRequest body,
HttpSession session) {
return service.create(body, adminId(session));
}
@PutMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void update(@PathVariable Long id, @RequestBody OpenClientService.ClientRequest body,
HttpSession session) {
service.update(id, body, adminId(session));
}
@PatchMapping("/{id}/status")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void status(@PathVariable Long id, @RequestBody StatusRequest body, HttpSession session) {
if (body == null) throw new org.springframework.web.server.ResponseStatusException(
HttpStatus.BAD_REQUEST, "请求内容不能为空");
service.status(id, body.status(), adminId(session));
}
@PostMapping("/{id}/rotate")
public OpenClientService.IssuedClient rotate(@PathVariable Long id, HttpSession session) {
return service.rotate(id, adminId(session));
}
private static Long adminId(HttpSession session) {
return (Long) session.getAttribute(AdminSessionFilter.ADMIN_SESSION_ID);
}
public record StatusRequest(byte status) {}
}
@@ -55,23 +55,66 @@ public class AdminTokenService {
/** 签发一把绑定单个作用域的 Token;人员/作用域合法性由调用方保证。 */
public IssuedToken issueSingle(Long userId, String memo, Long scopeId, Date expireTime, Long creatorId) {
String plaintext = "xqak_" + AdminAuthController.randomHex(24);
AgentApiKey token = new AgentApiKey();
AgentApiKey token = newToken(memo, expireTime, creatorId);
token.setUserId(userId);
token.setKeyPrefix(plaintext.substring(0, 13));
token.setKeyHash(AuthService.sha256Hex(plaintext));
token.setKeyCipher(tokenCipher.encrypt(plaintext));
token.setPrincipalType(AgentApiKey.PRINCIPAL_TYPE_USER);
return insert(token, scopeId, creatorId);
}
/**
* 签发服务 Token(principal_type=service):不绑定人员,行级权限不适用,
* 数据范围由绑定的作用域决定(scope 粒度)。subjectName 标识服务主体,供 get-or-create 查找。
*/
public IssuedToken issueServiceToken(String serviceName, String memo, Long scopeId, Date expireTime, Long creatorId) {
if (serviceName == null || !serviceName.trim().matches("[a-z][a-z0-9_-]{0,63}")) bad("服务主体标识格式无效");
AgentApiKey token = newToken(memo, expireTime, creatorId);
token.setPrincipalType(AgentApiKey.PRINCIPAL_TYPE_SERVICE);
token.setSubjectName(serviceName.trim());
return insert(token, scopeId, creatorId);
}
private AgentApiKey newToken(String memo, Date expireTime, Long creatorId) {
AgentApiKey token = new AgentApiKey();
token.setMemo(trim(memo, 255));
token.setStatus((byte) 1);
token.setExpireTime(expireTime);
token.setCreateId(creatorId);
token.setModifyId(creatorId);
token.setSoftDelete((byte) 0);
return token;
}
private IssuedToken insert(AgentApiKey token, Long scopeId, Long creatorId) {
String plaintext = "xqak_" + AdminAuthController.randomHex(24);
token.setKeyPrefix(plaintext.substring(0, 13));
token.setKeyHash(AuthService.sha256Hex(plaintext));
token.setKeyCipher(tokenCipher.encrypt(plaintext));
tokenMapper.insert(token);
replaceScopes(token.getId(), List.of(scopeId), creatorId);
return new IssuedToken(plaintext, view(token));
}
/** 轮换:禁用旧把(吊销立即生效,AkAuthFilter 校验 status),按原主体/作用域/过期时间签新把,明文逐把返回。 */
@Transactional(transactionManager = "svcTransactionManager")
public List<IssuedToken> rotate(Long tokenId, Long operatorId) {
AgentApiKey current = activeToken(tokenId);
List<Long> scopeIds = relationMapper.selectList(new LambdaQueryWrapper<TokenScope>()
.eq(TokenScope::getTokenId, tokenId).eq(TokenScope::getSoftDelete, 0))
.stream().map(TokenScope::getScopeId).toList();
if (scopeIds.isEmpty()) bad("Token 未绑定作用域,无法轮换");
current.setStatus((byte) 0);
current.setModifyId(operatorId);
tokenMapper.updateById(current);
boolean service = AgentApiKey.PRINCIPAL_TYPE_SERVICE.equals(current.getPrincipalType());
return scopeIds.stream()
.map(scopeId -> service
? issueServiceToken(current.getSubjectName(), current.getMemo(), scopeId,
current.getExpireTime(), operatorId)
: issueSingle(current.getUserId(), current.getMemo(), scopeId,
current.getExpireTime(), operatorId))
.toList();
}
/** 查找某人员在某作用域下的有效 Token(正常状态且未过期),取最新一把;无则 null。 */
public AgentApiKey findActiveToken(Long userId, Long scopeId) {
List<Long> tokenIds = relationMapper.selectList(new LambdaQueryWrapper<TokenScope>()
@@ -90,6 +133,25 @@ public class AdminTokenService {
.stream().findFirst().orElse(null);
}
/** 查找某服务主体在某作用域下的有效 Token(正常状态且未过期),取最新一把;无则 null。 */
public AgentApiKey findActiveServiceToken(String serviceName, Long scopeId) {
List<Long> tokenIds = relationMapper.selectList(new LambdaQueryWrapper<TokenScope>()
.eq(TokenScope::getScopeId, scopeId).eq(TokenScope::getSoftDelete, 0))
.stream().map(TokenScope::getTokenId).toList();
if (tokenIds.isEmpty()) return null;
return tokenMapper.selectList(new LambdaQueryWrapper<AgentApiKey>()
.in(AgentApiKey::getId, tokenIds)
.eq(AgentApiKey::getPrincipalType, AgentApiKey.PRINCIPAL_TYPE_SERVICE)
.eq(AgentApiKey::getSubjectName, serviceName)
.eq(AgentApiKey::getStatus, 1)
.eq(AgentApiKey::getSoftDelete, 0)
.and(w -> w.isNull(AgentApiKey::getExpireTime)
.or().gt(AgentApiKey::getExpireTime, new Date()))
.orderByDesc(AgentApiKey::getCreateTime)
.last("LIMIT 1"))
.stream().findFirst().orElse(null);
}
@Transactional(transactionManager = "svcTransactionManager")
public void update(Long tokenId, String memo, List<Long> scopeIds, Date expireTime, Long adminId) {
AgentApiKey current = activeToken(tokenId);
@@ -133,6 +195,7 @@ public class AdminTokenService {
new LambdaQueryWrapper<AgentScope>().in(AgentScope::getId, scopeIds))
.stream().map(AgentScope::getName).toList();
return new TokenView(token.getId(), token.getKeyPrefix(), token.getMemo(), token.getStatus(),
token.getPrincipalType(), token.getSubjectName(),
token.getExpireTime(), token.getLastUsedTime(), scopeIds, scopeNames);
}
@@ -187,7 +250,8 @@ public class AdminTokenService {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
}
public record TokenView(Long id, String keyPrefix, String memo, Byte status, Date expireTime,
public record TokenView(Long id, String keyPrefix, String memo, Byte status,
String principalType, String subjectName, Date expireTime,
Date lastUsedTime, List<Long> scopeIds, List<String> scopeNames) {}
public record IssuedToken(String plaintext, TokenView token) {}
}
@@ -11,7 +11,8 @@ import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
/**
* 工具调用审计(设计文档 §3.2 第 5 步)。不记录 ak。
* 工具调用审计(设计文档 §3.2 第 5 步)。不记录 ak;
* 入参摘要与结果信息中的手机号一律脱敏(REQ-20260911-0011)。
*/
@Component
public class AuditService {
@@ -33,9 +34,9 @@ public class AuditService {
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.setParamsDigest(truncate(MaskingUtil.maskPhones(paramsJson), PARAMS_DIGEST_MAX));
auditLog.setResultStatus(resultStatus);
auditLog.setResultMessage(truncate(resultMessage, PARAMS_DIGEST_MAX));
auditLog.setResultMessage(truncate(MaskingUtil.maskPhones(resultMessage), PARAMS_DIGEST_MAX));
auditLog.setCostMs(costMs);
auditLog.setClientIp(clientIp());
auditLog.setClientType(clientType());
@@ -0,0 +1,32 @@
package me.iiv.agent.common;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 日志/审计脱敏(REQ-20260911-0011):手机号明文脱敏为 138****5678 形式。
* Token/ak 不落日志由调用方保证(现有日志点均不输出 ak),本工具只负责手机号。
*/
public final class MaskingUtil {
/** 中国大陆手机号:1 开头 11 位,第二位 3-9 */
private static final Pattern PHONE = Pattern.compile("1[3-9]\\d{9}");
private MaskingUtil() {
}
public static String maskPhones(String text) {
if (text == null || text.isEmpty()) {
return text;
}
Matcher matcher = PHONE.matcher(text);
StringBuilder sb = new StringBuilder(text.length());
while (matcher.find()) {
String phone = matcher.group();
matcher.appendReplacement(sb,
Matcher.quoteReplacement(phone.substring(0, 3) + "****" + phone.substring(7)));
}
matcher.appendTail(sb);
return sb.toString();
}
}
@@ -31,12 +31,15 @@ public class M0DataSourceConfig {
@Primary
@Bean(name = "m0SqlSessionFactory")
public SqlSessionFactory m0SqlSessionFactory(@Qualifier("m0DataSource") DataSource dataSource) throws Exception {
public SqlSessionFactory m0SqlSessionFactory(@Qualifier("m0DataSource") DataSource dataSource,
SyncQueryTimeoutInterceptor syncQueryTimeoutInterceptor)
throws Exception {
MybatisSqlSessionFactoryBean bean = new MybatisSqlSessionFactoryBean();
bean.setDataSource(dataSource);
MybatisConfiguration configuration = new MybatisConfiguration();
configuration.setMapUnderscoreToCamelCase(true);
bean.setConfiguration(configuration);
bean.setPlugins(syncQueryTimeoutInterceptor);
return bean.getObject();
}
}
@@ -27,12 +27,15 @@ public class M1DataSourceConfig {
}
@Bean(name = "m1SqlSessionFactory")
public SqlSessionFactory m1SqlSessionFactory(@Qualifier("m1DataSource") DataSource dataSource) throws Exception {
public SqlSessionFactory m1SqlSessionFactory(@Qualifier("m1DataSource") DataSource dataSource,
SyncQueryTimeoutInterceptor syncQueryTimeoutInterceptor)
throws Exception {
MybatisSqlSessionFactoryBean bean = new MybatisSqlSessionFactoryBean();
bean.setDataSource(dataSource);
MybatisConfiguration configuration = new MybatisConfiguration();
configuration.setMapUnderscoreToCamelCase(true);
bean.setConfiguration(configuration);
bean.setPlugins(syncQueryTimeoutInterceptor);
return bean.getObject();
}
}
@@ -27,12 +27,15 @@ public class M2DataSourceConfig {
}
@Bean(name = "m2SqlSessionFactory")
public SqlSessionFactory m2SqlSessionFactory(@Qualifier("m2DataSource") DataSource dataSource) throws Exception {
public SqlSessionFactory m2SqlSessionFactory(@Qualifier("m2DataSource") DataSource dataSource,
SyncQueryTimeoutInterceptor syncQueryTimeoutInterceptor)
throws Exception {
MybatisSqlSessionFactoryBean bean = new MybatisSqlSessionFactoryBean();
bean.setDataSource(dataSource);
MybatisConfiguration configuration = new MybatisConfiguration();
configuration.setMapUnderscoreToCamelCase(true);
bean.setConfiguration(configuration);
bean.setPlugins(syncQueryTimeoutInterceptor);
return bean.getObject();
}
}
@@ -2,6 +2,7 @@ package me.iiv.agent.config;
import me.iiv.agent.common.AuditService;
import me.iiv.agent.mcp.GuardedToolCallbackProvider;
import me.iiv.agent.mcp.RateLimitService;
import me.iiv.agent.mcp.ToolObject;
import me.iiv.agent.security.ToolGuard;
import org.springframework.ai.tool.ToolCallbackProvider;
@@ -20,10 +21,11 @@ import java.util.List;
public class McpToolConfig {
@Bean
public ToolCallbackProvider xiaoquTools(List<ToolObject> toolObjects, ToolGuard toolGuard, AuditService auditService) {
public ToolCallbackProvider xiaoquTools(List<ToolObject> toolObjects, ToolGuard toolGuard,
AuditService auditService, RateLimitService rateLimitService) {
ToolCallbackProvider methodProvider = MethodToolCallbackProvider.builder()
.toolObjects(toolObjects.toArray())
.build();
return new GuardedToolCallbackProvider(methodProvider, toolGuard, auditService);
return new GuardedToolCallbackProvider(methodProvider, toolGuard, auditService, rateLimitService);
}
}
@@ -0,0 +1,33 @@
package me.iiv.agent.config;
import me.iiv.agent.mcp.sync.SyncQueryTimeoutHolder;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Signature;
import org.springframework.stereotype.Component;
import java.sql.Connection;
import java.sql.Statement;
/**
* 慢查询超时(REQ-20260911-0013 资源隔离):SyncQueryTimeoutHolder 非空时,
* 给本线程 prepare 出的 Statement 套 queryTimeout(秒),
* 防止 SYNC 工具的后台全量扫描拖慢销售在线点查。未设置时完全不影响现有查询。
*/
@Component
@Intercepts(@Signature(type = StatementHandler.class, method = "prepare",
args = {Connection.class, Integer.class}))
public class SyncQueryTimeoutInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object statement = invocation.proceed();
Integer timeout = SyncQueryTimeoutHolder.current();
if (timeout != null && timeout > 0 && statement instanceof Statement stmt) {
stmt.setQueryTimeout(timeout);
}
return statement;
}
}
@@ -15,7 +15,7 @@ import java.util.Date;
* </p>
*
* @author codegen
* @since 2026-09-03
* @since 2026-09-08
*/
@Getter
@Setter
@@ -62,21 +62,11 @@ public class Agent implements Serializable {
*/
private String name;
/**
* 渠道编号(Z:总部;F:分公司;D:代理)
*/
private String channelNo;
/**
* 上级id
*/
private Long supervisorId;
/**
* 上级代理
*/
private String supervisorName;
/**
* 企业地址
*/
@@ -137,6 +127,11 @@ public class Agent implements Serializable {
*/
private Integer level;
/**
* 渠道编号(Z:总部;F:分公司;D:代理)
*/
private String channelNo;
/**
* 户名
*/
@@ -192,6 +187,36 @@ public class Agent implements Serializable {
*/
private String shortName;
/**
* 金蝶供应商编号
*/
private String supplierNo;
/**
* 开票通讯地址
*/
private String invoiceAddress;
/**
* 开票联系电话
*/
private String invoiceContact;
/**
* 组织内码
*/
private String saleOrgInternalId;
/**
* 客户分组
*/
private String custerGroupNo;
/**
* 金蝶内码
*/
private Long jindieInternalId;
/**
* 金蝶客户编号
*/
@@ -212,41 +237,16 @@ public class Agent implements Serializable {
*/
private String saleOrgId;
/**
* 金蝶内码
*/
private Long jindieInternalId;
/**
* 组织内码
*/
private String saleOrgInternalId;
/**
* 客户分组
*/
private String custerGroupNo;
/**
* 金蝶供应商编号
*/
private String supplierNo;
/**
* 开票通讯地址
*/
private String invoiceAddress;
/**
* 开票联系电话
*/
private String invoiceContact;
/**
* 金蝶客户名称
*/
private String jinideName;
/**
* 上级代理
*/
private String supervisorName;
/**
* 最后开票时间
*/
@@ -16,7 +16,7 @@ import java.util.Date;
* </p>
*
* @author codegen
* @since 2026-09-03
* @since 2026-09-08
*/
@Getter
@Setter
@@ -15,7 +15,7 @@ import java.util.Date;
* </p>
*
* @author codegen
* @since 2026-09-03
* @since 2026-09-08
*/
@Getter
@Setter
@@ -0,0 +1,94 @@
package me.iiv.agent.entity.m0;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
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.time.LocalDate;
import java.util.Date;
@Getter
@Setter
@ToString
@TableName("district_district_strategy")
public class DistrictDistrictStrategy implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Long createId;
private Date createTime;
private Date modifyTime;
private Integer softDelete;
private String districtName;
private String address;
private Long propertyId;
private Long agentId;
private String province;
private String city;
private String countyseat;
private String memo;
private String contact;
private String mail;
@TableField("buildCount")
private Integer buildCount;
private String districtNumber;
private String phone;
private Integer installType;
private Integer warning;
private Integer airdetectorFlag;
private Integer warning2;
private Integer warning3;
private Integer warningTime;
private Integer onlineState;
private Integer paperPercentWarn;
private Integer pointState;
private Integer assignmentWorkState;
private Integer airState;
private String businessFormat;
private String posDevicePaperNorm;
private String toiletDevicePaperNorm;
private Integer messageFlag;
private Integer reportFlag;
private Double fineMoney;
private Integer lockMachineFlag;
private Integer eventRepairFlag;
private Integer rfidUseFlag;
private Integer calibratedFlag;
private String salesName;
private Long salesId;
private Integer waitTimes;
private Integer noPaperState;
private Integer lowerPaperState;
private Integer districtState;
private Integer deviceSalesType;
private Integer paperSalesType;
private LocalDate weaningDate;
private LocalDate honourDate;
private LocalDate testDate;
private Integer powerWarning;
private Integer pointDeduction;
private Integer testDays;
private Integer illegalPaperDisable;
private Integer effectiveUseTime;
private Long attributeId;
private String attributeName;
private Integer hardwareCount;
private Double consumablesPrice;
private Integer billDate;
private Integer foamCountWarning;
private Integer foamPowerWarning;
private Long serviceCompanyId;
private String serviceCompany;
private String billType;
private Integer consumablesType;
private Integer maintainOrderWarning;
private Integer lnfluenceLevel;
private Integer isSmart;
}
@@ -0,0 +1,295 @@
package me.iiv.agent.entity.m0;
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;
/**
* hardware_view 视图(m0 库),字段 1:1 照搬老项目
* xiaoqu-publicbeanandmap beans/b0/HardwareView.java。
*/
@Getter
@Setter
@ToString
@TableName("hardware_view")
public class HardwareView implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/** 创建id */
private Long createId;
/** 创建时间 */
private Date createTime;
/** 修改id */
private Long modifyId;
/** 修改时间 */
private Date modifyTime;
/** 数据状态 */
private Integer softDelete;
/** 设备编号 */
private String no;
/** 位置信息 */
private String address;
/** 位置id */
private Long positionId;
/** 位置信息 */
private String position;
/** sim卡号 */
private String simNo;
/** sn码 */
private String snCode;
/** 状态1:在线;2:故障;3:离线 */
private Integer status;
/** 设备随机码 */
private String checkCode;
/** 固件版本 */
private String firmwareVersion;
/** 厕所id */
private Long toiletId;
/** 楼id */
private Long buildingId;
/** 楼层 */
private String floor;
/** 区域id */
private Long districtId;
/** 物业id */
private Long propertyId;
/** 代理id */
private Long agentId;
/** 设备型号 */
private String version;
/** 备注 */
private String memo;
/** 保洁id */
private Long userId;
/** 电力支持 */
private Integer powerSupply;
/** 感应抽纸长度 */
private Integer senseLength;
/** 扫码抽纸长度 */
private Integer scanLength;
/** 出纸模式 */
private Integer paperType;
/** 纸规格 */
private String paperNorms;
/** 感应出纸次数 */
private Integer senseCount;
/** 扫码出纸次数 */
private Integer scanCount;
/** 剩余次数 */
private Integer paperSurplus;
/** 出纸计数 */
private Long paperCount;
/** sn码 */
private String temSnCode;
/** rfid */
private String rfid;
/** 硬件名称 */
private String name;
/** 安装场景0:厕位内:1:厕位外:2其它 */
private Integer installScenes;
/** 安装方式0:免打孔 1:打孔 */
private Integer installMode;
/** 供电方式 0:电源 1:电池 */
private Integer poweredType;
/** 设备上下线状态 */
private Integer online;
/** 蓝牙名称 */
private String bluetoothName;
/** imsi码 */
private String imsi;
/** 蓝牙地址 */
private String bluetoothMac;
/** 上线时间 */
private Date onlineTime;
/** 芯片号 */
private String chip;
/** 主板号 */
private String motherboard;
private String sysversion;
/** 感应出纸次数 */
private Integer paperState;
/** 扫码出纸次数 */
private Integer powerState;
private Integer positionState;
/** 剩余次数 */
private Integer useState;
/** 质保期 */
private Integer warrantyPeriod;
/** 运行模式 */
private Integer runlMode;
/** 锁状态 */
private Integer lockState;
/** 设备类型 */
private Integer deviceType;
/** 清理用纸卷计数 */
private Integer cleanRfidCount;
/** 工程模式0:关;1:开 */
private Integer projectModel;
/** 出纸模式0:关闭;1:开启 */
private Integer paperOutState;
/** 机芯更换时间 */
private Date machineChipTime;
/** 机芯编号 */
private String machineChipCode;
/** 当前机芯编号 */
private String currentChip;
/** 锁定机器状态0:未锁定;1:已锁定 */
private Integer lockMachineState;
/** 语音版本 */
private String voiceVersion;
/** 校准标识0:否;1:是 */
private Integer calibratFlag;
/** 满卷标识0:否;1:是 */
private Integer fullRollFlag;
/** 中校准锁定开关 */
private Integer middleFlag;
/** 低校准锁定开关 */
private Integer lowerFlag;
/** 纸校准锁定开关 */
private Integer paperLockFlag;
/** 低故障开关(0:故障;1:正常) */
private Integer lowerFaultFlag;
/** 中故障开关(0:故障;1:正常) */
private Integer middleFaultFlag;
/** 当前锁定状态出纸次数 */
private Integer paperLockCount;
/** 保修截止日期 */
private Date warrantyExpirationTime;
/** 首次上线时间 */
private Date firstOnTime;
/** 广告机版本 */
private String advertiseVersion;
/** 非法纸禁出状态(0:禁出;1:未禁出) */
private Integer illegalPaperDisableState;
/** 是否绑定NFC 0:否;1:是 */
private Integer nfcFlag;
/** nfcId */
private String nfcId;
/** 定时语音播放0:关;1:开 */
private Integer timerVoiceFlag;
/** 设定时长(分) */
private Integer timerVoiceTiming;
/** 定时出纸0:关;1:开 */
private Integer timerPaperoutFlag;
/** 设定时长(分) */
private Integer timerPaperoutTiming;
/** 定时出纸长度 */
private Integer timerPaperoutLength;
/** 电池电量异常标记0:否;1:是 */
private Integer batteryFaultFlag;
/** 电池温度 */
private Integer batteryTemp;
/** 网络信号 */
private Integer networkSignal;
/** 充电循环次数 */
private Integer batteryCircle;
/** 生产备注 */
private String prdMemo;
/** 备注 */
private String runMemo;
/** 电流 */
private Integer current;
/** 电压 */
private Integer voltage;
/** 计租标记(0否1是) */
private Integer rentFlag;
}
@@ -0,0 +1,141 @@
package me.iiv.agent.entity.m0;
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;
/**
* <p>
* 发票信息表
* </p>
*
* @author codegen
* @since 2026-09-08
*/
@Getter
@Setter
@ToString
@TableName("invoice_info")
public class InvoiceInfo implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 创建id
*/
private Long createId;
/**
* 创建时间
*/
private Date createTime;
/**
* 创建人
*/
private String createName;
/**
* 修改id
*/
private Long modifyId;
/**
* 修改人
*/
private String modifyName;
/**
* 修改时间
*/
private Date modifyTime;
/**
* 数据状态
*/
private Integer softDelete;
/**
* 类型(0:代理;1:物业)
*/
private Integer type;
/**
* 客户ID
*/
private Long customerId;
/**
* 客户名称
*/
private String customerName;
/**
* 开票名称
*/
private String invoiceName;
/**
* 统一社会信用代码
*/
private String identificationNumber;
/**
* 联系人
*/
private String contact;
/**
* 联系人邮箱
*/
private String mail;
/**
* 银行账户户名称
*/
private String accountName;
/**
* 银行账号
*/
private String accountNo;
/**
* 开户行名称
*/
private String accountBank;
/**
* 开票通讯地址
*/
private String invoiceAddress;
/**
* 开票联系电话
*/
private String invoiceContact;
/**
* 备注信息
*/
private String memo;
/**
* 默认标识(0:非默认;1:默认)
*/
private Integer defaultFlag;
/**
* 发票抬头类型(0:企业单位;1:个人|非企业)
*/
private Integer invoiceType;
}
@@ -0,0 +1,118 @@
package me.iiv.agent.entity.m0;
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.math.BigDecimal;
import java.util.Date;
/**
* networkcard 联网卡列表(m0 库),字段照搬老项目
* xiaoqu-publicbeanandmap beans/b0/Networkcard.java。
*/
@Getter
@Setter
@ToString
@TableName("networkcard")
public class Networkcard implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/** 创建id */
private Long createId;
/** 创建时间 */
private Date createTime;
/** 修改id */
private Long modifyId;
/** 修改时间 */
private Date modifyTime;
/** 数据状态 */
private Integer softDelete;
/** sim卡号 */
private String simNo;
/** 设备编号 */
private String sncode;
/** 绑定状态 */
private Integer state;
/** msisdn */
private String msisdn;
/** iccid */
private String iccid;
/** imsi码 */
private String imsi;
/** 本月流量 */
private BigDecimal dataUsage;
/** 运营商 */
private String carrier;
/** 套餐大小 */
private Integer dataPlan;
/** 卡状态 */
private Integer accountStatus;
/** 计费结束日期 */
private Integer expiryDate;
/** 激活状态 0,1 */
private Integer active;
/** 激活日期 */
private Integer activeDate;
/** 剩余流量,单位M */
private BigDecimal dataBalance;
/** 出库日期 */
private Integer outboundDate;
/** 区域/站id */
private Integer districtId;
/** 所属物业 */
private Long propertyId;
/** 代理商id */
private Long agentId;
/** 楼id */
private Long buildingId;
/** 位置id */
private Long positionId;
/** 厕所id */
private Long toiletId;
/** 厂商类型0:厂商1;1:厂商2.... */
private Integer factoryType;
/** 卡状态 */
private String status;
/** 所属机构名称 */
private String orgName;
/** 卡当前套餐名称 */
private String iratePlanName;
}
@@ -1,6 +1,7 @@
package me.iiv.agent.entity.m0;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Getter;
import lombok.Setter;
@@ -15,7 +16,7 @@ import java.util.Date;
* </p>
*
* @author codegen
* @since 2026-09-03
* @since 2026-09-08
*/
@Getter
@Setter
@@ -28,15 +29,10 @@ public class Properyt implements Serializable {
private Long id;
/**
* 销售id
* 创建id
*/
private Long createId;
/**
* 销售名称
*/
private String createName;
/**
* 创建时间
*/
@@ -62,11 +58,6 @@ public class Properyt implements Serializable {
*/
private Integer type;
/**
* 金蝶客户名称
*/
private String jinideName;
/**
* 企业名称
*/
@@ -77,11 +68,6 @@ public class Properyt implements Serializable {
*/
private Long agentId;
/**
* 代理名称
*/
private String agentName;
/**
* 企业地址
*/
@@ -134,7 +120,10 @@ public class Properyt implements Serializable {
/**
* 客户id自动生成
* 注意:库中列名为驼峰 customerId(dev/prod 一致),需显式指定,
* 否则 MyBatis-Plus 默认驼峰转下划线会映射成不存在的 customer_id。
*/
@TableField("customerId")
private String customerId;
/**
@@ -178,14 +167,14 @@ public class Properyt implements Serializable {
private String invoiceName;
/**
* 金蝶客户编号
* 身份编码
*/
private String identifier;
/**
* 金蝶内码
* 金蝶客户名称
*/
private Long jindieInternalId;
private String jinideName;
/**
* 开票通讯地址
@@ -197,6 +186,21 @@ public class Properyt implements Serializable {
*/
private String invoiceContact;
/**
* 金蝶内码
*/
private Long jindieInternalId;
/**
* 销售名称
*/
private String createName;
/**
* 代理名称
*/
private String agentName;
/**
* 最后开票时间
*/
@@ -15,7 +15,7 @@ import java.util.Date;
* </p>
*
* @author codegen
* @since 2026-09-03
* @since 2026-09-08
*/
@Getter
@Setter
@@ -43,7 +43,7 @@ public class User implements Serializable {
private Date modifyTime;
/**
* 数据状态(0:启用:1:删除)
* 数据状态
*/
private Integer softDelete;
@@ -72,6 +72,11 @@ public class User implements Serializable {
*/
private String password;
/**
* 代理ID
*/
private Long agentId;
/**
* 所属公司名称
*/
@@ -103,7 +108,7 @@ public class User implements Serializable {
private Integer approle;
/**
* 系统权限0:管理权限;1:普通权限
* 角色类别(管理员,主管,其他)
*/
private Integer roleType;
@@ -112,11 +117,6 @@ public class User implements Serializable {
*/
private Long districtId;
/**
* 代理ID
*/
private Long agentId;
/**
* 物业ID
*/
@@ -128,7 +128,7 @@ public class User implements Serializable {
private Long supervisorId;
/**
* 考勤状态0:上班;1:下班;2:请假;3休息
* 工作状态
*/
private Integer workStatus;
@@ -142,8 +142,6 @@ public class User implements Serializable {
*/
private String email;
private String emailMask;
/**
* 身份证号
*/
@@ -175,7 +173,7 @@ public class User implements Serializable {
private Integer backHardFlag;
/**
* 权限id
* 权限组id
*/
private Long authorityId;
@@ -227,7 +225,7 @@ public class User implements Serializable {
private String modifyName;
/**
* 物业权限id
* 物业平台权限组id
*/
private Long propertyAuthorityId;
@@ -237,48 +235,24 @@ public class User implements Serializable {
private String dingUserId;
/**
* 工作中状态0:作业;1:转场;2:空闲;3:休息
* 工作状态
*/
private Integer workAtStatus;
/**
* 年龄
*/
private Integer age;
/**
* 试用期保护0:未启用;1:启用
*/
private Integer probationProtection;
/**
* 备注
*/
private String note;
/**
* 上班时间
*/
private String onTime;
/**
* 下班时间
*/
private String offTime;
/**
* 出纸机权限
*/
private Integer czjAuth;
/**
* 空感权限
*/
private Integer kgAuth;
/**
* 信标权限
*/
private Integer xbAuth;
/**
@@ -311,16 +285,6 @@ public class User implements Serializable {
*/
private Integer productOpAuth;
/**
* 金蝶人员销售订单编号
*/
private String jindieSalerid;
/**
* 泡沫机APP显示0:关闭;1:打开;
*/
private Integer foamFunctionAuth;
/**
* 金蝶内码
*/
@@ -351,6 +315,16 @@ public class User implements Serializable {
*/
private Integer flowAuth;
/**
* 泡沫机APP显示0:关闭;1:打开;
*/
private Integer foamFunctionAuth;
/**
* 金蝶人员销售订单编号
*/
private String jindieSalerid;
/**
* xiaoqu-ai 用户ID
*/
@@ -360,44 +334,4 @@ public class User implements Serializable {
* 同步到 xiaoqu-ai 的时间
*/
private Date xiaoquAiSyncedAt;
/**
* 0待回填/1已解析/2歧义/3悬空;property_id非空→初始UPDATE=1
*/
private Byte tenantResolveStatus;
/**
* 0正常/1身份待核(冲突fail-closed锁B3人证比对;⊥tenant_resolve_status,不进PEP单路)
*/
private Byte idcardResolveStatus;
/**
* AES-256-GCM(IV‖ct‖tag);canonical后加密;明文绝不落盘
*/
private byte[] idcardEnc;
/**
* keyed HMAC-SHA256(canonical idcard);等值/dedup/唯一
*/
private byte[] idcardBidx;
/**
* 存储预算掩码 前3后4(默认零解密展示)
*/
private String idcardMask;
/**
* AES-256-GCM
*/
private byte[] mobileEnc;
/**
* keyed HMAC-SHA256(canonical mobile);等值查找(非唯一)
*/
private byte[] mobileBidx;
/**
* 展示掩码 前3后4
*/
private String mobileMask;
}
@@ -0,0 +1,248 @@
package me.iiv.agent.entity.m1;
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.math.BigDecimal;
import java.util.Date;
/**
* <p>
* 订单(m1 商城库)
* </p>
*
* @author codegen
* @since 2026-09-09
*/
@Getter
@Setter
@ToString
@TableName("mall_goods_order")
public class MallGoodsOrder implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/** 销售id */
private Long createId;
/** 创建时间 */
private Date createTime;
/** 修改id */
private Long modifyId;
/** 修改时间 */
private Date modifyTime;
/** 数据状态(0:启用,99:停用) */
private Integer softDelete;
/** 代理ID */
private Long agentId;
/** 物业ID */
private Long propertyId;
/** 站点ID */
private Long districtId;
/** 客户ID */
private Long userId;
/** 订单编号 */
private String orderNo;
/** 订单类型(0:代理;1:物业) */
private Integer orderType;
/** 结账类型(0:预付;1:月结) */
private Integer accountsType;
/** 支付方式 */
private Integer paymentType;
/** 收货地址 */
private Long addressId;
/** 订单状态(0:待付款;1:待出库;2:待发货;3:已发货;4:已签收;5:已收货;6:已取消;7:已删除) */
private Integer orderState;
/** 支付金额 */
private BigDecimal payMoney;
/** 已支付金额 */
private BigDecimal paidMoney;
/** 未支付金额 */
private BigDecimal unPayMoney;
/** 回款状态(0:待付款;1:已付款;2:付款中) */
private Integer payState;
/** 付款付款确认状态(0:未确认;1:已确认;2:延迟确认) */
private Integer orderPayState;
/** 退款状态(0:待退款;1:退款成功;2:退款失败;99:未知) */
private Integer rePayState;
/** 发票状态(0:未开票;1:已开票;2:部分开票) */
private Integer orderInvoiceState;
/** 订单有效时长(小时) */
private Integer effectiveTime;
/** 退款原因 */
private String reMemo;
/** 邮费 */
private BigDecimal postageMoney;
/** 订单备注 */
private String memo;
/** 应支付总金额 */
private BigDecimal totalMoney;
/** 商品金额 */
private BigDecimal goodsMoney;
/** 支付编号 */
private String payNo;
/** 下单时间 */
private Date orderTime;
/** 实际回款时间 */
private Date payTime;
/** 收货时间 */
private Date receiptTime;
/** 签收时间 */
private Date signTime;
/** 商品类型(0:非月结商品;1:月结商品) */
private Integer goodsType;
/** 账单编号(月结商品有) */
private String billNo;
/** 付款识别号(线下付款) */
private String payIdentifier;
/** 如果自行配送则有值 */
private String mobile;
/** 特许状态(0:未特许;1:已特许) */
private Integer franchiseState;
/** 优惠金额 */
private BigDecimal discountMoney;
/** 活动名称 */
private String activityName;
/** 活动编号 */
private String activityNo;
/** 促销编号 */
private String schemeNo;
/** 出库状态(0:未出库;1:已出库;2:部分出库) */
private Integer warehouseState;
/** 预订单编号 */
private String beforehandOrderNo;
/** 锁定状态(0:未锁定;1:已锁定) */
private Integer lockState;
/** 预订单下单人 */
private String placingPeople;
/** 预订单下单人 */
private Long placingPeopleId;
/** 代理 */
private String agentName;
/** 物业 */
private String propertyName;
/** 站点 */
private String districtName;
/** 下单人 */
private String userName;
/** 销售名称 */
private String createName;
/** 原始应支付金额 */
private BigDecimal originalPayMoney;
/** 逾期标识0:否;1:是 */
private Integer overdueFlag;
/** 逾期天数 */
private Integer overdueDays;
/** 回款周期(天) */
private Integer paymentCollection;
/** 最晚应回款时间 */
private Date lastPayTime;
/** 出库日期 */
private Date deliveryDate;
/** 金蝶系统内码 */
private Long jindieInternalId;
/** 金蝶系统采购订单内码 */
private Long jindiePurchaseInternalId;
/** 销售公司 */
private Long saleCompanyId;
/** 销售公司 */
private String saleCompany;
/** 发货公司 */
private Long deliveryCompanyId;
/** 发货公司 */
private String deliveryCompany;
/** 已开票金额 */
private BigDecimal invoiceMoney;
/** 未开票金额 */
private BigDecimal unInvoiceMoney;
/** 金蝶销售服务订单内码 */
private Long jindieServiceOrderInternalId;
/** 服务订单状态(0:未处理;1:已处理;2:无需处理) */
private Integer serviceOrderState;
/** 微店订单标识(0:否;1:是) */
private Integer weiDianOrderFlag;
/** 微店订单编号 */
private String weiDianOrderNo;
/** 装机单标识(0:否;1:是) */
private Integer installOrderFlag;
/** 营销状态(0:未营销;1:部分营销;2:已营销) */
private Integer marketingState;
}
@@ -0,0 +1,118 @@
package me.iiv.agent.entity.m1;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
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.math.BigDecimal;
import java.util.Date;
/**
* <p>
* 订单商品详情(m1 商城库)
* </p>
*
* @author codegen
* @since 2026-09-09
*/
@Getter
@Setter
@ToString
@TableName("mall_order_details")
public class MallOrderDetails implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/** 创建id */
private Long createId;
/** 创建时间 */
private Date createTime;
/** 修改id */
private Long modifyId;
/** 修改时间 */
private Date modifyTime;
/** 数据状态(0:启用,99:停用) */
private Integer softDelete;
/** spu编号 */
private String spuNo;
/** sku编号 */
private String skuNo;
/** 购买数量 */
private Integer count;
/** 单价 */
private BigDecimal price;
/** 折扣 */
private Double discount;
/** 订单编号 */
private String orderNo;
/** 商品类型(0:非月结商品;1:月结商品) */
private Integer goodsType;
/** sku标签 */
private String skuLable;
/** 商品描述 */
private String skuDesc;
/** 封面路径 */
private String picUrl;
/** spu名称 */
private String spuName;
/** 物料类型(0:设备;1:配件;2:耗材);历史遗留驼峰列名 */
@TableField("spuType")
private Integer spuType;
/** 出库状态(0:未出库;1:已出库) */
private Integer warehouseState;
/** 出库单ID */
private Long warehouseOutId;
/** 原始单价 */
private BigDecimal originalPrice;
/** 原始购买数量 */
private Integer originalCount;
/** 收款状态(0:未收款;1:全部收款;2:部分收款) */
private Integer payState;
/** 发票状态(0:未开票;1:全部开票;2:部分开票) */
private Integer orderInvoiceState;
/** 应支付金额 */
private BigDecimal payMoney;
/** 应开票金额 */
private BigDecimal invoiceMoney;
/** 未支付金额 */
private BigDecimal notPayMoney;
/** 未开票金额 */
private BigDecimal notInvoiceMoney;
/** 营销状态(0:未营销;1:营销中;2:已营销) */
private Integer marketingState;
}
@@ -0,0 +1,93 @@
package me.iiv.agent.entity.m1;
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;
/**
* <p>
* 订单物流(m1 商城库)
* </p>
*
* @author codegen
* @since 2026-09-09
*/
@Getter
@Setter
@ToString
@TableName("mall_order_logistics")
public class MallOrderLogistics implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/** 订单发货人 */
private Long createId;
/** 创建时间 */
private Date createTime;
/** 修改id */
private Long modifyId;
/** 发货时间 */
private Date modifyTime;
/** 数据状态(0:启用,99:停用) */
private Integer softDelete;
/** 发货方式(0:快递公司;1:自行派送) */
private Integer sendMode;
/** 订单编号 */
private String orderNo;
/** 物流公司id */
private Long logisticsId;
/** 物流公司编号 */
private String logisticsNo;
/** 物流公司名称 */
private String logisticsName;
/** 快递单号 */
private String logisticsOrderNo;
/** 自己生成的编号 */
private String logisticsSelfNo;
/** 派送员姓名 */
private String deliverName;
/** 派送手机号 */
private String deliverNumber;
/** 配送员id */
private Long userId;
/** 物流当前状态(0:已出单;1:已出库;2:已发货;3:已揽件;4:运输中;5:待取件;6:派送中;7:已签收) */
private Integer state;
/** 收货方公司名称 */
private String receivingGoodsName;
/** 收货姓名 */
private String reveiveName;
/** 收货手机号 */
private String reveiveNumber;
/** 详细地址 */
private String address;
private Long agentId;
}
@@ -0,0 +1,62 @@
package me.iiv.agent.entity.m1;
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;
/**
* <p>
* 订单物流轨迹(m1 商城库;表名为老系统遗留拼写 mall_order_logistics_detai)。
* 注意:logistics_order_no 字段在老系统写入时混用,实际存的是订单号。
* </p>
*
* @author codegen
* @since 2026-09-09
*/
@Getter
@Setter
@ToString
@TableName("mall_order_logistics_detai")
public class MallOrderLogisticsDetai implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/** 创建id */
private Long createId;
/** 创建时间 */
private Date createTime;
/** 修改id */
private Long modifyId;
/** 修改时间 */
private Date modifyTime;
/** 数据状态(0:启用,99:停用) */
private Integer softDelete;
/** 快递单号(老系统写入时混用,实际多为订单号) */
private String logisticsOrderNo;
/** 物流详情 */
private String memo;
/** 物流状态(0:已出单;1:已出库;2:已发货;3:已揽件;4:运输中;5:待取件;6:派送中;7:已签收;13:上传凭证) */
private Integer state;
/** 订单号 */
private String orderNo;
/** 图片列表 */
private String picList;
}
@@ -0,0 +1,94 @@
package me.iiv.agent.entity.m1;
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;
/**
* <p>
* 收货地址(m1 商城库)
* </p>
*
* @author codegen
* @since 2026-09-09
*/
@Getter
@Setter
@ToString
@TableName("mall_shipping_address")
public class MallShippingAddress implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/** 创建id */
private Long createId;
/** 创建时间 */
private Date createTime;
/** 修改id */
private Long modifyId;
/** 修改时间 */
private Date modifyTime;
/** 数据状态(0:启用,99:停用) */
private Integer softDelete;
/** 收货姓名 */
private String reveiveName;
/** 收货手机号 */
private String reveiveNumber;
/** 客户id */
private Long userId;
/** 下单人 */
private String userName;
/** 所属站点 */
private Long districtId;
/** 省 */
private String province;
/** 市 */
private String city;
/** 县 */
private String countyseat;
/** 详细地址 */
private String address;
/** 备注信息 */
private String memo;
/** 状态 */
private Integer status;
/** 代理ID */
private Long agentId;
/** 默认标识0:否;1:是 */
private Integer defaultFlag;
/** 使用客户类型(0:内部人员;1:渠道商;2:直营客户) */
private Integer useCustomerType;
/** 使用客户ID */
private Long useCustomerId;
/** 使用客户名称 */
private String useCustomerName;
}
@@ -0,0 +1,71 @@
package me.iiv.agent.entity.m2;
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;
/**
* <p>
* 合同附件
* </p>
*
* @author codegen
* @since 2026-09-11
*/
@Getter
@Setter
@ToString
@TableName("contract_file")
public class ContractFile implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 创建id
*/
private Long createId;
/**
* 创建时间
*/
private Date createTime;
/**
* 修改id
*/
private Long modifyId;
/**
* 修改时间
*/
private Date modifyTime;
/**
* 数据状态0:正常;1:删除
*/
private Integer softDelete;
/**
* 合同ID(0=上传后未提交的孤儿附件,定时清理)
*/
private Long contractId;
/**
* 原始文件名
*/
private String fileName;
/**
* 文件访问地址
*/
private String fileUrl;
}
@@ -0,0 +1,162 @@
package me.iiv.agent.entity.m2;
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.math.BigDecimal;
import java.util.Date;
/**
* <p>
* 合同主表
* </p>
*
* @author codegen
* @since 2026-09-11
*/
@Getter
@Setter
@ToString
@TableName("contract_new")
public class ContractNew implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 创建id
*/
private Long createId;
/**
* 创建时间
*/
private Date createTime;
/**
* 修改id
*/
private Long modifyId;
/**
* 修改时间
*/
private Date modifyTime;
/**
* 数据状态0:正常;1:删除
*/
private Integer softDelete;
/**
* 合同编号(properyt.serialnumber + "C" + 三位流水,如452542C001)
*/
private String contractNo;
/**
* 合同名称
*/
private String contractName;
/**
* 合同所属公司(=物业所属销售所属公司,审批流按此公司匹配)
*/
private Long contractAgentId;
/**
* 客户所属公司(=创建人提交时选择的所属公司)
*/
private Long customerAgentId;
/**
* 所属物业ID(关联权限库properyt)
*/
private Long propertyId;
/**
* 物业名称(冗余快照)
*/
private String propertyName;
/**
* 客户编号快照(=properyt.serialnumber,编号流水/按客户归集用)
*/
private String customerNo;
/**
* 客户名称快照(=properyt.jinide_name 金蝶客户名称)
*/
private String customerName;
/**
* 合同金额(元)
*/
private BigDecimal amount;
/**
* 联系人
*/
private String contact;
/**
* 合同开始日期
*/
private Date startDate;
/**
* 合同结束日期
*/
private Date endDate;
/**
* 到期提醒开关0:关;1:开
*/
private Integer expireRemind;
/**
* 到期提醒天数
*/
private Integer remindDays;
/**
* 到期提醒时间(=end_date-remind_days 当天09:30:00)
*/
private Date remindTime;
/**
* 到期提醒是否已发送0:未发送;1:已发送
*/
private Integer reminded;
/**
* 所属销售ID(=物业所属销售)
*/
private Long saleId;
/**
* 所属销售姓名(冗余快照)
*/
private String saleName;
/**
* 合同状态0:待审;1:驳回;2:正常;3:过期;4:终止
*/
private Integer contractStatus;
/**
* 终止原因分类(cancel_reason.content快照,type=6)
*/
private String stopReasonType;
/**
* 终止详细原因
*/
private String stopReason;
}
@@ -0,0 +1,106 @@
package me.iiv.agent.entity.m2;
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;
/**
* <p>
* 合同流程/待办/操作记录表
* </p>
*
* @author codegen
* @since 2026-09-11
*/
@Getter
@Setter
@ToString
@TableName("contract_process")
public class ContractProcess implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 创建id
*/
private Long createId;
/**
* 创建时间
*/
private Date createTime;
/**
* 修改id
*/
private Long modifyId;
/**
* 修改时间
*/
private Date modifyTime;
/**
* 数据状态0:正常;1:删除
*/
private Integer softDelete;
/**
* 合同ID
*/
private Long contractId;
/**
* 合同编号(冗余)
*/
private String contractNo;
/**
* 节点类型0:创建;1:部门内审核;2:层级审核;3:终止;4:抄送
*/
private Integer nodeType;
/**
* 审核层级(层级审核为第几级,其余0)
*/
private Integer auditLevel;
/**
* 操作类型0:创建;1:审核通过;2:审核驳回;3:终止;4:修改再提交;5:抄送
*/
private Integer operationType;
/**
* 待办人/操作人ID
*/
private Long userId;
/**
* 待办人/操作人姓名
*/
private String userName;
/**
* 待办状态0:无需待办;1:待办;2:已办
*/
private Integer scheduleState;
/**
* 备注/驳回原因/终止原因/抄送文案
*/
private String remark;
/**
* 办理时间(待办未办为NULL)
*/
private Date operateTime;
}
@@ -16,7 +16,7 @@ import java.util.Date;
* </p>
*
* @author codegen
* @since 2026-09-03
* @since 2026-09-08
*/
@Getter
@Setter
@@ -0,0 +1,56 @@
package me.iiv.agent.entity.m2;
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.time.LocalDate;
import java.util.Date;
/** 入库单主表(m2,只读)。 */
@Getter
@Setter
@ToString
@TableName("warehouse_in")
public class WarehouseIn implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Long createId;
private Date createTime;
private Long modifyId;
private Date modifyTime;
private Integer softDelete;
private String inOrderNo;
private String allocationOrderNo;
private Long agentId;
private String agentName;
private Long warehouseTypeId;
private String warehouseTypeName;
private LocalDate inDate;
private Integer goodsCount;
private Integer inCount;
private Integer orderState;
private Long applyUserId;
private String applyUserName;
private Date applyDate;
private String memo;
private Integer auditLevel;
private Integer currendAuditLevel;
private Integer inSynchronizeState;
private Date inSynchronizeTime;
private String removeUserName;
private Date removeDate;
private String outOrderNo;
private String mallOrderNo;
private Long logisticsId;
private String logisticsName;
private String logisticsWayBill;
private String deliveryOrderNo;
private String installOrderNo;
private String pickupOrderNo;
private String withdrawOrderNo;
}
@@ -0,0 +1,34 @@
package me.iiv.agent.entity.m2;
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;
/** 入库单审批/处理流程(m2,只读)。 */
@Getter
@Setter
@ToString
@TableName("warehouse_in_audit_process")
public class WarehouseInAuditProcess implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Integer softDelete;
private Long warehouseInId;
private Long warehouseId;
private String warehouseName;
private Long userId;
private String userName;
private Date operationTime;
private Integer scheduleState;
private Integer auditState;
private Integer auditLevel;
private Integer operationTheme;
private Integer operationType;
private String memo;
}
@@ -0,0 +1,63 @@
package me.iiv.agent.entity.m2;
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.time.LocalDate;
import java.util.Date;
/** 入库单物料/SN 明细(m2,只读)。 */
@Getter
@Setter
@ToString
@TableName("warehouse_in_details")
public class WarehouseInDetail implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Long createId;
private Date createTime;
private Long modifyId;
private Date modifyTime;
private Integer softDelete;
private Long agentId;
private String agentName;
private String inOrderNo;
private String allocationOrderNo;
private Long warehouseInId;
private Long materielDefinitionId;
private String materialNo;
private String materielFullName;
private String materielSort;
private Long materielIndexId;
private String specifications;
private String snCode;
private Integer inCount;
private Integer currentInventory;
private LocalDate inDate;
private Long warehouseId;
private String warehouseName;
private Long warehouseTypeId;
private String warehouseTypeName;
private Integer orderState;
private Long applyUserId;
private String applyUserName;
private Integer goodInState;
private Date inConfirmTime;
private String inConfirmUserName;
private String outOrderNo;
private String mallOrderNo;
private Integer assetDisposalType;
private Integer synchronizeState;
private Long outDetailsId;
private String deliveryOrderNo;
private String installOrderNo;
private String pickupOrderNo;
private String withdrawOrderNo;
private String memo;
private String lineMemo;
}
@@ -0,0 +1,27 @@
package me.iiv.agent.entity.m2;
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;
/** 出入库附件定位信息(m2,只读)。 */
@Getter
@Setter
@ToString
@TableName("warehouse_in_out_pic")
public class WarehouseInOutPic implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Integer softDelete;
private Long aggregateId;
private String fileName;
private String ossbucketname;
private String ossfolder;
private Integer fileType;
private Integer inOutAllocationType;
}
@@ -0,0 +1,61 @@
package me.iiv.agent.entity.m2;
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.time.LocalDate;
import java.util.Date;
/** 出库单主表(m2,只读)。 */
@Getter
@Setter
@ToString
@TableName("warehouse_out")
public class WarehouseOut implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Long createId;
private Date createTime;
private Long modifyId;
private Date modifyTime;
private Integer softDelete;
private String outOrderNo;
private String allocationOrderNo;
private String mallOrderNo;
private Long agentId;
private String agentName;
private Long warehouseTypeId;
private String warehouseTypeName;
private LocalDate outDate;
private Integer goodsCount;
private Integer outCount;
private Integer orderState;
private Long applyUserId;
private String applyUserName;
private Date applyDate;
private String memo;
private Integer auditLevel;
private Integer currendAuditLevel;
private Integer useCustomerType;
private Long useCustomerId;
private String useCustomerName;
private Integer outSynchronizeState;
private Date outSynchronizeTime;
private String removeUserName;
private Date removeDate;
private Integer sendMode;
private Long logisticsId;
private String logisticsName;
private String logisticsOrderNo;
private String logisticsWayBill;
private Long addressId;
private Integer assetSynchronizeState;
private String deliveryOrderNo;
private String installOrderNo;
private String pickupOrderNo;
}
@@ -0,0 +1,34 @@
package me.iiv.agent.entity.m2;
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;
/** 出库单审批/处理记录(m2,只读)。 */
@Getter
@Setter
@ToString
@TableName("warehouse_out_audit_process")
public class WarehouseOutAuditProcess implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Integer softDelete;
private Long warehouseOutId;
private Long warehouseId;
private String warehouseName;
private Long userId;
private String userName;
private Date operationTime;
private Integer scheduleState;
private Integer auditState;
private Integer auditLevel;
private Integer operationTheme;
private Integer operationType;
private String memo;
}
@@ -0,0 +1,27 @@
package me.iiv.agent.entity.m2;
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;
/** 出库凭证备注(m2,只读)。 */
@Getter
@Setter
@ToString
@TableName("warehouse_out_certificate")
public class WarehouseOutCertificate implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Long outOrderId;
private String outOrderNo;
private String memo;
private Date createTime;
private Date modifyTime;
private Integer softDelete;
}
@@ -0,0 +1,57 @@
package me.iiv.agent.entity.m2;
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.math.BigDecimal;
import java.time.LocalDate;
import java.util.Date;
/** 出库单物料/SN 明细(m2,只读)。 */
@Getter
@Setter
@ToString
@TableName("warehouse_out_details")
public class WarehouseOutDetail implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Long createId;
private Date createTime;
private Long modifyId;
private Date modifyTime;
private Integer softDelete;
private Long agentId;
private String agentName;
private Long warehouseId;
private String warehouseName;
private Integer outCount;
private String outOrderNo;
private String allocationOrderNo;
private String mallOrderNo;
private Long warehouseOutId;
private Long materielDefinitionId;
private String materialNo;
private String materielFullName;
private String materielSort;
private Long materielIndexId;
private String specifications;
private String snCode;
private LocalDate outDate;
private Long warehouseTypeId;
private String warehouseTypeName;
private Integer orderState;
private Integer queryFlag;
private Long parentId;
private Date outConfirmTime;
private String outConfirmUserName;
private BigDecimal price;
private BigDecimal allMoney;
private String threeAttribute;
private String assetDisposalType;
private Integer assetSynchronizeState;
}
@@ -0,0 +1,26 @@
package me.iiv.agent.entity.m2;
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;
/** 出入库类型(m2,只读)。 */
@Getter
@Setter
@ToString
@TableName("warehouse_type")
public class WarehouseType implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Integer softDelete;
private Integer warehouseType;
private String typeName;
private Integer sceneType;
private String typeNo;
private Integer assetFlag;
}
@@ -16,7 +16,7 @@ import java.util.Date;
* </p>
*
* @author codegen
* @since 2026-09-03
* @since 2026-09-08
*/
@Getter
@Setter
@@ -38,7 +38,7 @@ public class AdminUser implements Serializable {
private String account;
/**
* PBKDF2 加盐密码哈希
* PBKDF2 加盐密码哈希
*/
private String passwordHash;
@@ -16,7 +16,7 @@ import java.util.Date;
* </p>
*
* @author codegen
* @since 2026-09-03
* @since 2026-09-08
*/
@Getter
@Setter
@@ -26,6 +26,12 @@ public class AgentApiKey implements Serializable {
private static final long serialVersionUID = 1L;
/** 主体类型:用户委托(绑定 user_id,行级权限跟随人员) */
public static final String PRINCIPAL_TYPE_USER = "user";
/** 主体类型:租户服务(Open API 服务密钥签发,不绑定人员) */
public static final String PRINCIPAL_TYPE_SERVICE = "service";
/**
* 主键
*/
@@ -33,10 +39,20 @@ public class AgentApiKey implements Serializable {
private Long id;
/**
* 所属人(m0.user.id)
* 所属人(m0.user.id);principal_type=service 时为 NULL
*/
private Long userId;
/**
* 主体类型:user=用户委托 service=租户服务
*/
private String principalType;
/**
* 服务主体标识(principal_type=service 时必填,如 xiaoqu-ai)
*/
private String subjectName;
/**
* ak 前缀(展示用,如 xqak_abcd1234)
*/
@@ -48,7 +64,7 @@ public class AgentApiKey implements Serializable {
private String keyHash;
/**
* ak 明文 AES-GCM 加密密文(二次获取用),空=历史数据未存
* ak 明文 AES-GCM 加密密文(Base64: iv‖cipher‖tag),空=历史数据未存
*/
private String keyCipher;
@@ -16,7 +16,7 @@ import java.util.Date;
* </p>
*
* @author codegen
* @since 2026-09-03
* @since 2026-09-08
*/
@Getter
@Setter
@@ -0,0 +1,34 @@
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;
@Getter
@Setter
@ToString
@TableName("app_open_client")
public class AppOpenClient implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private String name;
private String keyPrefix;
private String keyHash;
private String memo;
private Byte status;
private Date lastUsedTime;
private Long createId;
private Date createTime;
private Long modifyId;
private Date modifyTime;
private Byte softDelete;
}
@@ -0,0 +1,30 @@
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;
@Getter
@Setter
@ToString
@TableName("app_open_client_scope")
public class AppOpenClientScope implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Long clientId;
private Long scopeId;
private Long createId;
private Date createTime;
private Long modifyId;
private Date modifyTime;
private Byte softDelete;
}
@@ -16,7 +16,7 @@ import java.util.Date;
* </p>
*
* @author codegen
* @since 2026-09-03
* @since 2026-09-08
*/
@Getter
@Setter
@@ -16,7 +16,7 @@ import java.util.Date;
* </p>
*
* @author codegen
* @since 2026-09-03
* @since 2026-09-08
*/
@Getter
@Setter
@@ -16,7 +16,7 @@ import java.util.Date;
* </p>
*
* @author codegen
* @since 2026-09-03
* @since 2026-09-08
*/
@Getter
@Setter
@@ -15,7 +15,7 @@ import java.util.Date;
* </p>
*
* @author codegen
* @since 2026-09-03
* @since 2026-09-08
*/
@Getter
@Setter
@@ -9,7 +9,7 @@ import me.iiv.agent.entity.m0.AgentManageDistrictAuth;
* </p>
*
* @author codegen
* @since 2026-09-03
* @since 2026-09-08
*/
public interface AgentManageDistrictAuthMapper extends BaseMapper<AgentManageDistrictAuth> {
@@ -9,7 +9,7 @@ import me.iiv.agent.entity.m0.Agent;
* </p>
*
* @author codegen
* @since 2026-09-03
* @since 2026-09-08
*/
public interface AgentMapper extends BaseMapper<Agent> {
@@ -9,7 +9,7 @@ import me.iiv.agent.entity.m0.Authority;
* </p>
*
* @author codegen
* @since 2026-09-03
* @since 2026-09-08
*/
public interface AuthorityMapper extends BaseMapper<Authority> {
@@ -0,0 +1,7 @@
package me.iiv.agent.mapper.m0;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m0.DistrictDistrictStrategy;
public interface DistrictDistrictStrategyMapper extends BaseMapper<DistrictDistrictStrategy> {
}
@@ -0,0 +1,7 @@
package me.iiv.agent.mapper.m0;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m0.HardwareView;
public interface HardwareViewMapper extends BaseMapper<HardwareView> {
}
@@ -0,0 +1,16 @@
package me.iiv.agent.mapper.m0;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m0.InvoiceInfo;
/**
* <p>
* 发票信息表 Mapper 接口
* </p>
*
* @author codegen
* @since 2026-09-08
*/
public interface InvoiceInfoMapper extends BaseMapper<InvoiceInfo> {
}
@@ -0,0 +1,7 @@
package me.iiv.agent.mapper.m0;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m0.Networkcard;
public interface NetworkcardMapper extends BaseMapper<Networkcard> {
}
@@ -9,7 +9,7 @@ import me.iiv.agent.entity.m0.Properyt;
* </p>
*
* @author codegen
* @since 2026-09-03
* @since 2026-09-08
*/
public interface ProperytMapper extends BaseMapper<Properyt> {
@@ -9,7 +9,7 @@ import me.iiv.agent.entity.m0.User;
* </p>
*
* @author codegen
* @since 2026-09-03
* @since 2026-09-08
*/
public interface UserMapper extends BaseMapper<User> {
@@ -0,0 +1,16 @@
package me.iiv.agent.mapper.m1;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m1.MallGoodsOrder;
/**
* <p>
* MallGoodsOrder Mapper 接口
* </p>
*
* @author codegen
* @since 2026-09-09
*/
public interface MallGoodsOrderMapper extends BaseMapper<MallGoodsOrder> {
}
@@ -0,0 +1,16 @@
package me.iiv.agent.mapper.m1;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m1.MallOrderDetails;
/**
* <p>
* MallOrderDetails Mapper 接口
* </p>
*
* @author codegen
* @since 2026-09-09
*/
public interface MallOrderDetailsMapper extends BaseMapper<MallOrderDetails> {
}
@@ -0,0 +1,16 @@
package me.iiv.agent.mapper.m1;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m1.MallOrderLogisticsDetai;
/**
* <p>
* MallOrderLogisticsDetai Mapper 接口
* </p>
*
* @author codegen
* @since 2026-09-09
*/
public interface MallOrderLogisticsDetaiMapper extends BaseMapper<MallOrderLogisticsDetai> {
}
@@ -0,0 +1,16 @@
package me.iiv.agent.mapper.m1;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m1.MallOrderLogistics;
/**
* <p>
* MallOrderLogistics Mapper 接口
* </p>
*
* @author codegen
* @since 2026-09-09
*/
public interface MallOrderLogisticsMapper extends BaseMapper<MallOrderLogistics> {
}
@@ -0,0 +1,16 @@
package me.iiv.agent.mapper.m1;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m1.MallShippingAddress;
/**
* <p>
* MallShippingAddress Mapper 接口
* </p>
*
* @author codegen
* @since 2026-09-09
*/
public interface MallShippingAddressMapper extends BaseMapper<MallShippingAddress> {
}
@@ -0,0 +1,16 @@
package me.iiv.agent.mapper.m2;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m2.ContractFile;
/**
* <p>
* 合同附件 Mapper 接口
* </p>
*
* @author codegen
* @since 2026-09-11
*/
public interface ContractFileMapper extends BaseMapper<ContractFile> {
}
@@ -0,0 +1,16 @@
package me.iiv.agent.mapper.m2;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m2.ContractNew;
/**
* <p>
* 合同主表 Mapper 接口
* </p>
*
* @author codegen
* @since 2026-09-11
*/
public interface ContractNewMapper extends BaseMapper<ContractNew> {
}
@@ -0,0 +1,16 @@
package me.iiv.agent.mapper.m2;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m2.ContractProcess;
/**
* <p>
* 合同流程/待办/操作记录 Mapper 接口
* </p>
*
* @author codegen
* @since 2026-09-11
*/
public interface ContractProcessMapper extends BaseMapper<ContractProcess> {
}
@@ -9,7 +9,7 @@ import me.iiv.agent.entity.m2.WarehouseAgentAuth;
* </p>
*
* @author codegen
* @since 2026-09-03
* @since 2026-09-08
*/
public interface WarehouseAgentAuthMapper extends BaseMapper<WarehouseAgentAuth> {
@@ -0,0 +1,7 @@
package me.iiv.agent.mapper.m2;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m2.WarehouseInAuditProcess;
public interface WarehouseInAuditProcessMapper extends BaseMapper<WarehouseInAuditProcess> {
}
@@ -0,0 +1,7 @@
package me.iiv.agent.mapper.m2;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m2.WarehouseInDetail;
public interface WarehouseInDetailMapper extends BaseMapper<WarehouseInDetail> {
}
@@ -0,0 +1,7 @@
package me.iiv.agent.mapper.m2;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m2.WarehouseIn;
public interface WarehouseInMapper extends BaseMapper<WarehouseIn> {
}
@@ -0,0 +1,7 @@
package me.iiv.agent.mapper.m2;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m2.WarehouseInOutPic;
public interface WarehouseInOutPicMapper extends BaseMapper<WarehouseInOutPic> {
}
@@ -0,0 +1,7 @@
package me.iiv.agent.mapper.m2;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m2.WarehouseOutAuditProcess;
public interface WarehouseOutAuditProcessMapper extends BaseMapper<WarehouseOutAuditProcess> {
}
@@ -0,0 +1,7 @@
package me.iiv.agent.mapper.m2;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m2.WarehouseOutCertificate;
public interface WarehouseOutCertificateMapper extends BaseMapper<WarehouseOutCertificate> {
}
@@ -0,0 +1,7 @@
package me.iiv.agent.mapper.m2;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m2.WarehouseOutDetail;
public interface WarehouseOutDetailMapper extends BaseMapper<WarehouseOutDetail> {
}
@@ -0,0 +1,7 @@
package me.iiv.agent.mapper.m2;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m2.WarehouseOut;
public interface WarehouseOutMapper extends BaseMapper<WarehouseOut> {
}
@@ -0,0 +1,7 @@
package me.iiv.agent.mapper.m2;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m2.WarehouseType;
public interface WarehouseTypeMapper extends BaseMapper<WarehouseType> {
}
@@ -9,7 +9,7 @@ import me.iiv.agent.entity.svc.AdminUser;
* </p>
*
* @author codegen
* @since 2026-09-03
* @since 2026-09-08
*/
public interface AdminUserMapper extends BaseMapper<AdminUser> {
@@ -9,7 +9,7 @@ import me.iiv.agent.entity.svc.AgentApiKey;
* </p>
*
* @author codegen
* @since 2026-09-03
* @since 2026-09-08
*/
public interface AgentApiKeyMapper extends BaseMapper<AgentApiKey> {
@@ -9,7 +9,7 @@ import me.iiv.agent.entity.svc.AgentScope;
* </p>
*
* @author codegen
* @since 2026-09-03
* @since 2026-09-08
*/
public interface AgentScopeMapper extends BaseMapper<AgentScope> {
@@ -0,0 +1,13 @@
package me.iiv.agent.mapper.svc;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.svc.AppOpenClient;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
public interface AppOpenClientMapper extends BaseMapper<AppOpenClient> {
/** 使用时间属于运行态信息,不应改变管理员看到的配置修改时间。 */
@Update("UPDATE app_open_client SET last_used_time = CURRENT_TIMESTAMP, modify_time = modify_time WHERE id = #{id}")
int touchLastUsed(@Param("id") Long id);
}
@@ -0,0 +1,7 @@
package me.iiv.agent.mapper.svc;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.svc.AppOpenClientScope;
public interface AppOpenClientScopeMapper extends BaseMapper<AppOpenClientScope> {
}
@@ -9,7 +9,7 @@ import me.iiv.agent.entity.svc.AuditLog;
* </p>
*
* @author codegen
* @since 2026-09-03
* @since 2026-09-08
*/
public interface AuditLogMapper extends BaseMapper<AuditLog> {

Some files were not shown because too many files have changed in this diff Show More