feat(mcp): add agent.user.list.query for agent-role user listing

查询 m0.user 中 enterprise_type=1 的代理角色用户,返回所属权限组、
dept_user 部门岗位明细与当前站点;行级权限复用 visibleAgentIds
(总部全量/代理商自己+直属下级+代管),mobile 原样不脱敏,
idcard/password/openid 不进白名单,不返回位置字段。
设计:doc/agent-user-list-mcp-tool-design.md(2026-10-13 王恒拍板)
This commit is contained in:
my_local_kimi_dev2
2026-10-13 11:33:19 +08:00
parent f4eb2387db
commit e1fcae2eb1
9 changed files with 909 additions and 6 deletions
+1
View File
@@ -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` 字段提示。
@@ -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)。
---
## 附录:在线查看工具清单
@@ -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 返回结构正确。
+7
View File
@@ -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;
-- 校验
@@ -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;
}
@@ -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<DeptUser> {
}
@@ -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<String, Object> 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<User> wrapper = new LambdaQueryWrapper<User>()
.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<Long> deptUserIds = deptUserMapper.selectList(new LambdaQueryWrapper<DeptUser>()
.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<User> users = userMapper.selectList(wrapper);
// 按页批量富化:代理商/权限组/站点名称 + dept_user 部门岗位明细(消除 N+1)
List<Long> agentIds = users.stream().map(User::getAgentId)
.filter(Objects::nonNull).distinct().collect(Collectors.toList());
Map<Long, String> agentNames = agentIds.isEmpty()
? Map.of()
: agentMapper.selectBatchIds(agentIds).stream()
.collect(Collectors.toMap(Agent::getId, Agent::getName, (a, b) -> a));
List<Long> authorityIds = users.stream().map(User::getAuthorityId)
.filter(Objects::nonNull).distinct().collect(Collectors.toList());
Map<Long, String> authorityNames = authorityIds.isEmpty()
? Map.of()
: authorityMapper.selectBatchIds(authorityIds).stream()
.collect(Collectors.toMap(Authority::getId, Authority::getName, (a, b) -> a));
List<Long> districtIds = users.stream().map(User::getDistrictId)
.filter(Objects::nonNull).filter(id -> id != 0).distinct().collect(Collectors.toList());
Map<Long, String> districtNames = districtIds.isEmpty()
? Map.of()
: districtMapper.selectBatchIds(districtIds).stream()
.collect(Collectors.toMap(District::getId, District::getDistrictName, (a, b) -> a));
List<Long> userIds = users.stream().map(User::getId)
.filter(Objects::nonNull).distinct().collect(Collectors.toList());
Map<Long, List<DeptUser>> deptUsersByUserId = userIds.isEmpty()
? Map.of()
: deptUserMapper.selectList(new LambdaQueryWrapper<DeptUser>()
.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<Map<String, Object>> records = new ArrayList<>();
for (User user : users) {
Map<String, Object> 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<Map<String, Object>> deptList = new ArrayList<>();
for (DeptUser deptUser : deptUsersByUserId.getOrDefault(user.getId(), List.of())) {
Map<String, Object> 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<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 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 + ")";
};
}
}
@@ -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<User> wrapper, User user, Long agentId) {
List<Long> 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 落在同一个主查询中,
@@ -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<String, Object> 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<LambdaQueryWrapper<User>> 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<String, Object> record = ((List<Map<String, Object>>) 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<Map<String, Object>> deptList = (List<Map<String, Object>>) 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<String, Object> result = tools.agentUserListQuery(
null, null, null, null, null, null, null, 1, 20);
assertEquals(0L, result.get("total"));
ArgumentCaptor<LambdaQueryWrapper<User>> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(userMapper).selectList(captor.capture());
String sql = captor.getValue().getSqlSegment();
assertTrue(sql.contains("agent_id IN"));
Collection<Object> 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<LambdaQueryWrapper<User>> 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<LambdaQueryWrapper<User>> 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<LambdaQueryWrapper<User>> 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<String, Object> 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<Map<String, Object>>) 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<String, Object> result = tools.agentUserListQuery(
null, null, null, null, null, null, null, 1, 20);
Map<String, Object> record = ((List<Map<String, Object>>) 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<Map<String, Object>>) record.get("deptList")).isEmpty());
}
@Test
void springAiPublishesReviewedToolSchema() {
var callbacks = org.springframework.ai.tool.method.MethodToolCallbackProvider.builder()
.toolObjects(tools).build().getToolCallbacks();
Map<String, String> 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;
}
}