Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f51e41a189 | ||
|
|
12de320459 | ||
|
|
487a41df74 | ||
|
|
798075f47e | ||
|
|
a575736c81 | ||
|
|
6e8cf60fd3 |
@@ -0,0 +1,133 @@
|
||||
# 装机工单 MCP 工具设计(install.order.list.query / install.order.detail.query)
|
||||
|
||||
> 状态:已评审(2026-09-16 王恒确认) | 对应老接口:`POST /installOrder/pageList`、`POST /installOrder/lookDetails`(xiaoqu-franchisee `InstallOrderController.java:2004` / `:229`)
|
||||
> 只读工具,不改老系统数据;数据源 m2(业务库)为主,详情聚合 m1(商城订单/地址)。
|
||||
|
||||
## 1. 工具清单
|
||||
|
||||
| 工具名 | category | kind | 用途 |
|
||||
|---|---|---|---|
|
||||
| `install.order.list.query` | workorder | read | 分页查询装机工单列表 |
|
||||
| `install.order.detail.query` | workorder | read | 按 id 或装机单号查询工单详情(主单+明细+人员+流程) |
|
||||
|
||||
## 2. install.order.list.query
|
||||
|
||||
### 2.1 入参
|
||||
|
||||
对齐老 pageList,命名转 camelCase,匹配方式保持老语义(String 模糊、数字精确、时间区间):
|
||||
|
||||
| 参数 | 类型 | 必填 | 匹配 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| installOrderNo | string | 否 | 模糊 | 装机单号 |
|
||||
| mallOrderNo | string | 否 | 模糊 | 商城销售订单号 |
|
||||
| deliveryOrderNo | string | 否 | 模糊 | 送货工单号 |
|
||||
| pickupOrderNo | string | 否 | 模糊 | 取货工单号 |
|
||||
| scheduleTransferOrder | string | 否 | 模糊 | 待办划转单号 |
|
||||
| installType | int | 否 | 精确 | 0=正式装机 1=测试装机 |
|
||||
| agentId | int | 否 | 精确 | 所属代理公司 id |
|
||||
| districtName | string | 否 | 模糊 | 项目/站点名称 |
|
||||
| applyUserName | string | 否 | 模糊 | 申请人 |
|
||||
| maintenanceUserName | string | 否 | 模糊 | 运维人员 |
|
||||
| cooperateUserName | string | 否 | 模糊 | 协同人员 |
|
||||
| orderState | int | 否 | 精确 | 0待审核 1被驳回 2调度中 3待出库 4待送货 5作业中 6待取回 7已结束 8已取消 |
|
||||
| applyTimeFrom / applyTimeTo | string | 否 | 区间 | 申请时间,yyyy-MM-dd(To 含当天,服务端补 23:59:59) |
|
||||
| page | int | 否 | — | 默认 1 |
|
||||
| rows | int | 否 | — | 默认 20,上限 100,超出截断并在 notice 提示 |
|
||||
|
||||
裁剪说明(已评审确认):老接口的 7 组时间区间(finish/audit/dispatch/allocation/signature/pickup/warehousing)使用频率低,首版**只保留申请时间一组**;如确有需要再扩展。老接口的 `sidx/sord` 自由排序存在 SQL 注入面,**不移植**,固定排序 `apply_time DESC, id DESC`。
|
||||
|
||||
### 2.2 返回
|
||||
|
||||
沿用 MCP 统一分页结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 123, "page": 1, "rows": 20,
|
||||
"notice": "rows 超过上限已截断为 100(仅超限时出现)",
|
||||
"records": [ { "id": 1, "installOrderNo": "...", "installType": 0, "installTypeText": "正式装机",
|
||||
"orderState": 5, "orderStateText": "作业中",
|
||||
"agentId": 1, "agentName": "...", "propertyId": 1, "propertyName": "...",
|
||||
"districtId": 1, "districtName": "...", "projectAddress": "...",
|
||||
"applyUserName": "...", "maintenanceUserName": "...", "cooperateUserName": "...",
|
||||
"hopeInstallDate": "...", "trueInstallDate": "...",
|
||||
"applyTime": "yyyy-MM-dd HH:mm:ss", "finishDate": "...",
|
||||
"mallOrderNo": "...", "deliveryOrderNo": "...", "pickupOrderNo": "...",
|
||||
"totalInstallCount": 3 } ]
|
||||
}
|
||||
```
|
||||
|
||||
`totalInstallCount` 同老接口:按 `install_order_details` 汇总 install_count(分页记录逐条汇总,与老行为一致)。`orderStateText/installTypeText` 为附带文案,免查字典。
|
||||
|
||||
### 2.3 行级权限(新增 `PermissionService.applyInstallOrderRowFilter`)
|
||||
|
||||
老逻辑在 `xiaoqu-franchisee utils/WrapperUtils.java:127-264`(`getWorkOrderAuth`,pageList 传 `propertyFlag=true, className=property_id, orderNoName=install_order_no, type=0`),1:1 移植:
|
||||
|
||||
1. **总部**(权限组 `isagent != 1`):不过滤,看全部。
|
||||
2. **代理销售非负责人**(Redis 字典 authDeptId 含本部门且不含负责人岗位,复用现有 `isSalesNonLeader` 语义):`property_id IN(自己创建的物业 ∪ 被授权代管公司名下的物业)`,集合为空则 `IN (0)`;命中后互斥 return,不进规则 3。
|
||||
3. **其他代理用户**:
|
||||
- `ids = 自己 agentId ∪ warehouse_agent_auth(user_id=自己).manager_agent_id`(本人 + 授权代管公司,**不含下级代理**,与现有 `visibleAgentIds` 不同,不可复用);
|
||||
- 参与者单号 `orderNos = install_order_process(user_id=自己) ∪ install_order_user(user_id=自己)`(让运维/协同人员在调度阶段即可见工单);
|
||||
- 代管物业 `propertyIds = agent_manage_district_auth(manage_agent_id IN ids).property_id`;
|
||||
- 最终:`AND (apply_agent_id IN ids OR agent_id IN ids OR property_id IN propertyIds[如有] OR install_order_no IN orderNos[如有])`。
|
||||
4. 未配置权限组的用户按现有约定直接拒绝(同 `isAgentAuthority`)。
|
||||
|
||||
## 3. install.order.detail.query
|
||||
|
||||
对齐老 `/installOrder/lookDetails`(`InstallOrderController.java:229`),返回 `InstallOrderApplyLookOutput` 的 MCP 版。商城订单聚合已评审确认**首版即带**(跨 m1 库)。
|
||||
|
||||
### 3.1 入参
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| id | int | 二选一 | 工单主键,>0 优先 |
|
||||
| installOrderNo | string | 二选一 | 装机单号,精确匹配 |
|
||||
| includeProcess | bool | 否 | 是否返回流程记录,默认 true |
|
||||
| processLimit | int | 否 | 流程记录上限,默认 100,最大 500 |
|
||||
|
||||
### 3.2 返回
|
||||
|
||||
- `record`:主单全字段(同列表字段 + `customerContact/contactMobile/projectMemo/auditTime/dispatchTime/...` 等完整时间链与经手人);
|
||||
- `details`:`install_order_details` 明细(物料、数量、安装位置等);
|
||||
- `maintenanceUsers` / `cooperateUsers`:`install_order_user`(userType 0/1);
|
||||
- `addressInfo`:收货地址(m1 `mall_shipping_address`,addressId 非空时);
|
||||
- `mallOrder`:商城订单+订单明细+物料(m1,mallOrderNo 非空时);
|
||||
- `processes`:流程记录,`schedule_state=2`(已办)、排除抄送(operationTheme!=12),按 `operation_time` 升序;调度/配发节点附人员,配发节点附 SN(`install_distribute_details`),出入库节点附 `install_to_out_warehouse` 记录与图片凭证 OSS URL(m1 `mall_logistics_pic` / m2 `warehouse_in_out_pic`)。
|
||||
|
||||
### 3.3 权限与防枚举
|
||||
|
||||
与列表**复用同一 `applyInstallOrderRowFilter`**:先按 id/单号查出主单,再套上同一个行级过滤 wrapper 校验可见性;不可见与不存在统一返回 `record=null` + `notice="工单不存在或无权查看"`,防止枚举越权工单(与 warehouse.out.detail.query 同一约定)。
|
||||
|
||||
## 4. 实现范围(变更文件清单)
|
||||
|
||||
新增(xiaoqu-agent-service):
|
||||
|
||||
- `entity/m2/InstallOrder.java`、`InstallOrderDetails.java`、`InstallOrderProcess.java`、`InstallOrderUser.java`、`InstallDistributeDetails.java`、`InstallToOutWarehouse.java`(只保留查询需要的字段)
|
||||
- `mapper/m2/InstallOrderMapper.java` 等对应 Mapper(m2 分包已被 `M2DataSourceConfig` 扫描)
|
||||
- `mcp/tool/InstallOrderTools.java`(两个 `@XqTool(category="workorder", kind="read")` 方法)
|
||||
- `permission/PermissionService.java` 新增 `applyInstallOrderRowFilter`
|
||||
- m1 侧复用现有商城订单/地址/物流 Mapper(warehouse.out.detail 已引入则直接复用,缺则新增)
|
||||
- codegen 表白名单 `CodeGenerator.java` 登记新表
|
||||
- 测试:`InstallOrderToolsTest`(结构/分页/排序/schema 发布)+ `PermissionServiceInstallOrderRowFilterTest`(总部/销售/代理三角色)
|
||||
- 文档:`doc/agent-mcp-tools-reference.md` 新增章节、README 工具表登记
|
||||
|
||||
不改:老系统任何代码与数据;svc 库表结构(tool 表由 ToolSyncService 启动自动同步)。
|
||||
|
||||
## 5. 边界与性能
|
||||
|
||||
- 列表 `selectCount + LIMIT` 手写分页;固定排序,无注入面。
|
||||
- 不做无界聚合;装机统计(数量/状态分布)如有需要,后续独立设计 `install.order.summary.query`。
|
||||
- 详情的子表均按主单单号/id 精确查询,processLimit 兜底;图片只回传 OSS URL,不内嵌内容。
|
||||
- 时间统一 `yyyy-MM-dd HH:mm:ss`,Asia/Shanghai。
|
||||
|
||||
## 6. 测试与验收
|
||||
|
||||
1. 单测全量通过(现有 236 + 新增)。
|
||||
2. 三角色权限用例:总部无过滤;代理只见 自己+代管公司+代管物业+本人参与 的工单;销售非负责人只见 自己创建物业+代管物业;越权查详情返回 record=null 且提示与不存在一致。
|
||||
3. rows=500 截断为 100 且带 notice;installOrderNo 模糊、orderState 精确、applyTime 区间生效。
|
||||
4. 发布:合并 main → `deploy/deploy.sh remote-deploy` → 健康检查 POST /mcp 401 → svc 库 tool 表出现两个新工具 → 给目标 scope 绑定 scope_tool → 线上 tools/list 可见性验证。
|
||||
|
||||
## 7. 评审结论(2026-09-16 王恒确认)
|
||||
|
||||
1. 时间区间只保留申请时间一组(applyTimeFrom/To),其余 7 组不移植。
|
||||
2. 详情首版即带商城订单聚合(跨 m1 库)。
|
||||
3. category 用 `workorder`(不新建 `install`,也不并入 `warehouse`)。
|
||||
@@ -0,0 +1,140 @@
|
||||
# 小趣 MCP 外部接入规范
|
||||
|
||||
> 版本:v1.0(2026-09-15)
|
||||
> 适用对象:CRM 等外部系统的后端集成方
|
||||
> 状态:当前生效。认证换发流程(第 3 节)为小趣自定义扩展,后续可能升级为 OAuth 2.1 / MCP 标准授权,届时本规范会发新版并保留兼容期。
|
||||
|
||||
## 1. 服务边界
|
||||
|
||||
小趣 MCP 只做三件事:**供数据、供身份、守权限**。
|
||||
|
||||
- 供数据:查询工具(QUERY)+ 增量同步工具(SYNC),保证准确、一致、可恢复
|
||||
- 供身份:Token 签发 / 吊销 / 自检
|
||||
- 守权限:服务 Token 按授权 scope 白名单限工具,个人 Token 实时 ACL 行过滤
|
||||
|
||||
到期规则计算、AI 决策、消息推送、经营事项流转**不属于** MCP 职责,由接入方或小趣 AI 系统自行实现,MCP 不提供也不预留相关字段。
|
||||
|
||||
## 2. 接入端点与协议
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| MCP 端点(生产) | `https://agent.ququclean.com/mcp` |
|
||||
| 传输协议 | MCP Streamable HTTP(JSON-RPC 2.0) |
|
||||
| 协议版本头 | `MCP-Protocol-Version: 2025-06-18` |
|
||||
| 响应格式 | SSE(`text/event-stream`),取 `data:` 行解析为 JSON-RPC 消息 |
|
||||
| 会话 | `initialize` 响应头返回 `Mcp-Session-Id`,后续请求必须携带 |
|
||||
|
||||
### 2.1 标准握手序列
|
||||
|
||||
```text
|
||||
POST /mcp initialize(JSON-RPC,带 id)
|
||||
← 响应头取 Mcp-Session-Id,data: 行为 initialize 结果
|
||||
POST /mcp notifications/initialized(无 id,带 Mcp-Session-Id)
|
||||
← 202,无响应体
|
||||
POST /mcp tools/list # 自动发现工具及输入 JSON Schema
|
||||
POST /mcp tools/call # 按 Schema 调用具体工具
|
||||
```
|
||||
|
||||
请求统一头:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <xqak-...>
|
||||
Content-Type: application/json
|
||||
Accept: application/json, text/event-stream
|
||||
MCP-Protocol-Version: 2025-06-18
|
||||
Mcp-Session-Id: <initialize 返回的会话 ID>
|
||||
X-Mcp-Schema-Version: 2
|
||||
```
|
||||
|
||||
注意:`notifications/initialized` 生效后再发 `tools/list`,否则偶发空响应,重试即可。
|
||||
|
||||
## 3. 认证与凭证管理(自定义扩展)
|
||||
|
||||
### 3.1 两类凭证
|
||||
|
||||
| 凭证 | 形态 | 用途 | 获取方式 |
|
||||
|---|---|---|---|
|
||||
| 服务密钥 `X-Open-Key` | `xqok-...` | 换取访问 Token,**不是** MCP Token | 管理员在管理后台「服务密钥」页创建,明文只展示一次 |
|
||||
| 访问 Token | `xqak-...` | 放进 `Authorization: Bearer` 调 `/mcp` | 用服务密钥调 ensure 接口换取,长期有效 |
|
||||
|
||||
### 3.2 换取 Token
|
||||
|
||||
```http
|
||||
POST https://agent.ququclean.com/api/open/token/ensure
|
||||
X-Open-Key: <服务密钥>
|
||||
Content-Type: application/json
|
||||
|
||||
{"scopeCode":"sync","principalType":"service","serviceName":"<你的系统名>"}
|
||||
```
|
||||
|
||||
响应 `token` 字段即访问 Token。该接口幂等:同一 subject 重复调用返回同一 Token。
|
||||
|
||||
个人 Token(前台点查场景):同接口传 `{"mobile": "<人员手机号>"}`,幂等返回该人员的个人 Token(`user_delegated`),人员停用后 60 秒内自动失效。
|
||||
|
||||
### 3.3 凭证安全要求
|
||||
|
||||
- 服务密钥与 Token 只允许存接入方后端的密钥管理设施或环境变量,**禁止**下发浏览器/App、禁止写入日志、禁止提交代码库
|
||||
- 密钥泄露:通知小趣管理员在管理后台执行「轮换」(旧密钥立即失效)或「吊销」
|
||||
- 数据库只存密钥哈希,创建/轮换弹窗关闭后明文不可找回
|
||||
|
||||
## 4. 工具分类与调用约定
|
||||
|
||||
### 4.1 工具发现
|
||||
|
||||
`tools/list` 返回当前 Token 可见的全部工具及入参 JSON Schema。**可见性由 Token 绑定的 scope 白名单决定**——未授权的工具不出现在列表中,接入方应按 `tools/list` 结果动态适配,不要硬编码工具清单。
|
||||
|
||||
### 4.2 统一输出信封
|
||||
|
||||
所有工具返回 `structuredContent`,`X-Mcp-Schema-Version: 2` 时为新信封:
|
||||
|
||||
```json
|
||||
{ "schema_version": 2, "request_id": "...", "data": { ... } }
|
||||
```
|
||||
|
||||
- 不带该请求头返回 v1 老结构(兼容期内有效)
|
||||
- `request_id` 必须记入接入方日志,双向排查用;Token 与手机号明文不落日志
|
||||
|
||||
### 4.3 通用入参约束
|
||||
|
||||
- 所有工具**禁止传 `tenant_id` / `scope`**,租户从 Token 推导,传了返回 `PERMISSION_DENIED`
|
||||
- 分页类工具 `limit` 上限 200,超出截断并在响应带 notice
|
||||
|
||||
## 5. 错误码与重试规则
|
||||
|
||||
| 错误码 | 含义 | 接入方动作 |
|
||||
|---|---|---|
|
||||
| `UNAUTHENTICATED` | Token 吊销 / 人员停用 | 重新 ensure 取 Token;仍失败则告警人工介入 |
|
||||
| `PERMISSION_DENIED` | 工具不在 Token scope 白名单,或传了 tenant_id | 检查绑定与入参,**不要**重试 |
|
||||
| `RATE_LIMITED` | 触发限流 | 指数退避重试(1s/2s/4s…上限 60s) |
|
||||
| `SYNC_CURSOR_EXPIRED` | 同步快照/游标过期或被篡改 | 清空本地镜像,`syncToken=null` 全量重建 |
|
||||
| `INVALID_ARGUMENT` | 入参不符合 Schema | 修正后重发,不要原样重试 |
|
||||
|
||||
限流分档:SYNC 工具独立配额档,与 QUERY 互不占配额。
|
||||
|
||||
## 6. 增量同步协议(SYNC 工具)
|
||||
|
||||
工具:`users.changes.list` / `properties.changes.list` / `contracts.changes.list`。
|
||||
|
||||
入参仅三个:`syncToken`、`cursor`、`limit`。
|
||||
|
||||
协议语义:
|
||||
|
||||
- **快照**:每轮同步签发 `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` 非空为删除标记,本地镜像应标记删除
|
||||
- **版本字段**:`version` 由 `modify_time` 派生,仅用于调试核对,禁止用作并发控制
|
||||
|
||||
接入方必须持久化每页的 `sync_token + cursor`(断点恢复),实现 `SYNC_CURSOR_EXPIRED` 全量重建分支,并在扫描启动前用 `integration.context.get` 校验 `principal_type=tenant_service` 且 capability 含 SYNC,拿错个人 Token 时拒绝执行扫描。
|
||||
|
||||
## 7. 交付物与责任划分
|
||||
|
||||
| 小趣侧交付 | 接入方自负责 |
|
||||
|---|---|
|
||||
| 本规范(契约定义) | MCP 客户端实现(可用任意标准 MCP Client SDK) |
|
||||
| 管理后台服务密钥创建/轮换/吊销 | 密钥与 Token 的安全存储 |
|
||||
| 工具清单及入参 Schema(`tools/list` 自动发现) | 本地镜像 upsert / 逻辑删除 |
|
||||
| 错误码语义与限流档位 | 定时任务调度、游标持久化、重试退避 |
|
||||
| `request_id` 双侧排查支持 | 业务规则、AI 决策、推送 |
|
||||
|
||||
详细逐步接入示例(含报文样例与伪代码)见《CRM 接入手册》`doc/crm-integration-guide.md`。
|
||||
@@ -0,0 +1,45 @@
|
||||
# REQ-20260915-0011 代码评审报告
|
||||
|
||||
- 工具:`warehouse.stock.list.query`
|
||||
- 评审任务:`21775`
|
||||
- 评审结论:通过,无阻塞问题
|
||||
- 评审范围:库存实体与 Mapper、`WarehouseStockTools`、库存专用权限过滤、专项测试及工具文档
|
||||
|
||||
## 变更与设计一致性
|
||||
|
||||
实现与详细设计一致:工具保持只读,查询 `warehouse_stock_manage` 快照表;输入支持仓库、所属公司、物品名称、编号、分类和规格组合筛选;分页默认 1/20、上限 100;结果固定按库存量和记录 ID 倒序。返回值采用显式白名单,并对库存空值及修改时间进行规范化处理。
|
||||
|
||||
关键实现位于 `src/main/java/me/iiv/agent/mcp/tool/WarehouseStockTools.java:41` 至 `:125`。行级权限实现位于 `src/main/java/me/iiv/agent/permission/PermissionService.java:195` 至 `:213`。专项测试位于 `src/test/java/me/iiv/agent/mcp/tool/WarehouseStockToolsTest.java:55` 至 `:114` 和 `src/test/java/me/iiv/agent/permission/PermissionServiceWarehouseStockRowFilterTest.java:52` 至 `:90`。
|
||||
|
||||
## 五视角扫描结果
|
||||
|
||||
### 1. 攻击者视角
|
||||
|
||||
筛选值通过 MyBatis-Plus 查询构造器参数化绑定,名称、编号和规格未直接拼接进 SQL。唯一显式拼接的分页片段来自已规范化的整数 page/rows;rows 被限制为 100,offset 使用 long 参与乘法,未发现可控 SQL 结构注入路径。工具始终添加 `soft_delete = 0`,不会通过普通筛选读取软删记录。工具标注为 warehouse/read,方法中只执行 count 和 selectList,不包含库存新增、扣减、预占或回写逻辑。
|
||||
|
||||
### 2. 泄露者视角
|
||||
|
||||
代理权限在 `PermissionService.java:199-213` 中独立执行。总部身份不追加过滤;代理身份仅组合本人 agentId 与 `warehouse_agent_auth` 授权的 managerAgentId,并以括号化的 `agent_id IN (...) OR warehouse_owner_id IN (...)` 约束结果。代理用户缺少 agentId 时直接拒绝访问,未降级为总部权限。输出映射位于 `WarehouseStockTools.java:95-120`,只返回仓库、公司、物料和库存业务字段,不包含 createId、modifyId、softDelete 等内部维护信息。
|
||||
|
||||
### 3. 并发者视角
|
||||
|
||||
工具无共享可变业务状态,时间格式器为不可变的 `DateTimeFormatter`,每次调用均新建查询 wrapper 和返回 Map,不存在跨请求复用条件或结果的问题。count 与 list 是两个读取时点,库存并发变化时 total 与当前页可能短暂不完全一致;详细设计已明确该工具是运营查询而非事务级快照接口,此行为可接受。稳定排序在 `WarehouseStockTools.java:78-80` 使用 `current_inventory DESC, id DESC`,降低相同库存量下翻页顺序的不确定性。
|
||||
|
||||
### 4. 边界者视角
|
||||
|
||||
page 为空或小于 1 时归一为 1;rows 为空或小于 1 时归一为 20,超过 100 时截断并返回 notice。空 ID 集合不会生成 `IN ()`,空白字符串不参与筛选且有效字符串会去除首尾空格。库存字段为空时按 0 返回,`availableInventory = currentInventory - outOfStock`,负值不会被静默钳制,能够暴露异常库存。修改时间为空时返回 null,否则按 Asia/Shanghai 格式化。页码超出结果范围时 Mapper 正常返回空列表。
|
||||
|
||||
### 5. 依赖者视角
|
||||
|
||||
实体与 Mapper 使用现有 m2 数据源和 MyBatis-Plus 单表查询路径,没有引入远程服务、逐条富化或 N+1 查询。权限复用现有 `managedAuths` 读取授权关系,保持与其他仓库工具一致的软删约束。Spring AI schema 测试验证工具名及关键参数可发现;查询测试验证组合筛选、分页截断、稳定排序、库存派生值与上海时区;权限测试覆盖总部、代理自有/代管公司以及缺少 agentId 的 fail-closed 分支。
|
||||
|
||||
## 约定与质量检查
|
||||
|
||||
- 库存工具及权限专项测试通过。
|
||||
- 项目全量 Java 测试此前通过,共 224 项。
|
||||
- `git diff --check` 通过。
|
||||
- 当前工作区还混有入库、出库工具的未提交改动,因此本次评审不以提交或 PR 边界作为变更范围依据;评审范围按上述库存文件和对应文档限定。
|
||||
|
||||
## 发现汇总与结论
|
||||
|
||||
未发现 Critical、High、Medium 或阻塞级问题。行级权限与老接口语义一致,分页与稳定排序正确,返回字段白名单符合设计,空值处理和可用库存计算正确,专项测试覆盖关键正反例。评审结论为通过,可进入测试阶段。
|
||||
@@ -0,0 +1,263 @@
|
||||
# 库存物品分页查询 MCP 工具详细设计
|
||||
|
||||
> 需求:`REQ-20260915-0011`
|
||||
> 工具:`warehouse.stock.list.query`
|
||||
> 对齐接口:`POST /warehouseStatis/listByPage`
|
||||
> 工具属性:`warehouse` / `read`
|
||||
> 数据源:m2 `warehouse_stock_manage`
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
老系统库存物品列表只能通过页面接口查询,Agent 无法在统一鉴权、工具作用域和审计体系下读取库存。
|
||||
本需求将列表查询封装为只读 MCP 工具,使 Agent 能按仓库、所属公司和物品属性分页检索库存,并保持
|
||||
老接口的代理商行级权限语义。
|
||||
|
||||
目标如下:
|
||||
|
||||
1. 暴露稳定、可发现的库存分页查询契约。
|
||||
2. 保持总部与代理商的数据可见范围和老接口一致。
|
||||
3. 返回库存业务白名单字段,并补充可用库存派生值。
|
||||
4. 限制单次查询规模,避免 Agent 发起无界读取。
|
||||
|
||||
## 2. 范围与非目标
|
||||
|
||||
### 2.1 本期范围
|
||||
|
||||
- 新增 `warehouse.stock.list.query` MCP 工具。
|
||||
- 新增 `WarehouseStockManage` 只读映射和 `WarehouseStockManageMapper`。
|
||||
- 在 `PermissionService` 新增库存专用行级权限过滤。
|
||||
- 支持组合筛选、分页、稳定排序、库存空值归零和可用库存计算。
|
||||
- 补充工具清单、详细设计和单元测试。
|
||||
|
||||
### 2.2 非目标
|
||||
|
||||
- 不实现老系统 `/warehouseStatis/statisticsQuery` 的全量聚合;如需要,另建
|
||||
`warehouse.stock.summary.query` 并单独评估权限和查询成本。
|
||||
- 不新增、扣减、预占或校正库存,不修改任何业务表。
|
||||
- 不返回库存变更流水、入出库单详情或 SN 级资产明细。
|
||||
- 不改变老系统接口行为,也不复用其 HTTP Controller。
|
||||
|
||||
## 3. 总体设计
|
||||
|
||||
调用链如下:
|
||||
|
||||
```text
|
||||
MCP Client
|
||||
-> Token scope 校验
|
||||
-> WarehouseStockTools.warehouseStockListQuery
|
||||
-> 规范化分页和筛选参数
|
||||
-> PermissionService.applyWarehouseStockRowFilter
|
||||
-> WarehouseStockManageMapper.selectCount/selectList
|
||||
-> 字段白名单映射及派生值计算
|
||||
-> total/page/rows/notice/records
|
||||
```
|
||||
|
||||
工具直接查询库存快照表,避免跨服务调用和 N+1 富化。仓库、公司、物品名称等均使用表内快照字段,
|
||||
因此结果反映库存记录最近一次维护时的业务快照。
|
||||
|
||||
## 4. 工具契约
|
||||
|
||||
### 4.1 输入参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 默认值 | 规则 | 数据库条件 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `warehouseIds` | `array<long>` | 否 | - | 空数组视为不筛选 | `warehouse_id IN (...)` |
|
||||
| `warehouseOwnerId` | `long` | 否 | - | 精确匹配仓库所属公司 | `warehouse_owner_id = ?` |
|
||||
| `materialName` | `string` | 否 | - | 去除首尾空格;空白不筛选 | `materiel_full_name LIKE` |
|
||||
| `materialNo` | `string` | 否 | - | 去除首尾空格;空白不筛选 | `material_no LIKE` |
|
||||
| `materialCategoryIds` | `array<long>` | 否 | - | 空数组视为不筛选 | `materiel_index_id IN (...)` |
|
||||
| `specifications` | `string` | 否 | - | 去除首尾空格;空白不筛选 | `specifications LIKE` |
|
||||
| `page` | `integer` | 否 | `1` | 小于 1 时归一为 1 | SQL offset |
|
||||
| `rows` | `integer` | 否 | `20` | 小于 1 时归一为 20;大于 100 截断为 100 | SQL limit |
|
||||
|
||||
所有业务筛选条件为 AND 关系;行级权限条件作为一个括号分组追加。查询始终包含
|
||||
`soft_delete = 0`。
|
||||
|
||||
### 4.2 返回结构
|
||||
|
||||
顶层字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `total` | `long` | 权限及筛选条件命中的总记录数 |
|
||||
| `page` | `integer` | 归一化后的当前页码 |
|
||||
| `rows` | `integer` | 实际采用的每页条数 |
|
||||
| `notice` | `string` | 仅在 `rows > 100` 时返回截断提示 |
|
||||
| `records` | `array<object>` | 当前页库存记录;无数据时为空数组 |
|
||||
|
||||
`records` 字段白名单:
|
||||
|
||||
| 字段 | 来源/计算 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `id` | `id` | 库存记录 ID |
|
||||
| `warehouseId` / `warehouseNo` / `warehouseName` | 同名仓库快照列 | 仓库信息 |
|
||||
| `agentId` / `agentName` | 管理公司快照列 | 库存管理公司 |
|
||||
| `warehouseOwnerId` / `warehouseOwnerName` | 所属公司快照列 | 仓库所属公司 |
|
||||
| `materialDefinitionId` | `materiel_definition_id` | 物料定义 ID |
|
||||
| `materialNo` / `materialName` | `material_no` / `materiel_full_name` | 物品编号和名称 |
|
||||
| `materialCategoryId` / `materialCategory` | `materiel_index_id` / `materiel_sort` | 分类 ID 和分类快照 |
|
||||
| `specifications` / `snCode` | 同名列 | 规格和库存表 SN 快照 |
|
||||
| `currentInventory` | `COALESCE(current_inventory, 0)` | 当前库存 |
|
||||
| `outOfStock` | `COALESCE(out_of_stock, 0)` | 出库中/预占库存 |
|
||||
| `availableInventory` | `currentInventory - outOfStock` | 可用库存;允许如实返回负数以暴露异常 |
|
||||
| `batteryInventory` | `COALESCE(battery_inventory, 0)` | 电池费用化库存 |
|
||||
| `updatedAt` | `modify_time` | 上海时区 `yyyy-MM-dd HH:mm:ss`;无值返回 null |
|
||||
|
||||
不返回 `create_id`、`create_time`、`modify_id`、`soft_delete` 等内部维护字段。
|
||||
|
||||
### 4.3 排序与分页
|
||||
|
||||
- 固定按 `current_inventory DESC, id DESC` 排序。
|
||||
- `id DESC` 作为同库存量下的稳定次序,减少翻页时顺序不确定。
|
||||
- offset 使用 `(page - 1) * rows`,计算过程使用 long,避免 int 乘法溢出。
|
||||
- count 与列表复用同一组过滤条件;排序和 limit 仅追加到列表查询。
|
||||
|
||||
返回示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 1,
|
||||
"page": 1,
|
||||
"rows": 20,
|
||||
"records": [
|
||||
{
|
||||
"id": 901,
|
||||
"warehouseId": 8,
|
||||
"warehouseNo": "WH-008",
|
||||
"warehouseName": "一号仓",
|
||||
"agentId": 10,
|
||||
"agentName": "管理公司A",
|
||||
"warehouseOwnerId": 10,
|
||||
"warehouseOwnerName": "所属公司A",
|
||||
"materialDefinitionId": 31,
|
||||
"materialNo": "MAT-031",
|
||||
"materialName": "擦手纸",
|
||||
"materialCategoryId": 3,
|
||||
"materialCategory": "纸品",
|
||||
"specifications": "A型",
|
||||
"snCode": null,
|
||||
"currentInventory": 20,
|
||||
"outOfStock": 4,
|
||||
"availableInventory": 16,
|
||||
"batteryInventory": 0,
|
||||
"updatedAt": "2026-09-15 17:30:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 行级权限设计
|
||||
|
||||
权限对齐老系统 `WrapperUtils.getWarehouseAuth`,但在 Agent 服务中独立实现,避免依赖老系统静态工具。
|
||||
|
||||
| 当前身份 | 可见范围 | SQL 行过滤 |
|
||||
| --- | --- | --- |
|
||||
| 总部(`isagent=0`) | 全部未删除库存 | 不追加公司过滤 |
|
||||
| 代理(`isagent=1`) | 本公司,以及当前用户在 `warehouse_agent_auth` 中获授权代管的公司 | `agent_id IN (ids) OR warehouse_owner_id IN (ids)` |
|
||||
| 代理但 `user.agent_id` 为空 | 无 | fail closed,抛出拒绝访问异常 |
|
||||
|
||||
授权公司集合计算方式:
|
||||
|
||||
```text
|
||||
visibleAgentIds = { currentUser.agentId }
|
||||
visibleAgentIds += SELECT manager_agent_id
|
||||
FROM warehouse_agent_auth
|
||||
WHERE user_id = currentUser.id AND soft_delete = 0
|
||||
```
|
||||
|
||||
最终条件必须保留括号:
|
||||
|
||||
```sql
|
||||
AND (agent_id IN (:visibleAgentIds)
|
||||
OR warehouse_owner_id IN (:visibleAgentIds))
|
||||
```
|
||||
|
||||
直属下级代理不会因层级关系自动加入;只有本公司和显式 `warehouse_agent_auth` 授权公司可见。这一点
|
||||
与老接口实际实现一致。MCP 层还会先校验 Token 是否绑定该工具 scope,行级权限不能替代 scope 校验。
|
||||
|
||||
## 6. 查询与数据处理
|
||||
|
||||
逻辑伪代码:
|
||||
|
||||
```text
|
||||
pageNo = page is null or page < 1 ? 1 : page
|
||||
pageSize = rows is null or rows < 1 ? 20 : min(rows, 100)
|
||||
|
||||
conditions = [soft_delete = 0]
|
||||
append non-empty business filters
|
||||
append warehouse stock row-level permission
|
||||
|
||||
total = count(conditions)
|
||||
records = select(conditions)
|
||||
order by current_inventory desc, id desc
|
||||
limit offset, pageSize
|
||||
|
||||
map records to whitelist fields
|
||||
normalize nullable inventory values to 0
|
||||
calculate availableInventory
|
||||
```
|
||||
|
||||
字符串仅在 `StringUtils.hasText` 时参与查询,并使用 MyBatis-Plus 参数绑定生成 LIKE 条件;ID 集合为空时
|
||||
不生成 `IN ()`。分页 limit 只使用规范化后的整数计算结果。
|
||||
|
||||
## 7. 异常、边界与一致性
|
||||
|
||||
- 未传筛选条件:在权限范围内正常分页,不执行全量无分页返回。
|
||||
- 页码超出末页:`total` 保持真实值,`records=[]`。
|
||||
- `rows > 100`:成功返回,实际 `rows=100`,同时返回 `notice`。
|
||||
- 库存列为 null:按 0 输出;不回写数据库。
|
||||
- `availableInventory < 0`:不钳制,保留原始业务异常信号。
|
||||
- 权限基础数据缺失:代理用户缺少公司 ID 时拒绝查询,不能降级为总部权限。
|
||||
- 查询期间库存发生变化:count 和 list 可能反映不同瞬时快照;工具定位为运营查询,不提供事务级快照保证。
|
||||
|
||||
## 8. 性能与安全
|
||||
|
||||
- 单页上限 100,避免大结果占用数据库、MCP 序列化和模型上下文。
|
||||
- 查询仅访问单表,无远程调用、循环查询或逐条富化。
|
||||
- 所有筛选值由查询构造器参数化;仅 limit 使用归一化后的数值拼接。
|
||||
- 固定过滤软删数据,输出采用显式白名单。
|
||||
- 典型过滤列为 `warehouse_id`、`warehouse_owner_id`、`materiel_index_id`;现有生产数据量较小时无需
|
||||
本需求新增索引。若慢查询监控显示退化,再依据执行计划单独提交索引变更。
|
||||
|
||||
## 9. 代码改动
|
||||
|
||||
| 模块 | 文件/组件 | 职责 |
|
||||
| --- | --- | --- |
|
||||
| entity | `WarehouseStockManage` | m2 库存表字段映射 |
|
||||
| mapper | `WarehouseStockManageMapper` | MyBatis-Plus 单表查询 |
|
||||
| tool | `WarehouseStockTools` | MCP 契约、参数处理、分页及返回映射 |
|
||||
| permission | `PermissionService.applyWarehouseStockRowFilter` | 库存专用行级权限 |
|
||||
| test | `WarehouseStockToolsTest` | 筛选、分页、排序、映射和 schema |
|
||||
| test | `PermissionServiceWarehouseStockRowFilterTest` | 总部、代理授权和 fail-closed |
|
||||
| docs | README、工具参考、本文 | 工具发现和维护说明 |
|
||||
|
||||
## 10. 测试与验收
|
||||
|
||||
### 10.1 自动化测试
|
||||
|
||||
1. 组合筛选生成仓库、所属公司、名称、编号、分类和规格条件。
|
||||
2. `page < 1` 归一为 1,`rows > 100` 截断并产生 notice。
|
||||
3. 固定排序包含 `current_inventory DESC, id DESC`,limit 正确。
|
||||
4. 可用库存计算正确,电池库存 null 输出 0,时间按上海时区格式化。
|
||||
5. MCP schema 发布工具名及全部输入参数。
|
||||
6. 总部不追加权限过滤,也不查询代管授权。
|
||||
7. 代理权限覆盖本公司和代管公司,并同时作用于管理公司、所属公司两列。
|
||||
8. 代理用户缺少 `agentId` 时 fail closed。
|
||||
|
||||
### 10.2 验收标准
|
||||
|
||||
- `tools/list` 可发现 `warehouse.stock.list.query`,分类为 warehouse、类型为 read。
|
||||
- 有 scope 的总部 Token 可分页查询全部未删除库存,并正确使用所有筛选条件。
|
||||
- 代理 Token 不能读取权限集合之外公司的库存;授权代管后可读取对应记录。
|
||||
- 返回字段、空值处理、可用库存公式、排序和分页符合第 4 节契约。
|
||||
- `rows=101` 时按 100 返回并带提示;空结果返回空数组。
|
||||
- 工具调用不产生库存及其他业务数据写入。
|
||||
- 相关单测和项目全量 Java 测试通过。
|
||||
|
||||
## 11. 发布与回滚
|
||||
|
||||
发布后由工具同步机制注册元数据;新工具默认未绑定 Token scope,管理员需显式授权后才可调用。
|
||||
上线验证依次检查 `tools/list`、总部样例查询、代理权限正反例和分页上限。
|
||||
|
||||
如需回滚,先解除 Token 与工具 scope 的绑定以立即停止外部调用,再回滚服务版本。由于本需求无 DDL、
|
||||
无数据迁移且全程只读,不需要业务数据回滚。
|
||||
@@ -0,0 +1,29 @@
|
||||
# REQ-20260915-0011 测试报告
|
||||
|
||||
> 工具:`warehouse.stock.list.query`
|
||||
> 测试任务:`21777`
|
||||
> 日期:2026-09-15
|
||||
> 结论:通过
|
||||
|
||||
## 自动化测试结果
|
||||
|
||||
- 专项测试:`WarehouseStockToolsTest`、`PermissionServiceWarehouseStockRowFilterTest`,共 5 项,
|
||||
0 失败、0 错误。
|
||||
- 全量测试:在业务时区 `TZ=Asia/Shanghai` 下执行 `mvn test`,共 224 项,0 失败、0 错误。
|
||||
- 静态检查:`git diff --check` 通过。
|
||||
|
||||
## 覆盖范围
|
||||
|
||||
- MCP schema 与工具发现契约。
|
||||
- 组合筛选、分页归一、100 条上限及稳定排序。
|
||||
- 字段白名单、库存空值归零及 `availableInventory` 计算。
|
||||
- 总部权限、代理本公司与代管授权、缺少 `agentId` 时 fail closed。
|
||||
|
||||
## 环境说明
|
||||
|
||||
宿主时区为 `Australia/Adelaide` 时,项目中 3 个既有上海时区断言失败;使用项目业务时区
|
||||
`Asia/Shanghai` 后全量通过。这些断言来自同步实体和合同工具测试,与本库存需求无关。
|
||||
|
||||
## 验收结论
|
||||
|
||||
实现满足 `REQ-20260915-0011` 的自动化测试和静态检查验收标准,可以进入预发布验证。
|
||||
@@ -24,8 +24,10 @@ const tokens = ref([])
|
||||
const openClients = ref([])
|
||||
const scopes = ref([])
|
||||
const tools = ref([])
|
||||
const allTools = ref([])
|
||||
const audits = ref([])
|
||||
const toolFilter = reactive({ category: '', kind: '', keyword: '' })
|
||||
const toolPage = reactive({ page: 1, pageSize: 20, total: 0 })
|
||||
const auditFilter = reactive({ person: '', tokenPrefix: '', tool: '', result: '', client: '', from: '', to: '' })
|
||||
const tokenDialog = ref(false)
|
||||
const tokenForm = reactive({ id: null, memo: '', scopeIds: [], scopeId: null, expireTime: null })
|
||||
@@ -38,14 +40,38 @@ const scopeForm = reactive({ id: null, name: '', code: '', memo: '' })
|
||||
const scopeToolsDialog = ref(false)
|
||||
const activeScope = ref(null)
|
||||
const selectedToolIds = ref([])
|
||||
const scopeToolsTab = ref('')
|
||||
const scopeToolsKeyword = ref('')
|
||||
const scopeToolsOnlySelected = ref(false)
|
||||
|
||||
const title = computed(() => menus.find(item => item.id === current.value)?.label || '')
|
||||
const categories = computed(() => [...new Set(tools.value.map(tool => tool.category))])
|
||||
const groupedTools = computed(() => tools.value.reduce((groups, tool) => {
|
||||
const categories = computed(() => [...new Set(allTools.value.map(tool => tool.category))])
|
||||
const groupedTools = computed(() => allTools.value.reduce((groups, tool) => {
|
||||
const category = tool.category || '其他'
|
||||
;(groups[category] ||= []).push(tool)
|
||||
return groups
|
||||
}, {}))
|
||||
const scopeToolTabs = computed(() => Object.entries(groupedTools.value).map(([category, items]) => ({
|
||||
category,
|
||||
total: items.length,
|
||||
selected: items.filter(tool => selectedToolIds.value.includes(tool.id)).length,
|
||||
})))
|
||||
const scopeToolsVisible = computed(() => {
|
||||
const keyword = scopeToolsKeyword.value.trim().toLowerCase()
|
||||
const items = scopeToolsTab.value ? (groupedTools.value[scopeToolsTab.value] || []) : allTools.value
|
||||
return items.filter(tool => {
|
||||
if (scopeToolsOnlySelected.value && !selectedToolIds.value.includes(tool.id)) return false
|
||||
if (!keyword) return true
|
||||
return tool.name.toLowerCase().includes(keyword) || (tool.description || '').toLowerCase().includes(keyword)
|
||||
})
|
||||
})
|
||||
const scopeToolsSummary = computed(() => {
|
||||
const detail = scopeToolTabs.value.filter(tab => tab.selected).map(tab => `${tab.category} ×${tab.selected}`).join(' · ')
|
||||
return { selected: selectedToolIds.value.length, total: allTools.value.length, detail }
|
||||
})
|
||||
const scopeToolsAllVisibleSelected = computed(() => scopeToolsVisible.value.length > 0
|
||||
&& scopeToolsVisible.value.every(tool => selectedToolIds.value.includes(tool.id)))
|
||||
const scopeToolsSomeVisibleSelected = computed(() => scopeToolsVisible.value.some(tool => selectedToolIds.value.includes(tool.id)))
|
||||
const issuedKeyCurl = computed(() => {
|
||||
if (!issuedOpenKey.value) return ''
|
||||
const payload = JSON.stringify({
|
||||
@@ -96,9 +122,9 @@ async function switchMenu(id) {
|
||||
current.value = id
|
||||
if (id === 'tokens') await loadUsers()
|
||||
if (id === 'clients') await loadOpenClients()
|
||||
if (id === 'tools') await loadTools()
|
||||
if (id === 'tools') await Promise.all([loadTools(), loadAllTools()])
|
||||
if (id === 'scopes') await loadScopes()
|
||||
if (id === 'audit') await Promise.all([loadAudit(), api('/tools').then(data => { tools.value = data })])
|
||||
if (id === 'audit') await Promise.all([loadAudit(), loadAllTools()])
|
||||
}
|
||||
|
||||
async function loadUsers(resetPage = false) {
|
||||
@@ -214,8 +240,21 @@ async function rotateClient(client) {
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function loadTools() {
|
||||
tools.value = await api(`/tools${qs(toolFilter)}`)
|
||||
async function loadTools(resetPage = false) {
|
||||
if (resetPage) toolPage.page = 1
|
||||
const data = await api(`/tools${qs({ ...toolFilter, page: toolPage.page, pageSize: toolPage.pageSize })}`)
|
||||
tools.value = data.records
|
||||
toolPage.total = data.total
|
||||
}
|
||||
|
||||
async function loadAllTools() {
|
||||
const data = await api('/tools?pageSize=0')
|
||||
allTools.value = data.records
|
||||
}
|
||||
|
||||
async function changeToolPage(page) {
|
||||
toolPage.page = page
|
||||
await run(loadTools)
|
||||
}
|
||||
|
||||
async function setToolStatus(tool) {
|
||||
@@ -261,10 +300,23 @@ async function deleteScope(scope) {
|
||||
|
||||
async function openScopeTools(scope) {
|
||||
activeScope.value = scope
|
||||
;[selectedToolIds.value, tools.value] = await Promise.all([api(`/scopes/${scope.id}/tools`), api('/tools')])
|
||||
scopeToolsTab.value = ''; scopeToolsKeyword.value = ''; scopeToolsOnlySelected.value = false
|
||||
;[selectedToolIds.value] = await Promise.all([api(`/scopes/${scope.id}/tools`), loadAllTools()])
|
||||
scopeToolsDialog.value = true
|
||||
}
|
||||
|
||||
function toggleScopeTool(id, checked) {
|
||||
const set = new Set(selectedToolIds.value)
|
||||
if (checked) set.add(id); else set.delete(id)
|
||||
selectedToolIds.value = [...set]
|
||||
}
|
||||
|
||||
function toggleScopeToolsVisible(checked) {
|
||||
const set = new Set(selectedToolIds.value)
|
||||
scopeToolsVisible.value.forEach(tool => { if (checked) set.add(tool.id); else set.delete(tool.id) })
|
||||
selectedToolIds.value = [...set]
|
||||
}
|
||||
|
||||
async function saveScopeTools() {
|
||||
await run(async () => {
|
||||
await api(`/scopes/${activeScope.value.id}/tools`, { method: 'PUT', body: JSON.stringify({ toolIds: selectedToolIds.value }) })
|
||||
@@ -411,8 +463,8 @@ onMounted(async () => {
|
||||
<div class="toolbar">
|
||||
<el-select v-model="toolFilter.category" clearable placeholder="全部分类" aria-label="工具分类" data-testid="admin-tool-category"><el-option v-for="item in categories" :key="item" :label="item" :value="item" /></el-select>
|
||||
<el-select v-model="toolFilter.kind" clearable placeholder="全部类型" aria-label="工具类型" data-testid="admin-tool-kind"><el-option label="读" value="read" /><el-option label="写" value="write" /></el-select>
|
||||
<el-input v-model="toolFilter.keyword" clearable placeholder="工具名 / 描述" aria-label="工具关键词" data-testid="admin-tool-keyword" @keyup.enter="run(loadTools)" />
|
||||
<el-button type="primary" data-testid="admin-tool-search" @click="run(loadTools)">查询</el-button>
|
||||
<el-input v-model="toolFilter.keyword" clearable placeholder="工具名 / 描述" aria-label="工具关键词" data-testid="admin-tool-keyword" @keyup.enter="run(() => loadTools(true))" />
|
||||
<el-button type="primary" data-testid="admin-tool-search" @click="run(() => loadTools(true))">查询</el-button>
|
||||
</div>
|
||||
</article>
|
||||
<article class="card">
|
||||
@@ -427,6 +479,9 @@ onMounted(async () => {
|
||||
<el-table-column label="同步时间" min-width="145"><template #default="{ row }">{{ date(row.modifyTime) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="90"><template #default="{ row }"><el-button link :type="row.status === 1 ? 'danger' : 'primary'" :data-testid="`admin-tool-status-${row.id}`" @click="setToolStatus(row)">{{ row.status === 1 ? '停用' : '启用' }}</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<el-pagination class="user-pager" layout="prev, pager, next, total" :total="toolPage.total"
|
||||
:page-size="toolPage.pageSize" :current-page="toolPage.page" small background
|
||||
data-testid="admin-tool-pager" @current-change="changeToolPage" />
|
||||
<p class="hint">“停用”是全局紧急开关,生效后任何 Token 都无法调用该工具。</p>
|
||||
</article>
|
||||
</template>
|
||||
@@ -456,7 +511,7 @@ onMounted(async () => {
|
||||
<div class="toolbar audit-toolbar">
|
||||
<el-input v-model="auditFilter.person" clearable placeholder="姓名 / 手机号" aria-label="人员" data-testid="admin-audit-person" />
|
||||
<el-input v-model="auditFilter.tokenPrefix" clearable placeholder="Token 前缀" aria-label="Token 前缀" data-testid="admin-audit-token" />
|
||||
<el-select v-model="auditFilter.tool" clearable filterable placeholder="全部工具" aria-label="工具名" data-testid="admin-audit-tool"><el-option v-for="item in tools" :key="item.id" :label="item.name" :value="item.name" /></el-select>
|
||||
<el-select v-model="auditFilter.tool" clearable filterable placeholder="全部工具" aria-label="工具名" data-testid="admin-audit-tool"><el-option v-for="item in allTools" :key="item.id" :label="item.name" :value="item.name" /></el-select>
|
||||
<el-select v-model="auditFilter.result" clearable placeholder="全部结果" aria-label="调用结果" data-testid="admin-audit-result"><el-option label="成功" value="success" /><el-option label="拒绝" value="denied" /><el-option label="错误" value="error" /></el-select>
|
||||
<el-select v-model="auditFilter.client" clearable placeholder="全部客户端" aria-label="客户端" data-testid="admin-audit-client"><el-option label="Kimi" value="mcp-kimi" /><el-option label="Claude" value="mcp-claude" /><el-option label="CLI" value="cli" /><el-option label="未知" value="unknown" /></el-select>
|
||||
<el-date-picker v-model="auditFilter.from" type="date" value-format="YYYY-MM-DD" placeholder="开始日期" aria-label="开始日期" data-testid="admin-audit-from" />
|
||||
@@ -530,11 +585,42 @@ onMounted(async () => {
|
||||
<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>
|
||||
</el-checkbox-group>
|
||||
<template #footer><el-button data-testid="admin-scope-tools-cancel" @click="scopeToolsDialog = false">取消</el-button><el-button type="primary" data-testid="admin-scope-tools-save" @click="saveScopeTools">保存配置</el-button></template>
|
||||
<el-dialog v-model="scopeToolsDialog" :title="`配置工具 · ${activeScope?.name || ''}`" width="780px" destroy-on-close>
|
||||
<div class="scope-tools-toolbar">
|
||||
<el-input v-model="scopeToolsKeyword" clearable placeholder="搜索工具名 / 描述" aria-label="搜索工具" data-testid="admin-scope-tools-search" />
|
||||
<el-checkbox v-model="scopeToolsOnlySelected" data-testid="admin-scope-tools-only-selected">仅看已选</el-checkbox>
|
||||
</div>
|
||||
<el-tabs v-model="scopeToolsTab" class="scope-tools-tabs" data-testid="admin-scope-tools-tabs">
|
||||
<el-tab-pane name="">
|
||||
<template #label>全部({{ allTools.length }})<span v-if="scopeToolsSummary.selected" class="tab-badge">{{ scopeToolsSummary.selected }}</span></template>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane v-for="tab in scopeToolTabs" :key="tab.category" :name="tab.category">
|
||||
<template #label>{{ tab.category }}({{ tab.total }})<span v-if="tab.selected" class="tab-badge">{{ tab.selected }}</span></template>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<el-table :data="scopeToolsVisible" height="360" empty-text="没有匹配的工具" data-testid="admin-scope-tool-options"
|
||||
:row-class-name="({ row }) => selectedToolIds.includes(row.id) ? 'row-selected' : ''">
|
||||
<el-table-column width="48" align="center">
|
||||
<template #header>
|
||||
<el-checkbox :model-value="scopeToolsAllVisibleSelected" :indeterminate="scopeToolsSomeVisibleSelected && !scopeToolsAllVisibleSelected"
|
||||
aria-label="全选当前列表" data-testid="admin-scope-tool-select-all" @change="toggleScopeToolsVisible" />
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<el-checkbox :model-value="selectedToolIds.includes(row.id)" :aria-label="row.name"
|
||||
:data-testid="`admin-scope-tool-${row.id}`" @change="checked => toggleScopeTool(row.id, checked)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="工具名" min-width="200"><template #default="{ row }"><code>{{ row.name }}</code></template></el-table-column>
|
||||
<el-table-column prop="description" label="描述" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column label="类型" width="70"><template #default="{ row }"><el-tag :type="row.kind === 'write' ? 'danger' : 'success'" size="small">{{ row.kind === 'write' ? '写' : '读' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="状态" width="80"><template #default="{ row }"><el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">{{ row.status === 1 ? '启用' : '已停用' }}</el-tag></template></el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<div class="scope-tools-footer">
|
||||
<span class="scope-tools-summary" data-testid="admin-scope-tools-summary">已选 {{ scopeToolsSummary.selected }}/{{ scopeToolsSummary.total }} 个工具<template v-if="scopeToolsSummary.detail"> {{ scopeToolsSummary.detail }}</template></span>
|
||||
<span><el-button data-testid="admin-scope-tools-cancel" @click="scopeToolsDialog = false">取消</el-button><el-button type="primary" data-testid="admin-scope-tools-save" @click="saveScopeTools">保存配置</el-button></span>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog :model-value="!!issuedTokens.length" title="Token 签发成功" width="600px" :close-on-click-modal="false" @close="issuedTokens = []">
|
||||
|
||||
@@ -52,13 +52,14 @@ code { font-family: Menlo, Consolas, monospace; font-size: 12px; }
|
||||
.audit-toolbar .el-input { width: 150px; }
|
||||
.audit-toolbar .el-select { width: 140px; }
|
||||
.audit-toolbar .el-date-editor { width: 150px; }
|
||||
.tool-groups { display: block; max-height: 520px; overflow: auto; }
|
||||
.tool-group { margin-bottom: 12px; border: 1px solid #ebeef5; border-radius: 4px; }
|
||||
.tool-group h3 { margin: 0; padding: 9px 12px; font-size: 14px; background: #fafafa; border-bottom: 1px solid #ebeef5; }
|
||||
.tool-group .el-checkbox { width: 100%; height: auto; margin: 0; padding: 9px 12px; align-items: flex-start; }
|
||||
.tool-group .el-checkbox + .el-checkbox { border-top: 1px solid #f2f3f5; }
|
||||
.tool-group .el-checkbox__label { display: flex; flex-direction: column; white-space: normal; }
|
||||
.tool-group small { margin-top: 3px; color: #909399; }
|
||||
.scope-tools-toolbar { display: flex; gap: 12px; align-items: center; }
|
||||
.scope-tools-toolbar .el-input { max-width: 260px; }
|
||||
.scope-tools-tabs { margin-top: 4px; }
|
||||
.scope-tools-tabs .el-tabs__header { margin-bottom: 8px; }
|
||||
.tab-badge { display: inline-block; margin-left: 6px; padding: 0 6px; font-size: 12px; line-height: 16px; border-radius: 8px; background: #f0f9eb; color: #67c23a; vertical-align: 2px; }
|
||||
.scope-tools-footer { display: flex; align-items: center; justify-content: space-between; width: 100%; }
|
||||
.scope-tools-summary { color: #606266; font-size: 13px; text-align: left; }
|
||||
.el-table .row-selected { background: #ecf5ff; }
|
||||
.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; }
|
||||
|
||||
@@ -19,6 +19,7 @@ test('prototype interactions expose stable test ids', async () => {
|
||||
'admin-table-clients',
|
||||
'admin-issued-client-key',
|
||||
'admin-table-tools',
|
||||
'admin-tool-pager',
|
||||
'admin-btn-new-scope',
|
||||
'admin-table-scopes',
|
||||
'admin-table-audit',
|
||||
|
||||
@@ -162,16 +162,24 @@ public class AdminConsoleController {
|
||||
return tokenService.rotate(id, adminId(session));
|
||||
}
|
||||
|
||||
/** pageSize=0 表示不分页返回全量,供作用域配置、审计筛选等需要完整工具列表的场景使用。 */
|
||||
@GetMapping("/tools")
|
||||
public List<ToolView> tools(@RequestParam(defaultValue = "") String category,
|
||||
@RequestParam(defaultValue = "") String kind,
|
||||
@RequestParam(defaultValue = "") String keyword) {
|
||||
public ToolPageView tools(@RequestParam(defaultValue = "") String category,
|
||||
@RequestParam(defaultValue = "") String kind,
|
||||
@RequestParam(defaultValue = "") String keyword,
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "20") int pageSize) {
|
||||
int pageNo = Math.max(page, 1);
|
||||
int size = pageSize == 0 ? 0 : pageSize < 1 ? 20 : Math.min(pageSize, 100);
|
||||
LambdaQueryWrapper<Tool> query = new LambdaQueryWrapper<Tool>().eq(Tool::getSoftDelete, 0);
|
||||
if (!category.isBlank()) query.eq(Tool::getCategory, category);
|
||||
if (!kind.isBlank()) query.eq(Tool::getKind, kind);
|
||||
if (!keyword.isBlank()) query.and(w -> w.like(Tool::getName, keyword.trim())
|
||||
.or().like(Tool::getDescription, keyword.trim()));
|
||||
List<Tool> tools = toolMapper.selectList(query.orderByAsc(Tool::getCategory, Tool::getName));
|
||||
long total = toolMapper.selectCount(query);
|
||||
LambdaQueryWrapper<Tool> paged = query.orderByAsc(Tool::getCategory, Tool::getName);
|
||||
if (size > 0) paged.last("LIMIT " + (long) (pageNo - 1) * size + "," + size);
|
||||
List<Tool> tools = toolMapper.selectList(paged);
|
||||
Map<Long, AgentScope> scopes = scopeMapper.selectList(new LambdaQueryWrapper<AgentScope>()
|
||||
.eq(AgentScope::getSoftDelete, 0)).stream()
|
||||
.collect(Collectors.toMap(AgentScope::getId, Function.identity()));
|
||||
@@ -180,9 +188,10 @@ public class AdminConsoleController {
|
||||
.filter(r -> scopes.containsKey(r.getScopeId()))
|
||||
.collect(Collectors.groupingBy(ScopeTool::getToolId,
|
||||
Collectors.mapping(r -> scopes.get(r.getScopeId()).getName(), Collectors.toList())));
|
||||
return tools.stream().map(tool -> new ToolView(tool.getId(), tool.getName(), tool.getCategory(),
|
||||
tool.getDescription(), tool.getKind(), tool.getStatus(), tool.getModifyTime(),
|
||||
List<ToolView> records = tools.stream().map(tool -> new ToolView(tool.getId(), tool.getName(),
|
||||
tool.getCategory(), tool.getDescription(), tool.getKind(), tool.getStatus(), tool.getModifyTime(),
|
||||
scopeNames.getOrDefault(tool.getId(), List.of()))).toList();
|
||||
return new ToolPageView(total, size > 0 ? pageNo : 1, size, records);
|
||||
}
|
||||
|
||||
@PatchMapping("/tools/{id}/status")
|
||||
@@ -397,6 +406,7 @@ public class AdminConsoleController {
|
||||
public record StatusRequest(byte status) {}
|
||||
public record ToolView(Long id, String name, String category, String description, String kind,
|
||||
Byte status, Date modifyTime, List<String> scopeNames) {}
|
||||
public record ToolPageView(long total, int page, int pageSize, List<ToolView> records) {}
|
||||
public record ScopeRequest(String name, String code, String memo) {}
|
||||
public record ScopeView(Long id, String name, String code, String memo, Byte status,
|
||||
Date modifyTime, long toolCount) {}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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_stock_manage")
|
||||
public class WarehouseStockManage implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@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 warehouseName;
|
||||
private Long warehouseId;
|
||||
private String warehouseNo;
|
||||
private Integer currentInventory;
|
||||
private Integer batteryInventory;
|
||||
private Long agentId;
|
||||
private String agentName;
|
||||
private Long materielDefinitionId;
|
||||
private String materialNo;
|
||||
private String materielFullName;
|
||||
private String materielSort;
|
||||
private Long materielIndexId;
|
||||
private String specifications;
|
||||
private Integer outOfStock;
|
||||
private String snCode;
|
||||
private Long warehouseOwnerId;
|
||||
private String warehouseOwnerName;
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package me.iiv.agent.mapper.m2;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import me.iiv.agent.entity.m2.WarehouseStockManage;
|
||||
|
||||
/** 库存管理 Mapper。 */
|
||||
public interface WarehouseStockManageMapper extends BaseMapper<WarehouseStockManage> {
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package me.iiv.agent.mcp.tool;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import me.iiv.agent.entity.m0.User;
|
||||
import me.iiv.agent.entity.m2.WarehouseStockManage;
|
||||
import me.iiv.agent.mapper.m2.WarehouseStockManageMapper;
|
||||
import me.iiv.agent.mcp.ToolObject;
|
||||
import me.iiv.agent.mcp.XqTool;
|
||||
import me.iiv.agent.permission.PermissionService;
|
||||
import me.iiv.agent.security.AuthContextHolder;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** 库存物品查询工具,对齐老系统 /warehouseStatis/listByPage。 */
|
||||
@Component
|
||||
public class WarehouseStockTools implements ToolObject {
|
||||
|
||||
private static final int DEFAULT_ROWS = 20;
|
||||
private static final int MAX_ROWS = 100;
|
||||
private static final DateTimeFormatter TIME_FORMATTER =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.of("Asia/Shanghai"));
|
||||
|
||||
final WarehouseStockManageMapper warehouseStockManageMapper;
|
||||
final PermissionService permissionService;
|
||||
|
||||
public WarehouseStockTools(WarehouseStockManageMapper warehouseStockManageMapper,
|
||||
PermissionService permissionService) {
|
||||
this.warehouseStockManageMapper = warehouseStockManageMapper;
|
||||
this.permissionService = permissionService;
|
||||
}
|
||||
|
||||
@XqTool(category = "warehouse", kind = "read")
|
||||
@Tool(name = "warehouse.stock.list.query", description = "分页查询库存物品,对应老系统 /warehouseStatis/listByPage。"
|
||||
+ "总部可看全部;代理仅可看本公司或 warehouse_agent_auth 授权代管公司作为管理公司/所属公司的库存。"
|
||||
+ "支持仓库、所属公司、物品名称/编号/分类/规格筛选;page 默认1,rows 默认20、上限100。"
|
||||
+ "固定按 current_inventory DESC、id DESC 排序。")
|
||||
public Map<String, Object> warehouseStockListQuery(
|
||||
@ToolParam(required = false, description = "仓库 id 集合") List<Long> warehouseIds,
|
||||
@ToolParam(required = false, description = "仓库所属公司 id") Long warehouseOwnerId,
|
||||
@ToolParam(required = false, description = "物品名称,模糊匹配") String materialName,
|
||||
@ToolParam(required = false, description = "物品编号,模糊匹配") String materialNo,
|
||||
@ToolParam(required = false, description = "物品分类 id 集合") List<Long> materialCategoryIds,
|
||||
@ToolParam(required = false, description = "规格型号,模糊匹配") String specifications,
|
||||
@ToolParam(required = false, description = "页码,从1开始,默认1") Integer page,
|
||||
@ToolParam(required = false, description = "每页条数,默认20、上限100") Integer rows) {
|
||||
int pageNo = page == null || page < 1 ? 1 : page;
|
||||
boolean truncated = rows != null && rows > MAX_ROWS;
|
||||
int pageSize = rows == null || rows < 1 ? DEFAULT_ROWS : Math.min(rows, MAX_ROWS);
|
||||
|
||||
LambdaQueryWrapper<WarehouseStockManage> wrapper = new LambdaQueryWrapper<WarehouseStockManage>()
|
||||
.eq(WarehouseStockManage::getSoftDelete, 0)
|
||||
.in(warehouseIds != null && !warehouseIds.isEmpty(), WarehouseStockManage::getWarehouseId, warehouseIds)
|
||||
.eq(warehouseOwnerId != null, WarehouseStockManage::getWarehouseOwnerId, warehouseOwnerId)
|
||||
.in(materialCategoryIds != null && !materialCategoryIds.isEmpty(),
|
||||
WarehouseStockManage::getMaterielIndexId, materialCategoryIds);
|
||||
if (StringUtils.hasText(materialName)) {
|
||||
wrapper.like(WarehouseStockManage::getMaterielFullName, materialName.trim());
|
||||
}
|
||||
if (StringUtils.hasText(materialNo)) {
|
||||
wrapper.like(WarehouseStockManage::getMaterialNo, materialNo.trim());
|
||||
}
|
||||
if (StringUtils.hasText(specifications)) {
|
||||
wrapper.like(WarehouseStockManage::getSpecifications, specifications.trim());
|
||||
}
|
||||
|
||||
User user = AuthContextHolder.get().getUser();
|
||||
permissionService.applyWarehouseStockRowFilter(wrapper, user);
|
||||
Long total = warehouseStockManageMapper.selectCount(wrapper);
|
||||
wrapper.orderByDesc(WarehouseStockManage::getCurrentInventory)
|
||||
.orderByDesc(WarehouseStockManage::getId)
|
||||
.last("LIMIT " + (long) (pageNo - 1) * pageSize + "," + pageSize);
|
||||
List<Map<String, Object>> records = warehouseStockManageMapper.selectList(wrapper).stream()
|
||||
.map(this::record).collect(Collectors.toList());
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("total", total);
|
||||
result.put("page", pageNo);
|
||||
result.put("rows", pageSize);
|
||||
if (truncated) {
|
||||
result.put("notice", "rows 超过上限 100,已截断为 100");
|
||||
}
|
||||
result.put("records", records);
|
||||
return result;
|
||||
}
|
||||
|
||||
private Map<String, Object> record(WarehouseStockManage stock) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("id", stock.getId());
|
||||
row.put("warehouseId", stock.getWarehouseId());
|
||||
row.put("warehouseNo", stock.getWarehouseNo());
|
||||
row.put("warehouseName", stock.getWarehouseName());
|
||||
row.put("agentId", stock.getAgentId());
|
||||
row.put("agentName", stock.getAgentName());
|
||||
row.put("warehouseOwnerId", stock.getWarehouseOwnerId());
|
||||
row.put("warehouseOwnerName", stock.getWarehouseOwnerName());
|
||||
row.put("materialDefinitionId", stock.getMaterielDefinitionId());
|
||||
row.put("materialNo", stock.getMaterialNo());
|
||||
row.put("materialName", stock.getMaterielFullName());
|
||||
row.put("materialCategoryId", stock.getMaterielIndexId());
|
||||
row.put("materialCategory", stock.getMaterielSort());
|
||||
row.put("specifications", stock.getSpecifications());
|
||||
row.put("snCode", stock.getSnCode());
|
||||
int currentInventory = zeroIfNull(stock.getCurrentInventory());
|
||||
int outOfStock = zeroIfNull(stock.getOutOfStock());
|
||||
row.put("currentInventory", currentInventory);
|
||||
row.put("outOfStock", outOfStock);
|
||||
row.put("availableInventory", currentInventory - outOfStock);
|
||||
row.put("batteryInventory", zeroIfNull(stock.getBatteryInventory()));
|
||||
row.put("updatedAt", stock.getModifyTime() == null ? null
|
||||
: TIME_FORMATTER.format(stock.getModifyTime().toInstant()));
|
||||
return row;
|
||||
}
|
||||
|
||||
private static int zeroIfNull(Integer value) {
|
||||
return value == null ? 0 : value;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import me.iiv.agent.entity.m2.ContractNew;
|
||||
import me.iiv.agent.entity.m2.WarehouseAgentAuth;
|
||||
import me.iiv.agent.entity.m2.WarehouseIn;
|
||||
import me.iiv.agent.entity.m2.WarehouseOut;
|
||||
import me.iiv.agent.entity.m2.WarehouseStockManage;
|
||||
import me.iiv.agent.mapper.m0.AgentManageDistrictAuthMapper;
|
||||
import me.iiv.agent.mapper.m0.AgentMapper;
|
||||
import me.iiv.agent.mapper.m0.AuthorityMapper;
|
||||
@@ -197,6 +198,26 @@ public class PermissionService {
|
||||
.collect(Collectors.joining(","));
|
||||
}
|
||||
|
||||
/**
|
||||
* 库存查询专用权限,1:1 对齐老系统 WrapperUtils.getWarehouseAuth:总部不加过滤;
|
||||
* 代理可看本公司及 warehouse_agent_auth 授权代管公司作为管理公司或所属公司的库存。
|
||||
*/
|
||||
public void applyWarehouseStockRowFilter(LambdaQueryWrapper<WarehouseStockManage> wrapper, User user) {
|
||||
if (!isAgentAuthority(user.getAuthorityId())) {
|
||||
return;
|
||||
}
|
||||
if (user.getAgentId() == null) {
|
||||
throw new IllegalStateException("代理商用户未关联代理商,拒绝访问业务数据");
|
||||
}
|
||||
Set<Long> ids = new LinkedHashSet<>();
|
||||
ids.add(user.getAgentId());
|
||||
ids.addAll(managedAuths(user.getId()).stream()
|
||||
.map(WarehouseAgentAuth::getManagerAgentId)
|
||||
.collect(Collectors.toList()));
|
||||
wrapper.and(w -> w.in(WarehouseStockManage::getAgentId, ids)
|
||||
.or().in(WarehouseStockManage::getWarehouseOwnerId, ids));
|
||||
}
|
||||
|
||||
/** 直属下级代理 id + 自己 agentId(老 :682-695,supervisor_id=自己,只取一层) */
|
||||
private List<Long> subordinateAndSelfIds(User user) {
|
||||
List<Long> ids = new ArrayList<>();
|
||||
|
||||
+35
-2
@@ -46,11 +46,15 @@ class AdminConsoleControllerTest {
|
||||
new org.apache.ibatis.builder.MapperBuilderAssistant(
|
||||
new com.baomidou.mybatisplus.core.MybatisConfiguration(), ""),
|
||||
me.iiv.agent.entity.m0.User.class);
|
||||
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
|
||||
new org.apache.ibatis.builder.MapperBuilderAssistant(
|
||||
new com.baomidou.mybatisplus.core.MybatisConfiguration(), ""),
|
||||
me.iiv.agent.entity.svc.Tool.class);
|
||||
controller = new AdminConsoleController(userMapper, authorityMapper, agentMapper, tokenMapper,
|
||||
scopeMapper, toolMapper, scopeToolMapper, auditMapper, tokenScopeMapper,
|
||||
permissionService, tokenService);
|
||||
when(userMapper.selectCount(any())).thenReturn(0L);
|
||||
when(userMapper.selectList(any())).thenReturn(List.of());
|
||||
org.mockito.Mockito.lenient().when(userMapper.selectCount(any())).thenReturn(0L);
|
||||
org.mockito.Mockito.lenient().when(userMapper.selectList(any())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -78,4 +82,33 @@ class AdminConsoleControllerTest {
|
||||
assertTrue(sql.contains("enterprise_type"), "用户列表必须过滤物业账号: " + sql);
|
||||
assertTrue(sql.contains("IS NULL"), "历史 NULL 数据需保留: " + sql);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolsClampsPageSizeToHundred() {
|
||||
when(toolMapper.selectCount(any())).thenReturn(30L);
|
||||
when(toolMapper.selectList(any())).thenReturn(List.of());
|
||||
when(scopeMapper.selectList(any())).thenReturn(List.of());
|
||||
when(scopeToolMapper.selectList(any())).thenReturn(List.of());
|
||||
AdminConsoleController.ToolPageView view = controller.tools("", "", "", 1, 500);
|
||||
assertEquals(100, view.pageSize());
|
||||
assertEquals(1, view.page());
|
||||
assertEquals(30, view.total());
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolsPageSizeZeroReturnsAllWithoutLimit() {
|
||||
when(toolMapper.selectCount(any())).thenReturn(30L);
|
||||
when(toolMapper.selectList(any())).thenReturn(List.of());
|
||||
when(scopeMapper.selectList(any())).thenReturn(List.of());
|
||||
when(scopeToolMapper.selectList(any())).thenReturn(List.of());
|
||||
AdminConsoleController.ToolPageView view = controller.tools("", "", "", 3, 0);
|
||||
assertEquals(0, view.pageSize());
|
||||
assertEquals(1, view.page());
|
||||
assertEquals(30, view.total());
|
||||
org.mockito.ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<me.iiv.agent.entity.svc.Tool>>
|
||||
captor = org.mockito.ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper.class);
|
||||
org.mockito.Mockito.verify(toolMapper).selectList(captor.capture());
|
||||
assertTrue(captor.getValue().getSqlSegment() == null || !captor.getValue().getSqlSegment().contains("LIMIT"),
|
||||
"全量模式不应带 LIMIT: " + captor.getValue().getSqlSegment());
|
||||
}
|
||||
}
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package me.iiv.agent.mcp.tool;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import me.iiv.agent.entity.m0.User;
|
||||
import me.iiv.agent.entity.m2.WarehouseStockManage;
|
||||
import me.iiv.agent.mapper.m2.WarehouseStockManageMapper;
|
||||
import me.iiv.agent.permission.PermissionService;
|
||||
import me.iiv.agent.security.AuthContext;
|
||||
import me.iiv.agent.security.AuthContextHolder;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class WarehouseStockToolsTest {
|
||||
|
||||
@Mock WarehouseStockManageMapper mapper;
|
||||
@Mock PermissionService permissionService;
|
||||
|
||||
WarehouseStockTools tools;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
WarehouseStockManage.class);
|
||||
tools = new WarehouseStockTools(mapper, permissionService);
|
||||
User user = new User();
|
||||
user.setId(7L);
|
||||
AuthContextHolder.set(new AuthContext(null, user));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
AuthContextHolder.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listAppliesFiltersPermissionPaginationAndMapsInventory() {
|
||||
WarehouseStockManage stock = new WarehouseStockManage();
|
||||
stock.setId(9L);
|
||||
stock.setWarehouseId(8L);
|
||||
stock.setWarehouseName("一号仓");
|
||||
stock.setMaterielFullName("擦手纸");
|
||||
stock.setMaterielIndexId(3L);
|
||||
stock.setCurrentInventory(20);
|
||||
stock.setOutOfStock(4);
|
||||
stock.setBatteryInventory(null);
|
||||
stock.setModifyTime(new Date(0));
|
||||
when(mapper.selectCount(any())).thenReturn(1L);
|
||||
when(mapper.selectList(any())).thenReturn(List.of(stock));
|
||||
|
||||
Map<String, Object> result = tools.warehouseStockListQuery(
|
||||
List.of(8L), 10L, " 擦手 ", " MAT ", List.of(3L), " A型 ", 0, 500);
|
||||
|
||||
assertEquals(1L, result.get("total"));
|
||||
assertEquals(1, result.get("page"));
|
||||
assertEquals(100, result.get("rows"));
|
||||
assertEquals("rows 超过上限 100,已截断为 100", result.get("notice"));
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> record = ((List<Map<String, Object>>) result.get("records")).get(0);
|
||||
assertEquals("擦手纸", record.get("materialName"));
|
||||
assertEquals(16, record.get("availableInventory"));
|
||||
assertEquals(0, record.get("batteryInventory"));
|
||||
assertEquals("1970-01-01 08:00:00", record.get("updatedAt"));
|
||||
verify(permissionService).applyWarehouseStockRowFilter(any(), any());
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<LambdaQueryWrapper<WarehouseStockManage>> captor =
|
||||
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
verify(mapper).selectList(captor.capture());
|
||||
String sql = captor.getValue().getSqlSegment();
|
||||
assertTrue(sql.contains("warehouse_id IN"));
|
||||
assertTrue(sql.contains("warehouse_owner_id"));
|
||||
assertTrue(sql.contains("materiel_full_name LIKE"));
|
||||
assertTrue(sql.contains("material_no LIKE"));
|
||||
assertTrue(sql.contains("materiel_index_id IN"));
|
||||
assertTrue(sql.contains("specifications LIKE"));
|
||||
assertTrue(sql.contains("current_inventory DESC"));
|
||||
assertTrue(sql.contains("id DESC"));
|
||||
assertTrue(sql.contains("LIMIT 0,100"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void springAiPublishesWarehouseStockSchema() {
|
||||
var callbacks = org.springframework.ai.tool.method.MethodToolCallbackProvider.builder()
|
||||
.toolObjects(tools).build().getToolCallbacks();
|
||||
Map<String, String> schemas = Arrays.stream(callbacks).collect(java.util.stream.Collectors.toMap(
|
||||
callback -> callback.getToolDefinition().name(),
|
||||
callback -> callback.getToolDefinition().inputSchema()));
|
||||
|
||||
assertEquals(1, schemas.size());
|
||||
String schema = schemas.get("warehouse.stock.list.query");
|
||||
assertTrue(schema.contains("warehouseIds"));
|
||||
assertTrue(schema.contains("warehouseOwnerId"));
|
||||
assertTrue(schema.contains("materialCategoryIds"));
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package me.iiv.agent.permission;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import me.iiv.agent.entity.m0.Authority;
|
||||
import me.iiv.agent.entity.m0.User;
|
||||
import me.iiv.agent.entity.m2.WarehouseAgentAuth;
|
||||
import me.iiv.agent.entity.m2.WarehouseStockManage;
|
||||
import me.iiv.agent.mapper.m0.AgentManageDistrictAuthMapper;
|
||||
import me.iiv.agent.mapper.m0.AgentMapper;
|
||||
import me.iiv.agent.mapper.m0.AuthorityMapper;
|
||||
import me.iiv.agent.mapper.m0.ProperytMapper;
|
||||
import me.iiv.agent.mapper.m2.WarehouseAgentAuthMapper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PermissionServiceWarehouseStockRowFilterTest {
|
||||
|
||||
@Mock AuthorityMapper authorityMapper;
|
||||
@Mock AgentMapper agentMapper;
|
||||
@Mock WarehouseAgentAuthMapper warehouseAgentAuthMapper;
|
||||
@Mock AgentManageDistrictAuthMapper agentManageDistrictAuthMapper;
|
||||
@Mock ProperytMapper properytMapper;
|
||||
@Mock StringRedisTemplate redisTemplate;
|
||||
|
||||
PermissionService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
WarehouseStockManage.class);
|
||||
service = new PermissionService(authorityMapper, agentMapper, warehouseAgentAuthMapper,
|
||||
agentManageDistrictAuthMapper, properytMapper, redisTemplate);
|
||||
}
|
||||
|
||||
@Test
|
||||
void headquartersAddsNoFilter() {
|
||||
User user = user(7L, 9L, null);
|
||||
when(authorityMapper.selectById(9L)).thenReturn(authority(9L, 0));
|
||||
LambdaQueryWrapper<WarehouseStockManage> wrapper = new LambdaQueryWrapper<>();
|
||||
|
||||
service.applyWarehouseStockRowFilter(wrapper, user);
|
||||
|
||||
assertTrue(wrapper.getSqlSegment().isBlank());
|
||||
verifyNoInteractions(warehouseAgentAuthMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void agentCanSeeOwnOrManagedCompanyInEitherCompanyColumn() {
|
||||
User user = user(7L, 9L, 10L);
|
||||
when(authorityMapper.selectById(9L)).thenReturn(authority(9L, 1));
|
||||
WarehouseAgentAuth managed = new WarehouseAgentAuth();
|
||||
managed.setManagerAgentId(12L);
|
||||
when(warehouseAgentAuthMapper.selectList(any())).thenReturn(List.of(managed));
|
||||
LambdaQueryWrapper<WarehouseStockManage> wrapper = new LambdaQueryWrapper<>();
|
||||
|
||||
service.applyWarehouseStockRowFilter(wrapper, user);
|
||||
|
||||
String sql = wrapper.getSqlSegment();
|
||||
Collection<Object> values = wrapper.getParamNameValuePairs().values();
|
||||
assertTrue(sql.contains("agent_id IN"));
|
||||
assertTrue(sql.contains("warehouse_owner_id IN"));
|
||||
assertTrue(sql.contains("OR"));
|
||||
assertTrue(values.containsAll(List.of(10L, 12L)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void agentWithoutAgentIdFailsClosed() {
|
||||
User user = user(7L, 9L, null);
|
||||
when(authorityMapper.selectById(9L)).thenReturn(authority(9L, 1));
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> service.applyWarehouseStockRowFilter(new LambdaQueryWrapper<>(), user));
|
||||
}
|
||||
|
||||
private static User user(Long id, Long authorityId, Long agentId) {
|
||||
User user = new User();
|
||||
user.setId(id);
|
||||
user.setAuthorityId(authorityId);
|
||||
user.setAgentId(agentId);
|
||||
return user;
|
||||
}
|
||||
|
||||
private static Authority authority(Long id, int isAgent) {
|
||||
Authority authority = new Authority();
|
||||
authority.setId(id);
|
||||
authority.setIsagent(isAgent);
|
||||
authority.setSoftDelete(0);
|
||||
return authority;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user