From e1fcae2eb1b610c3c80bfd05ec82a4afb54260f6 Mon Sep 17 00:00:00 2001 From: my_local_kimi_dev2 Date: Tue, 13 Oct 2026 11:33:19 +0800 Subject: [PATCH] feat(mcp): add agent.user.list.query for agent-role user listing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 查询 m0.user 中 enterprise_type=1 的代理角色用户,返回所属权限组、 dept_user 部门岗位明细与当前站点;行级权限复用 visibleAgentIds (总部全量/代理商自己+直属下级+代管),mobile 原样不脱敏, idcard/password/openid 不进白名单,不返回位置字段。 设计:doc/agent-user-list-mcp-tool-design.md(2026-10-13 王恒拍板) --- xiaoqu-agent-service/README.md | 1 + .../doc/agent-mcp-tools-reference.md | 31 +- .../doc/agent-user-list-mcp-tool-design.md | 121 ++++++ xiaoqu-agent-service/sql/seed-dev.sql | 7 + .../java/me/iiv/agent/entity/m0/DeptUser.java | 66 +++ .../iiv/agent/mapper/m0/DeptUserMapper.java | 11 + .../me/iiv/agent/mcp/tool/AgentUserTools.java | 262 ++++++++++++ .../agent/permission/PermissionService.java | 24 ++ .../agent/mcp/tool/AgentUserToolsTest.java | 392 ++++++++++++++++++ 9 files changed, 909 insertions(+), 6 deletions(-) create mode 100644 xiaoqu-agent-service/doc/agent-user-list-mcp-tool-design.md create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/DeptUser.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/DeptUserMapper.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/AgentUserTools.java create mode 100644 xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/AgentUserToolsTest.java diff --git a/xiaoqu-agent-service/README.md b/xiaoqu-agent-service/README.md index 7eb45a7..119a07b 100644 --- a/xiaoqu-agent-service/README.md +++ b/xiaoqu-agent-service/README.md @@ -98,6 +98,7 @@ xiaoqu-agent-service/ | `district.paper.trace.list.query` | district | read | 客户换纸记录分页查询(复刻老 /MonthlyStatistics/getRfidTraceList 数据语义,出分页明细不复刻轨迹展开)。入参 districtId/startDate/endDate 必填(≤31 天),snCode/rfid 可选 .keyword 精确匹配,state 可选(2上纸/4撤纸,不传查所有),deviceType 可选,page/rows 默认 1/20 上限 100。返回 records(createTime/state+stateText/rfid/snCode/lengthCm 厘米/paperlengthCm/positionsId/toiletId/gender+genderText/deviceType/isAssignment),createTime ASC。ES paperuselog 热/冷索引 + LocalEsDate 云/本地集群路由(复用 statistics 同款);v1 不返回满卷标记。详细设计见 doc/paper-trace-list-mcp-design.md | | `battery.fault.list.query` | hardware | read | 电池故障分页查询。按站点/物业/代理商/SN/设备类型/故障类型/恢复状态/故障日期筛选;沿用设备行级权限,按故障时间和 ID 倒序。详细设计见 doc/battery-fault-mcp-tool-design.md | | `hardware.electricity.message.list.query` | hardware | read | 设备电量/信号/温度等消息记录分页查询(ES electricitymessage_yyyyMM 月索引,只走本地集群,无权限过滤,对齐老 /batteryAnalyse/batteryAnalyseList)。入参 snCode(必填)/startTime/endTime(必填且须同一天,跨天拒绝)/messageType(0-5,不传=全部类型)/page/rows(默认 1/500,上限 1000);出参 snCode/index/total/page/rows/records(createTime/messageType/messageTypeText/value),createTime ASC。详细设计见 doc/electricity-message-mcp-tool-design.md | +| `agent.user.list.query` | agent | read | 代理角色用户分页查询(m0 user 表,强制 enterprise_type=1 + soft_delete=0,对齐老 /user/listByPage 代理用户子集)。入参 agentId/name/mobile/no/authorityId/deptId/workStatus/page/rows;出参含 agentName/authorityName/deptList(dept_user 岗位明细)/districtName,mobile 原样不脱敏,不含 idcard/password/openid 与位置字段。行权限:总部看全部,代理商按 visibleAgentIds(自己+直属下级+代管)收缩,agentId 越权拒绝;只绑 prod scope 1。详细设计见 doc/agent-user-list-mcp-tool-design.md | 分页统一规范:page 从 1 开始,rows 默认 20、上限 100,超出截断为 100 并在返回 `notice` 字段提示。 diff --git a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md index 31d120a..824e542 100644 --- a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md +++ b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md @@ -846,7 +846,7 @@ --- -## 33. district.paper.percapita.query +## 34. district.paper.percapita.query 查询站点人均用纸统计,覆盖老 `StatisticsReportController` 的五个接口。 @@ -869,7 +869,7 @@ --- -## 34. district.paper.device_average.query +## 35. district.paper.device_average.query 查询站点设备日均用纸统计,覆盖老 `StatisticsReportController` 的五个硬件统计接口。 @@ -911,7 +911,7 @@ --- -## 35. hardware.device.lifecycle.query +## 36. hardware.device.lifecycle.query 按 SN 查询设备生命周期当前状态,复刻老系统 `/lifeCycle/lifeCycleListByPage`。 完整设计见 [device-lifecycle-mcp-tool-design.md](device-lifecycle-mcp-tool-design.md)。 @@ -928,7 +928,7 @@ - roadState/inventoryState 含 `-1=初始化`;未知枚举值返回 `未知(code)`,不抛错。 - 与老系统差异:不做任何行/字段权限过滤;不复制老系统 `exchange_state∈(0,1)` 的排除,全状态可见。 -## 36. hardware.device.lifecycle.detail.query +## 37. hardware.device.lifecycle.detail.query 按 SN 查询设备生命周期事件时间线,复刻老系统 `/assetDepreciationSet/lookDetail`。 @@ -953,7 +953,7 @@ --- -## 37. district.traffic.statistics.query +## 38. district.traffic.statistics.query 查询站点人流量统计(人次,SUM(use_count)),复刻老 `StatisticsReportController.trafficStatistics`(饼图) 与 `trafficStatisticsDetail`(折线图)。 @@ -977,7 +977,7 @@ --- -## 38. district.paper.trace.list.query +## 39. district.paper.trace.list.query 客户换纸记录分页查询(ES paperuselog 热索引 / paperuselog_all 冷索引),复刻老 `/MonthlyStatistics/getRfidTraceList` 的数据查询语义但**出分页明细**(不复刻轨迹展开)。 @@ -997,6 +997,25 @@ --- +## 40. agent.user.list.query + +代理角色用户分页查询(m0 `user`,强制 `enterprise_type=1` + `soft_delete=0`),对应老 `/user/listByPage` 的代理用户子集。 + +- 分类:agent | 类型:读 +- 入参(全部可选):`agentId`(精确,必须在当前身份可见范围内)、`name`/`mobile`/`no`(模糊)、 + `authorityId`(精确)、`deptId`(精确,查 `dept_user` 关联)、`workStatus`(精确);`page`/`rows` 默认 1/20 上限 100。 +- 返回 `total/page/rows/[notice]/records`,固定 `id DESC`。record 含 id/name/no/loginAccount/mobile(**原样不脱敏**)/ + agentId+agentName/authorityId+authorityName/enterpriseType/approle/roleType/workStatus 及各自 `*Text`/ + deptId+deptName+postName(主岗)/`deptList`(dept_user 全量岗位明细:deptId/deptName/postId/postName/isManager/isKey)/ + districtId+districtName/lastLoginTime/createTime。 +- 枚举文案:enterpriseType 1=代理 2=物业;approle 1=运维 2=保洁 3=富士康 4=老化 5=物业 6=代理; + roleType 0=管理 1=普通;workStatus 0=上班 1=下班 2=请假 3=休息。 +- 行级权限:总部(isagent=0)看全部;代理商(isagent=1)`agent_id IN visibleAgentIds`(自己+直属下级+代管), + 传范围外 agentId 抛 `IllegalStateException("无权访问该代理商")`;服务主体不放行(仅绑 prod scope 1)。 +- 白名单不含 idcard/password/weixin_openid/weixin_name/ding_user_id,不返回位置字段(省市区/地址)。 + +详细设计见 [agent-user-list-mcp-tool-design](agent-user-list-mcp-tool-design.md)。 + --- ## 附录:在线查看工具清单 diff --git a/xiaoqu-agent-service/doc/agent-user-list-mcp-tool-design.md b/xiaoqu-agent-service/doc/agent-user-list-mcp-tool-design.md new file mode 100644 index 0000000..dc78a7f --- /dev/null +++ b/xiaoqu-agent-service/doc/agent-user-list-mcp-tool-design.md @@ -0,0 +1,121 @@ +# agent.user.list.query 代理角色用户查询 MCP 工具设计 + +- 需求来源:王恒 2026-10-12「找下小趣保洁用户管理接口,开发一个只查代理角色用户的 MCP 工具,所属权限组、部门位置信息都要显示」 +- 老接口参照:`xiaoqu-franchisee` `POST /user/listByPage`(UserController.java:266) +- 状态:已评审拍板(2026-10-13 王恒拍板 6 个决策点,见第 7 节),进入实现 +- 修订记录:v2 2026-10-12 经王恒指正,判定口径由 authority.isagent=1 更正为 user.enterprise_type=1;v3 2026-10-13 拍板结论回写(mobile 不脱敏、去掉位置字段) + +## 1. 背景与老接口语义 + +老系统用户管理主页面接口 `/user/listByPage`(UserController.java:266-339): + +- 入参 `UserPageInput` 继承 `PageInput`(rows/page/sidx/sord),查询条件经 `WrapperUtils.beanToWrapper` 反射构造:String→like、Integer→eq、List→in。 +- 返回 `UserOutput` 继承 User 全字段,额外富化 agentName/propertyName/districtName/authorityName/createName/isManager/postEntityList,名称全部从 Redis 取(`name:Agent:{id}` 等,UserController.java:296-303)。 +- 行级过滤(:278-282):非管理员不能看代理商管理员;代理身份登录人走 `WrapperUtils.getByIsAgent` 收缩 agent_id 范围。 +- 字段级裁剪:`field_webpage_authority` 配置驱动 select 列。 + +**"代理角色用户"的权威判定**(2026-10-12 经王恒指正修订):用户表字段 `user.enterprise_type=1`(1=代理;2=物业,User.java:82-84)。依据:代理商创建运维人员时硬编码 `setEnterpriseType(Constants.enterprise_type_1)`(UserController.java:243);全系统查代理用户均为 `wrapper.eq("enterprise_type", 1)`(AddressController.java:94/142、DropDownController.java:146/243/460/489、WarehouseOutController.java:4134、InstallOrderController.java:199);入参 `UserParamInput2`/`UserPageInput2` 默认 `enterpriseType=1`。 + +⚠️ 初版误判:`authority.isagent=1` 是**登录人**行级权限的判定(`checkAuthorityIsagent` 用于收缩调用者可见 agent 范围),不是被查询用户的角色分类依据,已废弃该口径。辅助字段 `user.approle`(6=代理,User.java:86-88)为 APP 端角色,仅作返回展示,不作判定依据。 + +## 2. 老接口的坑(本工具全部规避) + +| # | 坑 | 证据 | 本工具对策 | +|---|---|---|---| +| 1 | 名称靠 Redis,缓存缺失返回 null 名称 | UserController.java:296-303, 112-114 | 直接查库批量富化 | +| 2 | N+1:每行 Redis×3 + getById + deptUser list | :305-332 | 按页批量 IN 查询 | +| 3 | 接口级权限为空(PermissionInterceptor return true) | PermissionInterceptor.java:13-15 | MCP 侧 AuthContext + 行级过滤 | +| 4 | 字段裁剪由配置驱动,可能漏字段/多查 password | :285-287 | 固定白名单字段 | +| 5 | sidx 排序无白名单注入面 | WrapperUtils.java:313-324 | 固定排序 id DESC | +| 6 | mobile/idcard/password 原样返回 | UserOutput 继承全字段 | 白名单:mobile 原样返回(v3 拍板不脱敏),idcard/password/openid 不出 | +| 7 | dept_user/dept_name 为冗余快照,可能与 dept 表不一致 | DeptUser.java | 以 dept_user 关联记录为准返回,名称取自关联表快照(与老页面一致) | +| 8 | dept/dept_user 仅 Z/F 渠道代理强制维护,D 渠道代理用户大概率无部门数据 | UserController.java:454-458 | deptList 允许为空,不视为异常 | + +## 3. 工具契约 + +**`agent.user.list.query`**(category=`agent`,kind=read)——分页查询代理角色用户(`enterprise_type=1`)列表,返回所属权限组、部门岗位明细。(v3 拍板:不返回位置信息) + +### 入参 + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| agentId | int | 否 | 所属代理商 id,精确匹配,且必须在当前身份可见范围内 | +| name | string | 否 | 用户姓名,模糊匹配 | +| mobile | string | 否 | 手机号,模糊匹配 | +| no | string | 否 | 员工编号/工号,模糊匹配 | +| authorityId | int | 否 | 权限组 id,精确匹配(仍强制叠加 enterprise_type=1) | +| deptId | int | 否 | 部门 id,精确匹配(查 dept_user 关联) | +| workStatus | int | 否 | 在职状态,精确匹配 | +| page | int | 否 | 默认 1,<1 归 1 | +| rows | int | 否 | 默认 20,上限 100,超出截断 + notice | + +固定排序:`id DESC`。软删过滤 `soft_delete=0`。 + +### 返回 + +固定键序 `total / page / rows / [notice] / records`。record 字段: + +| 字段 | 说明 | +|---|---| +| id / name / no / loginAccount | 基本信息 | +| mobile | 手机号**原样返回不脱敏**(v3 王恒拍板) | +| agentId / agentName | 所属代理商 | +| authorityId / authorityName | **所属权限组** | +| enterpriseType / enterpriseTypeText | 1代理 2物业 | +| approle / approleText | 1运维 2保洁 3富士康 4老化 5物业 6代理 | +| roleType / roleTypeText | 0管理 1普通 | +| workStatus / workStatusText | 在职状态 | +| deptId / deptName / postName | 主岗部门(user 冗余字段) | +| deptList | **部门岗位明细**(dept_user 全量关联):deptId/deptName/postId/postName/isManager/isKey | +| districtId / districtName | 保洁当前站点(可为空) | +| lastLoginTime / createTime | 时间 | + +### 数据源与查询 + +全部 m0 库:`user`(主表,强制 `enterprise_type=1`)+ `authority`(权限组名称富化)+ `agent`(代理商名称富化)+ `dept_user`(部门岗位明细,soft_delete=0)+ `district`(站点名)。 + +实现:`user.enterprise_type=1 AND soft_delete=0` + 查询条件 + 行级过滤,`selectCount` + `wrapper.last("LIMIT offset,size")` 物理分页(项目未注册 MP 分页拦截器)→ 按页批量富化 agent/authority/district/dept_user。 + +## 4. 权限设计(新增 PermissionService.applyAgentUserRowFilter) + +m0.user 表目前无行级过滤先例(users.changes.list 走 SYNC scope 白名单不过滤)。本工具口径: + +- **总部**(isagent=0):可见全部代理角色用户,可传任意 agentId。 +- **代理商**(isagent=1):`user.agent_id IN visibleAgentIds(user)`(自己 + 直属下级 + warehouse_agent_auth 代管公司,复用 PermissionService.java:146-161);传 agentId 时校验必须在可见集合内,否则抛 `IllegalStateException("无权访问该代理商")`。 +- **服务主体**(tenant_service):按惯例不放行在线查询工具(本工具不绑 sync scope)。 +- 权限组不存在/已删/缺配置:fail closed 抛异常(对齐现有 isAgentAuthority 语义)。 + +**不叠加**老接口"非管理员不能看代理商管理员"的收缩——MCP 场景是系统对接,该规则是页面层防误操作语义。 + +## 5. 敏感字段处理 + +- `mobile` **原样返回不脱敏**(2026-10-13 王恒拍板);`idcard`、`password`、`weixin_openid`、`weixin_name`、`ding_user_id` **不进入返回白名单**。 +- 查询条件支持 mobile 模糊匹配。 + +## 6. 实现清单 + +1. `PermissionService.applyAgentUserRowFilter`(复用 visibleAgentIds) +2. 实体/Mapper 复用现有 `entity/m0/User.java`、`Authority.java`、`Agent.java`;新增 `entity/m0/DeptUser.java` + `DeptUserMapper`;district 名称富化复用现有 DistrictMapper +3. `mcp/tool/AgentUserTools.java`(@XqTool category=agent kind=read) +4. 单测 `AgentUserToolsTest`:参数校验、enterprise_type=1 强制过滤、代理身份行级收缩、agentId 越权拒绝、rows 截断 notice、敏感字段不出白名单、deptList 组装、空数据(纯 Mockito + TableInfoHelper 惯例) +5. 文档:本设计文档、`doc/agent-mcp-tools-reference.md` 新增章节(顺延至 §44)、README 工具表一行 +6. `sql/seed-dev.sql` 幂等 scope 绑定(建议绑 prod scope 1,见决策点 4) + +## 7. 评审决策点(2026-10-13 王恒全部拍板) + +| # | 决策点 | 拍板结论 | +|---|---|---| +| 1 | 行级权限口径 | **visibleAgentIds(自己+直属下级+代管)**,与 agent.list.query 等现有工具一致 | +| 2 | mobile 脱敏 | **不脱敏**,原样返回 | +| 3 | 位置信息 | **不需要取位置**,返回不含省市区/地址字段 | +| 4 | scope 绑定 | **只绑 prod scope 1**,用户数据敏感从严 | +| 5 | 分类归属 | **agent** 类 | +| 6 | 配套 detail 工具 | **不需要**,列表字段已含全量信息 | + +## 8. 验收标准 + +1. 总部身份不传条件可查全部 enterprise_type=1 用户,分页/排序正确。 +2. 代理商身份只见 visibleAgentIds 范围内用户;传范围外 agentId 拒绝。 +3. 返回含 authorityName、deptList、agentName;mobile 原样不脱敏;无 idcard/password/openid 字段,无位置字段。 +4. rows>100 截断并带 notice;全部新增单测通过,全量测试不回归。 +5. 生产冒烟:真实 AK tools/list 可见 + tools/call 返回结构正确。 diff --git a/xiaoqu-agent-service/sql/seed-dev.sql b/xiaoqu-agent-service/sql/seed-dev.sql index 64b26ca..652ff6d 100644 --- a/xiaoqu-agent-service/sql/seed-dev.sql +++ b/xiaoqu-agent-service/sql/seed-dev.sql @@ -83,6 +83,13 @@ WHERE s.soft_delete = 0 AND s.status = 1 AND t.name = 'district.paper.trace.list.query' AND NOT EXISTS (SELECT 1 FROM scope_tool st WHERE st.scope_id = s.id AND st.tool_id = t.id); +-- 代理角色用户查询(agent.user.list.query):2026-10-13 王恒拍板只绑 scope 1 +-- (prod scope 1 = code 'test'),用户数据敏感从严;不进入 operation/xiaoqu-ai/ops-query/sync。 +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.id = 1 AND t.name = 'agent.user.list.query' + AND NOT EXISTS (SELECT 1 FROM scope_tool st WHERE st.scope_id = s.id AND st.tool_id = t.id); + COMMIT; -- 校验 diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/DeptUser.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/DeptUser.java new file mode 100644 index 0000000..c6c740e --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/DeptUser.java @@ -0,0 +1,66 @@ +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; + +/** + * 部门人员(m0.dept_user)。dept_name/post_name 为冗余快照, + * 与老系统 /user/listByPage 一致:以 dept_user 关联记录为准返回,不回查 dept 表。 + */ +@Getter +@Setter +@ToString +@TableName("dept_user") +public class DeptUser implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 数据状态 + */ + private Integer softDelete; + + /** + * 人员id + */ + private Long userId; + + /** + * 部门id + */ + private Long deptId; + + /** + * 部门名称 + */ + private String deptName; + + /** + * 岗位id + */ + private Long postId; + + /** + * 岗位名称 + */ + private String postName; + + /** + * 是否部门负责(0:否;1:是) + */ + private Integer isManager; + + /** + * 是否主岗(0:否;1:是) + */ + private Integer isKey; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/DeptUserMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/DeptUserMapper.java new file mode 100644 index 0000000..995a0cb --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/DeptUserMapper.java @@ -0,0 +1,11 @@ +package me.iiv.agent.mapper.m0; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m0.DeptUser; + +/** + * 部门人员(dept_user)Mapper 接口 + */ +public interface DeptUserMapper extends BaseMapper { + +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/AgentUserTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/AgentUserTools.java new file mode 100644 index 0000000..a30a51f --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/AgentUserTools.java @@ -0,0 +1,262 @@ +package me.iiv.agent.mcp.tool; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import me.iiv.agent.entity.m0.Agent; +import me.iiv.agent.entity.m0.Authority; +import me.iiv.agent.entity.m0.DeptUser; +import me.iiv.agent.entity.m0.District; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.mapper.m0.AgentMapper; +import me.iiv.agent.mapper.m0.AuthorityMapper; +import me.iiv.agent.mapper.m0.DeptUserMapper; +import me.iiv.agent.mapper.m0.DistrictMapper; +import me.iiv.agent.mapper.m0.UserMapper; +import me.iiv.agent.mcp.ToolObject; +import me.iiv.agent.mcp.XqTool; +import me.iiv.agent.permission.PermissionService; +import me.iiv.agent.security.AuthContextHolder; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * 代理角色用户工具:agent.user.list.query(对应老系统 UserController.listByPage 的 + * enterprise_type=1 子集)。设计文档:doc/agent-user-list-mcp-tool-design.md。 + * 判定口径 user.enterprise_type=1(强制)+ soft_delete=0;mobile 原样返回不脱敏, + * idcard/password/weixin_openid/weixin_name/ding_user_id 不进返回白名单;不返回位置字段。 + */ +@Component +public class AgentUserTools implements ToolObject { + + private static final int DEFAULT_ROWS = 20; + private static final int MAX_ROWS = 100; + + final UserMapper userMapper; + final DeptUserMapper deptUserMapper; + final AgentMapper agentMapper; + final AuthorityMapper authorityMapper; + final DistrictMapper districtMapper; + final PermissionService permissionService; + + public AgentUserTools(UserMapper userMapper, DeptUserMapper deptUserMapper, + AgentMapper agentMapper, AuthorityMapper authorityMapper, + DistrictMapper districtMapper, PermissionService permissionService) { + this.userMapper = userMapper; + this.deptUserMapper = deptUserMapper; + this.agentMapper = agentMapper; + this.authorityMapper = authorityMapper; + this.districtMapper = districtMapper; + this.permissionService = permissionService; + } + + /** + * 查询代理角色用户列表。强制 enterprise_type=1 + soft_delete=0,固定 id DESC, + * selectCount + LIMIT 物理分页(项目未注册 MP 分页拦截器,禁用 selectPage)。 + * agentName/authorityName/districtName/deptList 按页批量 IN 富化,无 N+1。 + */ + @XqTool(category = "agent", kind = "read") + @Tool(name = "agent.user.list.query", description = "分页查询代理角色用户(user.enterprise_type=1)列表,返回所属权限组、部门岗位明细。" + + "行级权限:总部身份(权限组 isagent=0)可看全部,可传任意 agentId;代理商身份(isagent=1)只能看自己+直属下级+授权代管代理商范围内的用户," + + "传 agentId 时必须在可见范围内,否则拒绝。" + + "enterpriseType 含义:1=代理 2=物业(本工具恒为 1);approle 含义:1=运维 2=保洁 3=富士康 4=老化 5=物业 6=代理;" + + "roleType 含义:0=管理 1=普通;workStatus 含义:0=上班 1=下班 2=请假 3=休息。" + + "分页:page 从 1 开始,rows 默认 20、上限 100,超出自动截断为 100 并在返回的 notice 字段提示。" + + "返回 total(符合条件的总数)、records(本页记录);mobile 原样返回不脱敏,不含 idcard/password 等敏感字段与位置字段。") + public Map agentUserListQuery( + @ToolParam(required = false, description = "所属代理商 id,精确匹配,且必须在当前身份可见范围内") Long agentId, + @ToolParam(required = false, description = "用户姓名,模糊匹配") String name, + @ToolParam(required = false, description = "手机号,模糊匹配") String mobile, + @ToolParam(required = false, description = "员工编号/工号,模糊匹配") String no, + @ToolParam(required = false, description = "权限组 id,精确匹配(仍强制叠加 enterprise_type=1)") Long authorityId, + @ToolParam(required = false, description = "部门 id,精确匹配(查 dept_user 关联)") Long deptId, + @ToolParam(required = false, description = "工作状态,精确匹配:0=上班 1=下班 2=请假 3=休息") Integer workStatus, + @ToolParam(required = false, description = "页码,从 1 开始,默认 1") Integer page, + @ToolParam(required = false, description = "每页条数,默认 20,上限 100(超出截断为 100)") Integer rows) { + User currentUser = AuthContextHolder.get().getUser(); + 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 wrapper = new LambdaQueryWrapper() + .eq(User::getEnterpriseType, 1) + .eq(User::getSoftDelete, 0); + if (StringUtils.hasText(name)) { + wrapper.like(User::getName, name); + } + if (StringUtils.hasText(mobile)) { + wrapper.like(User::getMobile, mobile); + } + if (StringUtils.hasText(no)) { + wrapper.like(User::getNo, no); + } + if (authorityId != null) { + wrapper.eq(User::getAuthorityId, authorityId); + } + if (workStatus != null) { + wrapper.eq(User::getWorkStatus, workStatus); + } + if (deptId != null) { + List deptUserIds = deptUserMapper.selectList(new LambdaQueryWrapper() + .eq(DeptUser::getSoftDelete, 0) + .eq(DeptUser::getDeptId, deptId) + .select(DeptUser::getUserId)) + .stream().map(DeptUser::getUserId).filter(Objects::nonNull) + .distinct().collect(Collectors.toList()); + wrapper.in(User::getId, deptUserIds.isEmpty() ? List.of(0L) : deptUserIds); + } + permissionService.applyAgentUserRowFilter(wrapper, currentUser, agentId); + + Long total = userMapper.selectCount(wrapper); + wrapper.select(User::getId, User::getName, User::getNo, User::getLoginAccount, + User::getMobile, User::getAgentId, User::getAuthorityId, + User::getEnterpriseType, User::getApprole, User::getRoleType, + User::getWorkStatus, User::getDeptId, User::getDeptName, User::getPostName, + User::getDistrictId, User::getLastLoginTime, User::getCreateTime) + .orderByDesc(User::getId) + .last("LIMIT " + (long) (pageNo - 1) * pageSize + "," + pageSize); + List users = userMapper.selectList(wrapper); + + // 按页批量富化:代理商/权限组/站点名称 + dept_user 部门岗位明细(消除 N+1) + List agentIds = users.stream().map(User::getAgentId) + .filter(Objects::nonNull).distinct().collect(Collectors.toList()); + Map agentNames = agentIds.isEmpty() + ? Map.of() + : agentMapper.selectBatchIds(agentIds).stream() + .collect(Collectors.toMap(Agent::getId, Agent::getName, (a, b) -> a)); + List authorityIds = users.stream().map(User::getAuthorityId) + .filter(Objects::nonNull).distinct().collect(Collectors.toList()); + Map authorityNames = authorityIds.isEmpty() + ? Map.of() + : authorityMapper.selectBatchIds(authorityIds).stream() + .collect(Collectors.toMap(Authority::getId, Authority::getName, (a, b) -> a)); + List districtIds = users.stream().map(User::getDistrictId) + .filter(Objects::nonNull).filter(id -> id != 0).distinct().collect(Collectors.toList()); + Map districtNames = districtIds.isEmpty() + ? Map.of() + : districtMapper.selectBatchIds(districtIds).stream() + .collect(Collectors.toMap(District::getId, District::getDistrictName, (a, b) -> a)); + List userIds = users.stream().map(User::getId) + .filter(Objects::nonNull).distinct().collect(Collectors.toList()); + Map> deptUsersByUserId = userIds.isEmpty() + ? Map.of() + : deptUserMapper.selectList(new LambdaQueryWrapper() + .eq(DeptUser::getSoftDelete, 0) + .in(DeptUser::getUserId, userIds)) + .stream().collect(Collectors.groupingBy(DeptUser::getUserId)); + + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + List> records = new ArrayList<>(); + for (User user : users) { + Map record = new LinkedHashMap<>(); + record.put("id", user.getId()); + record.put("name", user.getName()); + record.put("no", user.getNo()); + record.put("loginAccount", user.getLoginAccount()); + record.put("mobile", user.getMobile()); + record.put("agentId", user.getAgentId()); + record.put("agentName", user.getAgentId() == null ? null : agentNames.get(user.getAgentId())); + record.put("authorityId", user.getAuthorityId()); + record.put("authorityName", + user.getAuthorityId() == null ? null : authorityNames.get(user.getAuthorityId())); + record.put("enterpriseType", user.getEnterpriseType()); + record.put("enterpriseTypeText", enterpriseTypeText(user.getEnterpriseType())); + record.put("approle", user.getApprole()); + record.put("approleText", approleText(user.getApprole())); + record.put("roleType", user.getRoleType()); + record.put("roleTypeText", roleTypeText(user.getRoleType())); + record.put("workStatus", user.getWorkStatus()); + record.put("workStatusText", workStatusText(user.getWorkStatus())); + record.put("deptId", user.getDeptId()); + record.put("deptName", user.getDeptName()); + record.put("postName", user.getPostName()); + List> deptList = new ArrayList<>(); + for (DeptUser deptUser : deptUsersByUserId.getOrDefault(user.getId(), List.of())) { + Map dept = new LinkedHashMap<>(); + dept.put("deptId", deptUser.getDeptId()); + dept.put("deptName", deptUser.getDeptName()); + dept.put("postId", deptUser.getPostId()); + dept.put("postName", deptUser.getPostName()); + dept.put("isManager", deptUser.getIsManager()); + dept.put("isKey", deptUser.getIsKey()); + deptList.add(dept); + } + record.put("deptList", deptList); + record.put("districtId", user.getDistrictId()); + record.put("districtName", + user.getDistrictId() == null ? null : districtNames.get(user.getDistrictId())); + record.put("lastLoginTime", + user.getLastLoginTime() == null ? null : sdf.format(user.getLastLoginTime())); + record.put("createTime", user.getCreateTime() == null ? null : sdf.format(user.getCreateTime())); + records.add(record); + } + + Map 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 static String enterpriseTypeText(Integer value) { + if (value == null) { + return null; + } + return switch (value) { + case 1 -> "代理"; + case 2 -> "物业"; + default -> "未知(" + value + ")"; + }; + } + + private static String approleText(Integer value) { + if (value == null) { + return null; + } + return switch (value) { + case 1 -> "运维"; + case 2 -> "保洁"; + case 3 -> "富士康"; + case 4 -> "老化"; + case 5 -> "物业"; + case 6 -> "代理"; + default -> "未知(" + value + ")"; + }; + } + + private static String roleTypeText(Integer value) { + if (value == null) { + return null; + } + return switch (value) { + case 0 -> "管理"; + case 1 -> "普通"; + default -> "未知(" + value + ")"; + }; + } + + private static String workStatusText(Integer value) { + if (value == null) { + return null; + } + return switch (value) { + case 0 -> "上班"; + case 1 -> "下班"; + case 2 -> "请假"; + case 3 -> "休息"; + default -> "未知(" + value + ")"; + }; + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java index 6401103..9ec418e 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java @@ -159,6 +159,30 @@ public class PermissionService { return new ArrayList<>(ids); } + /** + * 代理角色用户(m0.user,enterprise_type=1)行权限(agent.user.list.query 专用): + * 总部(isagent!=1,含 tenant_service 服务主体)不加过滤,可按任意 agentId 精确查; + * 代理商(isagent=1)user.agent_id IN visibleAgentIds(自己+直属下级+warehouse_agent_auth 代管); + * 传 agentId 时必须在可见集合内,否则拒绝访问。 + */ + public void applyAgentUserRowFilter(LambdaQueryWrapper wrapper, User user, Long agentId) { + List visibleIds = visibleAgentIds(user); + if (visibleIds == null) { + if (agentId != null) { + wrapper.eq(User::getAgentId, agentId); + } + return; + } + if (agentId != null) { + if (!visibleIds.contains(agentId)) { + throw new IllegalStateException("无权访问该代理商"); + } + wrapper.eq(User::getAgentId, agentId); + return; + } + wrapper.in(User::getAgentId, visibleIds); + } + /** * 出库单权限:总部看全部;代理身份可看所属代理范围内的主单、其代理范围所属/负责仓库 * 涉及的主单,以及本人参与审批或处理的主单。关联范围使用 EXISTS 落在同一个主查询中, diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/AgentUserToolsTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/AgentUserToolsTest.java new file mode 100644 index 0000000..ff7463d --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/AgentUserToolsTest.java @@ -0,0 +1,392 @@ +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.Agent; +import me.iiv.agent.entity.m0.Authority; +import me.iiv.agent.entity.m0.DeptUser; +import me.iiv.agent.entity.m0.District; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.entity.m2.WarehouseAgentAuth; +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.DeptUserMapper; +import me.iiv.agent.mapper.m0.DistrictMapper; +import me.iiv.agent.mapper.m0.ProperytMapper; +import me.iiv.agent.mapper.m0.UserMapper; +import me.iiv.agent.mapper.m2.WarehouseAgentAuthMapper; +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 org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +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.ArgumentMatchers.anyList; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * AgentUserTools 单测。行级权限用真实 PermissionService(底层 mapper 全部 mock), + * 直接覆盖 visibleAgentIds 收缩与 agentId 越权拒绝(设计文档 §6.4)。 + */ +@ExtendWith(MockitoExtension.class) +class AgentUserToolsTest { + + @Mock + UserMapper userMapper; + @Mock + DeptUserMapper deptUserMapper; + @Mock + AgentMapper agentMapper; + @Mock + AuthorityMapper authorityMapper; + @Mock + DistrictMapper districtMapper; + @Mock + WarehouseAgentAuthMapper warehouseAgentAuthMapper; + @Mock + AgentManageDistrictAuthMapper agentManageDistrictAuthMapper; + @Mock + ProperytMapper properytMapper; + @Mock + StringRedisTemplate redisTemplate; + + AgentUserTools tools; + + @BeforeEach + void setUp() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), + User.class); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), + DeptUser.class); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), + Agent.class); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), + WarehouseAgentAuth.class); + PermissionService permissionService = new PermissionService(authorityMapper, agentMapper, + warehouseAgentAuthMapper, agentManageDistrictAuthMapper, properytMapper, redisTemplate); + tools = new AgentUserTools(userMapper, deptUserMapper, agentMapper, authorityMapper, + districtMapper, permissionService); + } + + @AfterEach + void tearDown() { + AuthContextHolder.clear(); + } + + @Test + @SuppressWarnings("unchecked") + void headquartersQueryBuildsForcedFiltersAndTruncatesRows() { + loginAsHeadquarters(); + User agentUser = sampleUser(); + when(userMapper.selectCount(any())).thenReturn(1L); + when(userMapper.selectList(any())).thenReturn(List.of(agentUser)); + when(agentMapper.selectBatchIds(anyList())).thenReturn(List.of(agent(12L, "测试代理"))); + when(authorityMapper.selectBatchIds(anyList())).thenReturn(List.of(authority(20L, "代理运维组", 0))); + when(districtMapper.selectBatchIds(anyList())).thenReturn(List.of(district(5L, "测试站点"))); + DeptUser deptUser = new DeptUser(); + deptUser.setUserId(9L); + deptUser.setDeptId(31L); + deptUser.setDeptName("运维部"); + deptUser.setPostId(41L); + deptUser.setPostName("运维专员"); + deptUser.setIsManager(1); + deptUser.setIsKey(1); + when(deptUserMapper.selectList(any())).thenReturn(List.of(deptUser)); + + Map result = tools.agentUserListQuery( + null, "张", "138", "XQ00", 20L, null, 0, 1, 500); + + assertEquals(1, result.get("page")); + assertEquals(100, result.get("rows")); + assertEquals("rows 超过上限 100,已截断为 100", result.get("notice")); + + // 强制 enterprise_type=1 + soft_delete=0;总部不传 agentId 时无 agent_id 行过滤 + ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(userMapper).selectList(captor.capture()); + String sql = captor.getValue().getSqlSegment(); + assertTrue(sql.contains("enterprise_type =")); + assertTrue(sql.contains("soft_delete =")); + assertTrue(sql.contains("name LIKE")); + assertTrue(sql.contains("mobile LIKE")); + assertTrue(sql.contains("no LIKE")); + assertTrue(sql.contains("authority_id =")); + assertTrue(sql.contains("work_status =")); + assertFalse(sql.contains("agent_id")); + assertTrue(sql.contains("ORDER BY id DESC")); + + Map record = ((List>) result.get("records")).get(0); + assertEquals(9L, record.get("id")); + assertEquals("张三", record.get("name")); + assertEquals("13800001234", record.get("mobile")); + assertEquals("测试代理", record.get("agentName")); + assertEquals("代理运维组", record.get("authorityName")); + assertEquals(1, record.get("enterpriseType")); + assertEquals("代理", record.get("enterpriseTypeText")); + assertEquals(6, record.get("approle")); + assertEquals("代理", record.get("approleText")); + assertEquals(0, record.get("roleType")); + assertEquals("管理", record.get("roleTypeText")); + assertEquals(0, record.get("workStatus")); + assertEquals("上班", record.get("workStatusText")); + assertEquals("测试站点", record.get("districtName")); + // 敏感字段与位置字段不进白名单 + assertFalse(record.containsKey("idcard")); + assertFalse(record.containsKey("password")); + assertFalse(record.containsKey("weixinOpenid")); + assertFalse(record.containsKey("weixinName")); + assertFalse(record.containsKey("dingUserId")); + assertFalse(record.containsKey("province")); + assertFalse(record.containsKey("city")); + assertFalse(record.containsKey("address")); + + List> deptList = (List>) record.get("deptList"); + assertEquals(1, deptList.size()); + assertEquals(31L, deptList.get(0).get("deptId")); + assertEquals("运维部", deptList.get(0).get("deptName")); + assertEquals(41L, deptList.get(0).get("postId")); + assertEquals("运维专员", deptList.get(0).get("postName")); + assertEquals(1, deptList.get(0).get("isManager")); + assertEquals(1, deptList.get(0).get("isKey")); + } + + @Test + @SuppressWarnings("unchecked") + void agentIdentityShrinksToVisibleAgentIds() { + loginAsAgent(); + Agent subordinate = new Agent(); + subordinate.setId(2L); + when(agentMapper.selectList(any())).thenReturn(List.of(subordinate)); + WarehouseAgentAuth managedAuth = new WarehouseAgentAuth(); + managedAuth.setManagerAgentId(3L); + when(warehouseAgentAuthMapper.selectList(any())).thenReturn(List.of(managedAuth)); + when(userMapper.selectCount(any())).thenReturn(0L); + when(userMapper.selectList(any())).thenReturn(List.of()); + + Map result = tools.agentUserListQuery( + null, null, null, null, null, null, null, 1, 20); + + assertEquals(0L, result.get("total")); + ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(userMapper).selectList(captor.capture()); + String sql = captor.getValue().getSqlSegment(); + assertTrue(sql.contains("agent_id IN")); + Collection values = captor.getValue().getParamNameValuePairs().values(); + assertTrue(values.contains(1L)); + assertTrue(values.contains(2L)); + assertTrue(values.contains(3L)); + } + + @Test + void agentIdOutsideVisibleRangeIsRejected() { + loginAsAgent(); + when(agentMapper.selectList(any())).thenReturn(List.of()); + when(warehouseAgentAuthMapper.selectList(any())).thenReturn(List.of()); + + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> tools.agentUserListQuery(99L, null, null, null, null, null, null, 1, 20)); + assertEquals("无权访问该代理商", e.getMessage()); + verify(userMapper, never()).selectCount(any()); + verify(userMapper, never()).selectList(any()); + } + + @Test + @SuppressWarnings("unchecked") + void agentIdWithinVisibleRangeAppliedAsExactMatch() { + loginAsAgent(); + Agent subordinate = new Agent(); + subordinate.setId(2L); + when(agentMapper.selectList(any())).thenReturn(List.of(subordinate)); + when(warehouseAgentAuthMapper.selectList(any())).thenReturn(List.of()); + when(userMapper.selectCount(any())).thenReturn(0L); + when(userMapper.selectList(any())).thenReturn(List.of()); + + tools.agentUserListQuery(2L, null, null, null, null, null, null, 1, 20); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(userMapper).selectList(captor.capture()); + String sql = captor.getValue().getSqlSegment(); + assertTrue(sql.contains("agent_id =")); + assertFalse(sql.contains("agent_id IN")); + assertTrue(captor.getValue().getParamNameValuePairs().values().contains(2L)); + } + + @Test + @SuppressWarnings("unchecked") + void deptIdFilterResolvesUserIdsFromDeptUser() { + loginAsHeadquarters(); + DeptUser deptUser = new DeptUser(); + deptUser.setUserId(7L); + deptUser.setDeptId(31L); + when(deptUserMapper.selectList(any())).thenReturn(List.of(deptUser)); + when(userMapper.selectCount(any())).thenReturn(0L); + when(userMapper.selectList(any())).thenReturn(List.of()); + + tools.agentUserListQuery(null, null, null, null, null, 31L, null, 1, 20); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(userMapper).selectList(captor.capture()); + assertTrue(captor.getValue().getSqlSegment().contains("id IN")); + assertTrue(captor.getValue().getParamNameValuePairs().values().contains(7L)); + } + + @Test + @SuppressWarnings("unchecked") + void deptIdWithoutMembersFiltersToZero() { + loginAsHeadquarters(); + when(deptUserMapper.selectList(any())).thenReturn(List.of()); + when(userMapper.selectCount(any())).thenReturn(0L); + when(userMapper.selectList(any())).thenReturn(List.of()); + + tools.agentUserListQuery(null, null, null, null, null, 404L, null, 1, 20); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(userMapper).selectList(captor.capture()); + assertTrue(captor.getValue().getSqlSegment().contains("id IN")); + assertTrue(captor.getValue().getParamNameValuePairs().values().contains(0L)); + } + + @Test + @SuppressWarnings("unchecked") + void emptyPageSkipsEnrichmentAndReturnsEmptyRecords() { + loginAsHeadquarters(); + when(userMapper.selectCount(any())).thenReturn(0L); + when(userMapper.selectList(any())).thenReturn(List.of()); + + Map result = tools.agentUserListQuery( + null, null, null, null, null, null, null, null, null); + + assertEquals(1, result.get("page")); + assertEquals(20, result.get("rows")); + assertEquals(0L, result.get("total")); + assertTrue(((List>) result.get("records")).isEmpty()); + assertNull(result.get("notice")); + verify(agentMapper, never()).selectBatchIds(anyList()); + verify(authorityMapper, never()).selectBatchIds(anyList()); + verify(districtMapper, never()).selectBatchIds(anyList()); + verify(deptUserMapper, never()).selectList(any()); + } + + @Test + @SuppressWarnings("unchecked") + void nullEnumsAndNamesStayNullWithoutFallback() { + loginAsHeadquarters(); + User agentUser = new User(); + agentUser.setId(9L); + when(userMapper.selectCount(any())).thenReturn(1L); + when(userMapper.selectList(any())).thenReturn(List.of(agentUser)); + when(deptUserMapper.selectList(any())).thenReturn(List.of()); + + Map result = tools.agentUserListQuery( + null, null, null, null, null, null, null, 1, 20); + + Map record = ((List>) result.get("records")).get(0); + assertNull(record.get("agentName")); + assertNull(record.get("authorityName")); + assertNull(record.get("districtName")); + assertNull(record.get("enterpriseTypeText")); + assertNull(record.get("approleText")); + assertNull(record.get("roleTypeText")); + assertNull(record.get("workStatusText")); + assertTrue(((List>) record.get("deptList")).isEmpty()); + } + + @Test + void springAiPublishesReviewedToolSchema() { + var callbacks = org.springframework.ai.tool.method.MethodToolCallbackProvider.builder() + .toolObjects(tools).build().getToolCallbacks(); + Map schemas = Arrays.stream(callbacks).collect(Collectors.toMap( + callback -> callback.getToolDefinition().name(), + callback -> callback.getToolDefinition().inputSchema())); + + assertEquals(1, schemas.size()); + assertTrue(schemas.containsKey("agent.user.list.query")); + String schema = schemas.get("agent.user.list.query"); + assertTrue(schema.contains("agentId")); + assertTrue(schema.contains("authorityId")); + assertTrue(schema.contains("deptId")); + assertTrue(schema.contains("workStatus")); + } + + private void loginAsHeadquarters() { + User currentUser = new User(); + currentUser.setId(1L); + currentUser.setAuthorityId(100L); + when(authorityMapper.selectById(100L)).thenReturn(authority(100L, "总部管理组", 0)); + AuthContextHolder.set(new AuthContext(null, currentUser)); + } + + private void loginAsAgent() { + User currentUser = new User(); + currentUser.setId(1000L); + currentUser.setAuthorityId(108L); + currentUser.setAgentId(1L); + when(authorityMapper.selectById(108L)).thenReturn(authority(108L, "代理管理组", 1)); + AuthContextHolder.set(new AuthContext(null, currentUser)); + } + + private static User sampleUser() { + User user = new User(); + user.setId(9L); + user.setName("张三"); + user.setNo("XQ001"); + user.setLoginAccount("zhangsan"); + user.setMobile("13800001234"); + user.setAgentId(12L); + user.setAuthorityId(20L); + user.setEnterpriseType(1); + user.setApprole(6); + user.setRoleType(0); + user.setWorkStatus(0); + user.setDeptId(31L); + user.setDeptName("运维部"); + user.setPostName("运维专员"); + user.setDistrictId(5L); + return user; + } + + private static Agent agent(Long id, String name) { + Agent agent = new Agent(); + agent.setId(id); + agent.setName(name); + return agent; + } + + private static Authority authority(Long id, String name, int isAgent) { + Authority authority = new Authority(); + authority.setId(id); + authority.setName(name); + authority.setIsagent(isAgent); + authority.setSoftDelete(0); + return authority; + } + + private static District district(Long id, String name) { + District district = new District(); + district.setId(id); + district.setDistrictName(name); + return district; + } +}