diff --git a/xiaoqu-agent-service/README.md b/xiaoqu-agent-service/README.md index 119a07b..25a41a8 100644 --- a/xiaoqu-agent-service/README.md +++ b/xiaoqu-agent-service/README.md @@ -99,6 +99,7 @@ xiaoqu-agent-service/ | `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 | +| `hardware.cleanlog.list.query` | hardware | read | 清机记录分页查询(m0 hardware_clean_log + hardware_clean_pic,对齐老 /hardwareCleanLog/listByPage)。入参 districtId/propertyId/agentId/userId/snCode(模糊)/type/userRoleType/startTime/endTime(按 modify_time)/minCleanRfidCount/maxCleanRfidCount/page/rows;硬过滤 soft_delete=0 且 status=1,固定 id DESC;传 agentId 仍叠加行级 ACL(修老接口绕过 bug);名称 Redis multiGet + user 按页批量富化(修 N+1);type=0 用行内图片,type≠0 按页批量查图片表拼 OSS URL。详细设计见 doc/hardware-cleanlog-mcp-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 0ee41f4..885877a 100644 --- a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md +++ b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md @@ -1076,6 +1076,35 @@ --- +## 45. hardware.cleanlog.list.query + +清机记录分页查询(m0 `hardware_clean_log` 主表 + `hardware_clean_pic` 图片表,纯 MySQL),对应老 `/hardwareCleanLog/listByPage`。硬过滤 `soft_delete=0` 且 `status=1`(仅返回有效清机记录),固定 `id DESC`。 + +- 分类:hardware | 类型:读 +- 入参(全部可选):`districtId`/`propertyId`/`agentId`/`userId`(精确,正整数)、`snCode`(模糊)、 + `type`(0=大清洁 1=小清洁)、`userRoleType`(0=代理 1=物业 2=保洁)、 + `startTime`/`endTime`(yyyy-MM-dd HH:mm:ss,按 `modify_time` 即清机时间过滤,含边界, + startTime 不得晚于 endTime)、`minCleanRfidCount`/`maxCleanRfidCount`(清理卷数范围,非负,min≤max); + `page`/`rows` 默认 1/20 上限 100(超出截断 + notice)。 +- 返回 `total/page/rows/[notice]/records`,record 含 id/snCode/userId+userName/userRoleType+userRoleTypeText/ + type+typeText/cleanRfidCount/cleanRfidDays/memo/agentId+agentName/propertyId+propertyName/ + districtId+districtName/positions/workNo/assignmentId/maintenanceAgentId+maintenanceAgentName/ + maintenancePropertyId+maintenancePropertyName/poweredType+poweredTypeText(0=电源 1=电池)/ + createTime/modifyTime/beforeCleanPics/afterCleanPics。 +- 行级权限:与 hardware.list.query 同一 getByIsAgent 变体(落 `agent_id`/`property_id`); + **传了 agentId 也仍叠加 ACL**(修正老接口 agentId 绕过行权限的 bug)。 +- 富化:agentName/propertyName/districtName 走 Redis `name:Agent:*`/`name:Properyt:*`(老拼写)/`name:District:*` + multiGet 批量;userName 按页 `user_id IN` 批量查(修正老接口逐条 N+1)。 +- 图片:type=0 大清洁取行内 `before_clean_picurl`/`after_clean_picurl`;type≠0 按页 + `hardware_clean_id IN` 批量查 `hardware_clean_pic`(soft_delete=0 且 type=0 图片行),按 `order_no` + 排序拼 OSS URL `http://{bucket}.oss-cn-beijing.aliyuncs.com/{folder}{fileName}` 归入 afterCleanPics, + beforeCleanPics 恒空(老系统如此,保持)。 +- 入参校验失败抛 `INVALID_ARGUMENT`(文案透出)。 + +详细设计见 [hardware-cleanlog-mcp-design](hardware-cleanlog-mcp-design.md)。 + +--- + --- ## 附录:在线查看工具清单 diff --git a/xiaoqu-agent-service/doc/hardware-cleanlog-mcp-design.md b/xiaoqu-agent-service/doc/hardware-cleanlog-mcp-design.md new file mode 100644 index 0000000..52ab4da --- /dev/null +++ b/xiaoqu-agent-service/doc/hardware-cleanlog-mcp-design.md @@ -0,0 +1,78 @@ +# 清机记录 MCP 工具设计(hardware.cleanlog.list.query) + +对应老接口:`/hardwareCleanLog/listByPage`(xiaoqu-franchisee `HardwareCleanLogController.java:62-132`)。老系统无清机详情接口,本期只做列表查询 1 个工具。 + +## 1. 工具定义 + +- name:`hardware.cleanlog.list.query` +- category:`hardware`,kind:`read` +- 数据源:m0 `hardware_clean_log` 主表 + m0 `hardware_clean_pic` 图片表(纯 MySQL) +- Token:用户委托 Token(在线查询工具,不发服务 Token) +- scope:按 hardware 系列惯例绑 prod scope 1/2/3/5 + +## 2. 入参 + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| districtId | Long | 否 | 站点过滤(等值) | +| propertyId | Long | 否 | 物业过滤(等值) | +| agentId | Long | 否 | 代理过滤(等值)——与老接口不同,**传了也仍然叠加行级 ACL**(见 §5 差异②) | +| snCode | String | 否 | SN 码模糊匹配 | +| userId | Long | 否 | 操作人 | +| type | Integer | 否 | 清机方式:0=大清洁 1=小清洁 | +| userRoleType | Integer | 否 | 操作人身份:0=代理 1=物业 2=保洁 | +| startTime / endTime | String(yyyy-MM-dd HH:mm:ss) | 否 | 时间范围,落 `modify_time`(与老接口口径一致,见 §5 差异①) | +| minCleanRfidCount / maxCleanRfidCount | Integer | 否 | 清理卷数范围 | +| page / rows | Integer | 否 | 分页,rows 默认 20、上限 100(对齐 hardware.list.query) | + +排序固定 `id desc`(老接口默认排序,不开放 sidx/sord)。 + +## 3. 返回结构 + +扁平 `rows[]` + `page/rows/total`,每行: + +```json +{ + "id": 1, "snCode": "...", "userId": 1, "userName": "...", "userRoleType": 0, + "type": 0, "typeText": "大清洁", + "cleanRfidCount": 3, "cleanRfidDays": 7, "memo": "...", + "agentId": 1, "agentName": "...", "propertyId": 1, "propertyName": "...", + "districtId": 1, "districtName": "...", + "positions": "...", "workNo": "...", "assignmentId": 1, + "maintenanceAgentId": 1, "maintenanceAgentName": "...", + "maintenancePropertyId": 1, "maintenancePropertyName": "...", + "poweredType": 0, "createTime": "...", "modifyTime": "...", + "beforeCleanPics": ["http://..."], "afterCleanPics": ["http://..."] +} +``` + +- `userRoleType` / `type` / `poweredType` 配 `*Text` 文案(0=代理/1=物业/2=保洁;0=大清洁/1=小清洁;0=电源/1=电池)。 +- 富化(agentName/propertyName/districtName/userName)走 Redis `name:Agent:*` / `name:Properyt:*` / `name:District:*` multiGet 批量 + user 表按页 IN 批量,**修掉老接口逐条 N+1**(老 `HardwareCleanLogController.java:96-126`)。 +- 图片:type=0 用行内 `before_clean_picurl`/`after_clean_picurl`;type≠0 按页 `hardware_clean_id IN (...)` 批量查 `hardware_clean_pic`(`type=0` 图片行),按 `order_no` 排序拼 OSS URL `http://{ossbucketname}.oss-cn-beijing.aliyuncs.com/{ossfolder}{fileName}`。老接口只填 afterCleanPicS,MCP 版按 order_no 归入 after 列表、before 仅在 type=0 时有值,差异记入 §5。 + +## 4. 权限与过滤 + +- 行级权限:复用 `PermissionService.applyHardwareRowFilter`(同一 getByIsAgent 变体,落 agent_id/property_id,与清机表字段同名,语义 1:1);tenant_service 免过滤逻辑内置。 +- 硬过滤 `soft_delete=0` 且 `status=1`(保持老口径,status 其他值语义无文档,见 §5 差异③)。 +- 分页:项目无分页拦截器,`selectCount` + `LIMIT` 物理分页(禁用 selectPage)。 +- 报错:入参校验失败抛 `ToolCallException`(INVALID_ARGUMENT,文案透出,对齐 REQ-20261012-0003 之后的新工具风格)。 + +## 5. 与老接口的差异(不照搬的坑) + +1. **时间过滤字段名误导**:老接口入参叫 startCreateTime/endCreateTime("清机时间")实际查 `modify_time`。MCP 参数改名 startTime/endTime,description 明确"按记录修改时间(即清机时间)过滤"。 +2. **agentId 绕过 ACL**:老接口前端传 agentId 时完全跳过行级权限(`HardwareCleanLogController.java:73`)。MCP 版始终施加行过滤,agentId 只是额外的等值条件,无权数据返回空。 +3. **status 硬过滤**:老接口一律 `status=1` 且 status 字段无枚举文档,MCP 版保持 `status=1` 并在工具描述注明"仅返回有效清机记录"。 +4. **webId 字段裁剪**:老接口按页面字段配置裁剪返回列,MCP 无对应物,返回全字段(与 hardware.* 既有工具一致)。 +5. **图片 N+1 与 before 缺失**:批量查询修正;type≠0 时 before 列表恒空(老系统如此,保持)。 + +## 6. 改动清单 + +1. 新增实体:`me.iiv.agent.entity.m0.HardwareCleanLog`、`HardwareCleanPic`(从 publicbeanandmap `beans/b0` 复制改包名——注意 publicbeanandmap 不是 Maven 依赖,必须本地拷贝,PR #30/#31 踩坑记录)。 +2. 新增 Mapper:`me.iiv.agent.mapper.m0.HardwareCleanLogMapper`、`HardwareCleanPicMapper`。 +3. 新增工具类:`mcp/tool/HardwareCleanLogTools.java`(`@Component implements ToolObject`,`@XqTool(category="hardware", kind="read")`)。 +4. 单测:参数校验、行过滤调用、type=0/≠0 图片分支、富化批量、分页上限。 +5. seed-dev.sql 补 scope 绑定;README 与 `doc/agent-mcp-tools-reference.md` 登记。 + +## 7. 上线步骤 + +remote-deploy → ToolSyncService 自动注册 → prod 库 scope_tool 手工绑 scope 1/2/3/5 → 生产冒烟(望京 SOHO / 国大·沁里样本站点)。 diff --git a/xiaoqu-agent-service/sql/seed-dev.sql b/xiaoqu-agent-service/sql/seed-dev.sql index 92fbddd..890d4bb 100644 --- a/xiaoqu-agent-service/sql/seed-dev.sql +++ b/xiaoqu-agent-service/sql/seed-dev.sql @@ -27,10 +27,10 @@ INSERT INTO agent_scope (name, code, memo, status, create_id) SELECT '出纸机', 'hardware', 'dev 种子:出纸机查询作用域', 1, 0 WHERE NOT EXISTS (SELECT 1 FROM agent_scope WHERE code = 'hardware'); --- 作用域-工具:hardware.list.query / hardware.detail.query +-- 作用域-工具:hardware.list.query / hardware.detail.query / hardware.cleanlog.list.query INSERT INTO scope_tool (scope_id, tool_id, create_id) SELECT s.id, t.id, 0 FROM agent_scope s JOIN tool t -WHERE s.code = 'hardware' AND t.name IN ('hardware.list.query', 'hardware.detail.query') +WHERE s.code = 'hardware' AND t.name IN ('hardware.list.query', 'hardware.detail.query', 'hardware.cleanlog.list.query') AND NOT EXISTS (SELECT 1 FROM scope_tool st WHERE st.scope_id = s.id AND st.tool_id = t.id); -- 作用域:sync(SYNC 增量同步;changes 工具由 REQ-20260911-0013 注册并绑定到本作用域) @@ -56,7 +56,7 @@ WHERE NOT EXISTS (SELECT 1 FROM agent_scope WHERE code = 'ops-query'); INSERT INTO scope_tool (scope_id, tool_id, create_id) SELECT s.id, t.id, 0 FROM agent_scope s JOIN tool t -WHERE s.code = 'ops-query' AND t.name IN ('sys.ping', 'integration.context.get', 'agent.count', 'agent.list.query', 'agent.detail.query', 'property.list.query', 'property.detail.query', 'district.list.query', 'district.detail.get', 'district.paper.statistics.query', 'district.paper.position.detail.query', 'hardware.list.query', 'hardware.detail.query', 'contract.list.query', 'contract.detail.query', 'mall.order.list.query', 'mall.order.detail.query', 'mall.order.logistics.query', 'install.order.list.query', 'install.order.detail.query', 'delivery.order.list.query', 'delivery.order.detail.query', 'pickup.order.list.query', 'pickup.order.detail.query', 'relocation.order.list.query', 'relocation.order.detail.query', 'withdraw.order.list.query', 'withdraw.order.detail.query', 'warehouse.in.list.query', 'warehouse.in.detail.query', 'warehouse.out.list.query', 'warehouse.out.detail.query', 'warehouse.stock.list.query') +WHERE s.code = 'ops-query' AND t.name IN ('sys.ping', 'integration.context.get', 'agent.count', 'agent.list.query', 'agent.detail.query', 'property.list.query', 'property.detail.query', 'district.list.query', 'district.detail.get', 'district.paper.statistics.query', 'district.paper.position.detail.query', 'hardware.list.query', 'hardware.detail.query', 'hardware.cleanlog.list.query', 'contract.list.query', 'contract.detail.query', 'mall.order.list.query', 'mall.order.detail.query', 'mall.order.logistics.query', 'install.order.list.query', 'install.order.detail.query', 'delivery.order.list.query', 'delivery.order.detail.query', 'pickup.order.list.query', 'pickup.order.detail.query', 'relocation.order.list.query', 'relocation.order.detail.query', 'withdraw.order.list.query', 'withdraw.order.detail.query', 'warehouse.in.list.query', 'warehouse.in.detail.query', 'warehouse.out.list.query', 'warehouse.out.detail.query', 'warehouse.stock.list.query') AND NOT EXISTS (SELECT 1 FROM scope_tool st WHERE st.scope_id = s.id AND st.tool_id = t.id); -- 人均用纸统计仅开放给用户委托 Token:绑定全部非服务身份作用域,不进入 sync/ops-query。 diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/HardwareCleanLog.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/HardwareCleanLog.java new file mode 100644 index 0000000..bdc9d30 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/HardwareCleanLog.java @@ -0,0 +1,63 @@ +package me.iiv.agent.entity.m0; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.io.Serializable; +import java.util.Date; + +/** 设备清理日志(m0.hardware_clean_log),逐字段复制自老系统 publicbeanandmap beans/b0/HardwareCleanLog。 */ +@Getter +@Setter +@ToString +@TableName("hardware_clean_log") +public class HardwareCleanLog 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; + /** 记录修改时间(即清机时间,老接口 startCreateTime/endCreateTime 实际落此列) */ + private Date modifyTime; + private Integer softDelete; + private String snCode; + /** 操作人id */ + private Long userId; + /** 身份类型 0:代理;1:物业;2:保洁 */ + private Integer userRoleType; + /** 任务id */ + private Long assignmentId; + /** 清理卷数 */ + private Integer cleanRfidCount; + private String memo; + /** 状态(老接口恒过滤 status=1) */ + private Integer status; + /** 清机方式 0:大清洁;1:小清洁 */ + private Integer type; + /** 清理前图片(type=0 大清洁时行内直存) */ + private String beforeCleanPicurl; + /** 清理后图片(type=0 大清洁时行内直存) */ + private String afterCleanPicurl; + private Long agentId; + private Long propertyId; + private Long districtId; + /** 清机时清理天数 */ + private Integer cleanRfidDays; + /** 位置 */ + private String positions; + /** 工单编号 */ + private String workNo; + private Long maintenanceAgentId; + private String maintenanceAgentName; + private Long maintenancePropertyId; + private String maintenancePropertyName; + /** 供电方式 0:电源 1:电池 */ + private Integer poweredType; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/HardwareCleanPic.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/HardwareCleanPic.java new file mode 100644 index 0000000..d57cab7 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/HardwareCleanPic.java @@ -0,0 +1,40 @@ +package me.iiv.agent.entity.m0; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.io.Serializable; +import java.util.Date; + +/** 清机图片(m0.hardware_clean_pic),逐字段复制自老系统 publicbeanandmap beans/b0/HardwareCleanPic。 */ +@Getter +@Setter +@ToString +@TableName("hardware_clean_pic") +public class HardwareCleanPic 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; + /** 清洁记录id */ + private Long hardwareCleanId; + /** 图片状态 */ + private Integer state; + private String fileName; + /** 排序 */ + private Integer orderNo; + private String ossbucketname; + private String ossfolder; + /** 文件类型(0:图片;1:语音) */ + private Integer type; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/HardwareCleanLogMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/HardwareCleanLogMapper.java new file mode 100644 index 0000000..a956dae --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/HardwareCleanLogMapper.java @@ -0,0 +1,7 @@ +package me.iiv.agent.mapper.m0; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m0.HardwareCleanLog; + +public interface HardwareCleanLogMapper extends BaseMapper { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/HardwareCleanPicMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/HardwareCleanPicMapper.java new file mode 100644 index 0000000..f5384dd --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/HardwareCleanPicMapper.java @@ -0,0 +1,7 @@ +package me.iiv.agent.mapper.m0; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m0.HardwareCleanPic; + +public interface HardwareCleanPicMapper extends BaseMapper { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/HardwareCleanLogTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/HardwareCleanLogTools.java new file mode 100644 index 0000000..6dcc8ee --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/HardwareCleanLogTools.java @@ -0,0 +1,395 @@ +package me.iiv.agent.mcp.tool; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import me.iiv.agent.entity.m0.HardwareCleanLog; +import me.iiv.agent.entity.m0.HardwareCleanPic; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.mapper.m0.HardwareCleanLogMapper; +import me.iiv.agent.mapper.m0.HardwareCleanPicMapper; +import me.iiv.agent.mapper.m0.UserMapper; +import me.iiv.agent.mcp.McpErrorCode; +import me.iiv.agent.mcp.ToolCallException; +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.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.text.SimpleDateFormat; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.format.ResolverStyle; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 清机记录查询工具,仿老系统 HardwareCleanLogController /listByPage。 + * 修正老接口坑(设计文档 doc/hardware-cleanlog-mcp-design.md §5): + * agentId 传了仍叠加行级 ACL(老接口会绕过);富化/图片按页批量(老接口逐条 N+1); + * type=0 用行内 picurl,type≠0 批量查 hardware_clean_pic(type=0 图片行,按 order_no 拼 OSS URL)。 + */ +@Component +public class HardwareCleanLogTools implements ToolObject { + + private static final int DEFAULT_ROWS = 20; + private static final int MAX_ROWS = 100; + + /** 老接口 Redis 字典 key(HardwareCleanLogController /listByPage :99-104,Properyt 为老拼写) */ + private static final String NAME_AGENT_PREFIX = "name:Agent:"; + private static final String NAME_PROPERTY_PREFIX = "name:Properyt:"; + private static final String NAME_DISTRICT_PREFIX = "name:District:"; + + private static final DateTimeFormatter TIME_FORMAT = + DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss").withResolverStyle(ResolverStyle.STRICT); + + final HardwareCleanLogMapper hardwareCleanLogMapper; + final HardwareCleanPicMapper hardwareCleanPicMapper; + final UserMapper userMapper; + final PermissionService permissionService; + final StringRedisTemplate redisTemplate; + + public HardwareCleanLogTools(HardwareCleanLogMapper hardwareCleanLogMapper, + HardwareCleanPicMapper hardwareCleanPicMapper, + UserMapper userMapper, + PermissionService permissionService, + StringRedisTemplate redisTemplate) { + this.hardwareCleanLogMapper = hardwareCleanLogMapper; + this.hardwareCleanPicMapper = hardwareCleanPicMapper; + this.userMapper = userMapper; + this.permissionService = permissionService; + this.redisTemplate = redisTemplate; + } + + /** + * 分页查询清机记录。硬过滤 soft_delete=0 + status=1(仅返回有效清机记录),固定 id DESC, + * selectCount + LIMIT 物理分页(项目未注册 MP 分页拦截器,禁用 selectPage)。 + * 名称富化走 Redis multiGet、user 按页批量 IN、图片按页批量查,无 N+1。 + */ + @XqTool(category = "hardware", kind = "read") + @Tool(name = "hardware.cleanlog.list.query", description = "清机记录分页查询,对应老系统 /hardwareCleanLog/listByPage。" + + "仅返回有效清机记录(soft_delete=0 且 status=1),固定按 id 倒序。" + + "支持 districtId/propertyId/agentId/userId 精确匹配,snCode 模糊匹配," + + "type(0=大清洁 1=小清洁)/userRoleType(0=代理 1=物业 2=保洁)精确匹配," + + "startTime/endTime 按记录修改时间(即清机时间,modify_time)过滤,minCleanRfidCount/maxCleanRfidCount 清理卷数范围。" + + "传了 agentId 也仍然叠加当前身份的行级数据权限(修正老接口绕过 ACL 的 bug),无权数据返回空。" + + "page 默认 1,rows 默认 20、上限 100(超出截断为 100 并在 notice 提示)。" + + "每行返回清机记录全字段 + agentName/propertyName/districtName/userName 富化(Redis/按页批量)" + + "+ userRoleTypeText/typeText/poweredTypeText 中文文案 + beforeCleanPics/afterCleanPics 图片 URL 列表" + + "(type=0 大清洁取行内图片字段;type≠0 从清机图片表按 order_no 拼 OSS URL,归入 afterCleanPics,beforeCleanPics 恒空)。") + public Map hardwareCleanLogListQuery( + @ToolParam(required = false, description = "站点 id,正整数,精确匹配") Long districtId, + @ToolParam(required = false, description = "物业 id,正整数,精确匹配") Long propertyId, + @ToolParam(required = false, description = "代理 id,正整数,精确匹配;传了也仍叠加行级数据权限") Long agentId, + @ToolParam(required = false, description = "sn码,模糊匹配") String snCode, + @ToolParam(required = false, description = "操作人 id,正整数,精确匹配") Long userId, + @ToolParam(required = false, description = "清机方式:0=大清洁 1=小清洁") Integer type, + @ToolParam(required = false, description = "操作人身份:0=代理 1=物业 2=保洁") Integer userRoleType, + @ToolParam(required = false, description = "开始时间,yyyy-MM-dd HH:mm:ss,按记录修改时间(即清机时间)过滤,含") String startTime, + @ToolParam(required = false, description = "结束时间,yyyy-MM-dd HH:mm:ss,按记录修改时间(即清机时间)过滤,含") String endTime, + @ToolParam(required = false, description = "清理卷数下限,非负整数,含") Integer minCleanRfidCount, + @ToolParam(required = false, description = "清理卷数上限,非负整数,含") Integer maxCleanRfidCount, + @ToolParam(required = false, description = "页码,从 1 开始,默认 1") Integer page, + @ToolParam(required = false, description = "每页条数,默认 20,上限 100(超出截断为 100)") Integer rows) { + validatePositive(districtId, "districtId"); + validatePositive(propertyId, "propertyId"); + validatePositive(agentId, "agentId"); + validatePositive(userId, "userId"); + validateEnum(type, "type", 0, 1); + validateEnum(userRoleType, "userRoleType", 0, 1, 2); + LocalDateTime start = parseTime(startTime, "startTime"); + LocalDateTime end = parseTime(endTime, "endTime"); + if (start != null && end != null && start.isAfter(end)) { + throw invalid("startTime 不能晚于 endTime"); + } + validateCountRange(minCleanRfidCount, "minCleanRfidCount"); + validateCountRange(maxCleanRfidCount, "maxCleanRfidCount"); + if (minCleanRfidCount != null && maxCleanRfidCount != null + && minCleanRfidCount > maxCleanRfidCount) { + throw invalid("minCleanRfidCount 不能大于 maxCleanRfidCount"); + } + + User user = 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(HardwareCleanLog::getSoftDelete, 0) + .eq(HardwareCleanLog::getStatus, 1); + if (districtId != null) { + wrapper.eq(HardwareCleanLog::getDistrictId, districtId); + } + if (propertyId != null) { + wrapper.eq(HardwareCleanLog::getPropertyId, propertyId); + } + if (agentId != null) { + wrapper.eq(HardwareCleanLog::getAgentId, agentId); + } + if (StringUtils.hasText(snCode)) { + wrapper.like(HardwareCleanLog::getSnCode, snCode); + } + if (userId != null) { + wrapper.eq(HardwareCleanLog::getUserId, userId); + } + if (type != null) { + wrapper.eq(HardwareCleanLog::getType, type); + } + if (userRoleType != null) { + wrapper.eq(HardwareCleanLog::getUserRoleType, userRoleType); + } + if (start != null) { + wrapper.ge(HardwareCleanLog::getModifyTime, startTime.trim()); + } + if (end != null) { + wrapper.le(HardwareCleanLog::getModifyTime, endTime.trim()); + } + if (minCleanRfidCount != null) { + wrapper.ge(HardwareCleanLog::getCleanRfidCount, minCleanRfidCount); + } + if (maxCleanRfidCount != null) { + wrapper.le(HardwareCleanLog::getCleanRfidCount, maxCleanRfidCount); + } + permissionService.applyHardwareCleanLogRowFilter(wrapper, user); + + Long total = hardwareCleanLogMapper.selectCount(wrapper); + wrapper.orderByDesc(HardwareCleanLog::getId) + .last("LIMIT " + (long) (pageNo - 1) * pageSize + "," + pageSize); + List list = hardwareCleanLogMapper.selectList(wrapper); + + Enrichment enrichment = enrich(list); + Map> afterCleanPicsById = afterCleanPics(list); + List> records = new ArrayList<>(); + for (HardwareCleanLog log : list) { + records.add(toRecord(log, enrichment, afterCleanPicsById)); + } + + 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; + } + + /** 批量富化上下文:Redis 名称 multiGet + 本页 user 批量 IN(修老接口 :99-106 逐条 N+1)。 */ + private Enrichment enrich(List list) { + Enrichment enrichment = new Enrichment(); + if (list.isEmpty()) { + return enrichment; + } + enrichment.agentNames = redisNames(list, HardwareCleanLog::getAgentId, NAME_AGENT_PREFIX); + enrichment.propertyNames = redisNames(list, HardwareCleanLog::getPropertyId, NAME_PROPERTY_PREFIX); + enrichment.districtNames = redisNames(list, HardwareCleanLog::getDistrictId, NAME_DISTRICT_PREFIX); + List userIds = list.stream().map(HardwareCleanLog::getUserId) + .filter(Objects::nonNull).distinct().collect(Collectors.toList()); + if (!userIds.isEmpty()) { + enrichment.userNames = userMapper.selectBatchIds(userIds).stream() + .collect(Collectors.toMap(User::getId, User::getName, (a, b) -> a)); + } + return enrichment; + } + + private Map redisNames(List list, + Function idGetter, String prefix) { + List ids = list.stream().map(idGetter) + .filter(Objects::nonNull).distinct().collect(Collectors.toList()); + if (ids.isEmpty()) { + return Map.of(); + } + List keys = ids.stream().map(id -> prefix + id).collect(Collectors.toList()); + List values = redisTemplate.opsForValue().multiGet(keys); + Map names = new LinkedHashMap<>(); + for (int i = 0; i < ids.size(); i++) { + names.put(ids.get(i), values == null || i >= values.size() ? null : values.get(i)); + } + return names; + } + + /** + * type≠0 行的清理后图片:按页 hardware_clean_id IN 批量查 hardware_clean_pic + * (soft_delete=0 且 type=0 图片行),按 order_no 排序拼 OSS URL + * (老接口 :113-121 逐条查询的批量修正)。type=0 行不查图片表,用行内 picurl。 + */ + private Map> afterCleanPics(List list) { + List ids = list.stream() + .filter(log -> log.getType() == null || log.getType() != 0) + .map(HardwareCleanLog::getId) + .filter(Objects::nonNull).distinct().collect(Collectors.toList()); + if (ids.isEmpty()) { + return Map.of(); + } + List pics = hardwareCleanPicMapper.selectList(new LambdaQueryWrapper() + .eq(HardwareCleanPic::getSoftDelete, 0) + .eq(HardwareCleanPic::getType, 0) + .in(HardwareCleanPic::getHardwareCleanId, ids) + .orderByAsc(HardwareCleanPic::getHardwareCleanId) + .orderByAsc(HardwareCleanPic::getOrderNo)); + Map> urlsByCleanId = new LinkedHashMap<>(); + for (HardwareCleanPic pic : pics) { + urlsByCleanId.computeIfAbsent(pic.getHardwareCleanId(), ignored -> new ArrayList<>()) + .add(ossUrl(pic)); + } + return urlsByCleanId; + } + + /** 老接口 :118 同款 OSS URL 拼接:http://{bucket}.oss-cn-beijing.aliyuncs.com/{folder}{fileName} */ + private static String ossUrl(HardwareCleanPic pic) { + return "http://" + pic.getOssbucketname() + ".oss-cn-beijing.aliyuncs.com/" + + pic.getOssfolder() + pic.getFileName(); + } + + private Map toRecord(HardwareCleanLog log, Enrichment enrichment, + Map> afterCleanPicsById) { + List beforeCleanPics = new ArrayList<>(); + List afterCleanPics = new ArrayList<>(); + if (log.getType() != null && log.getType() == 0) { + if (StringUtils.hasText(log.getBeforeCleanPicurl())) { + beforeCleanPics.add(log.getBeforeCleanPicurl()); + } + if (StringUtils.hasText(log.getAfterCleanPicurl())) { + afterCleanPics.add(log.getAfterCleanPicurl()); + } + } else { + afterCleanPics.addAll(afterCleanPicsById.getOrDefault(log.getId(), List.of())); + } + + Map record = new LinkedHashMap<>(); + record.put("id", log.getId()); + record.put("snCode", log.getSnCode()); + record.put("userId", log.getUserId()); + record.put("userName", mapGet(enrichment.userNames, log.getUserId())); + record.put("userRoleType", log.getUserRoleType()); + record.put("userRoleTypeText", userRoleTypeText(log.getUserRoleType())); + record.put("type", log.getType()); + record.put("typeText", typeText(log.getType())); + record.put("cleanRfidCount", log.getCleanRfidCount()); + record.put("cleanRfidDays", log.getCleanRfidDays()); + record.put("memo", log.getMemo()); + record.put("agentId", log.getAgentId()); + record.put("agentName", mapGet(enrichment.agentNames, log.getAgentId())); + record.put("propertyId", log.getPropertyId()); + record.put("propertyName", mapGet(enrichment.propertyNames, log.getPropertyId())); + record.put("districtId", log.getDistrictId()); + record.put("districtName", mapGet(enrichment.districtNames, log.getDistrictId())); + record.put("positions", log.getPositions()); + record.put("workNo", log.getWorkNo()); + record.put("assignmentId", log.getAssignmentId()); + record.put("maintenanceAgentId", log.getMaintenanceAgentId()); + record.put("maintenanceAgentName", log.getMaintenanceAgentName()); + record.put("maintenancePropertyId", log.getMaintenancePropertyId()); + record.put("maintenancePropertyName", log.getMaintenancePropertyName()); + record.put("poweredType", log.getPoweredType()); + record.put("poweredTypeText", poweredTypeText(log.getPoweredType())); + record.put("createTime", format(log.getCreateTime())); + record.put("modifyTime", format(log.getModifyTime())); + record.put("beforeCleanPics", beforeCleanPics); + record.put("afterCleanPics", afterCleanPics); + return record; + } + + private static final class Enrichment { + Map agentNames = Map.of(); + Map propertyNames = Map.of(); + Map districtNames = Map.of(); + Map userNames = Map.of(); + } + + private static V mapGet(Map map, K key) { + return key == null ? null : map.get(key); + } + + private static String format(Date value) { + return value == null ? null : new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(value); + } + + private static String userRoleTypeText(Integer value) { + if (value == null) { + return null; + } + return switch (value) { + case 0 -> "代理"; + case 1 -> "物业"; + case 2 -> "保洁"; + default -> "未知(" + value + ")"; + }; + } + + private static String typeText(Integer value) { + if (value == null) { + return null; + } + return switch (value) { + case 0 -> "大清洁"; + case 1 -> "小清洁"; + default -> "未知(" + value + ")"; + }; + } + + private static String poweredTypeText(Integer value) { + if (value == null) { + return null; + } + return switch (value) { + case 0 -> "电源"; + case 1 -> "电池"; + default -> "未知(" + value + ")"; + }; + } + + private static void validatePositive(Long value, String name) { + if (value != null && value < 1) { + throw invalid(name + " 必须为正整数"); + } + } + + private static void validateEnum(Integer value, String name, int... allowed) { + if (value == null) { + return; + } + for (int candidate : allowed) { + if (value == candidate) { + return; + } + } + String options = Arrays.stream(allowed).mapToObj(String::valueOf) + .collect(Collectors.joining("、")); + throw invalid(name + " 仅支持 " + options); + } + + private static void validateCountRange(Integer value, String name) { + if (value != null && value < 0) { + throw invalid(name + " 必须为非负整数"); + } + } + + private static LocalDateTime parseTime(String value, String name) { + if (!StringUtils.hasText(value)) { + return null; + } + try { + return LocalDateTime.parse(value.trim(), TIME_FORMAT); + } catch (DateTimeParseException e) { + throw invalid(name + " 格式非法,要求 yyyy-MM-dd HH:mm:ss: " + value); + } + } + + /** 入参类错误透出具体文案给调用方(对齐 position.detail/traffic 等新工具风格),便于 LLM 自愈 */ + private static ToolCallException invalid(String message) { + return new ToolCallException(McpErrorCode.INVALID_ARGUMENT, message); + } +} 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 6b1c22a..c35601c 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 @@ -7,6 +7,7 @@ import me.iiv.agent.entity.m0.Authority; import me.iiv.agent.entity.m0.BatteryFaultLog; import me.iiv.agent.entity.m0.DeviceLifeCycle; import me.iiv.agent.entity.m0.DistrictDistrictStrategy; +import me.iiv.agent.entity.m0.HardwareCleanLog; import me.iiv.agent.entity.m0.HardwareView; import me.iiv.agent.entity.m0.Properyt; import me.iiv.agent.entity.m0.ToiletAirquality; @@ -885,6 +886,29 @@ public class PermissionService { } } + /** + * 清机记录(m0 hardware_clean_log)行权限:逐行镜像 applyHardwareRowFilter—— + * 老列表 /hardwareCleanLog/listByPage 用的就是同一 getByIsAgent 20250611 变体 + * (propertyFlag=true, className="property_id",HardwareCleanLogController.java:77), + * 落到 hardware_clean_log 的 agent_id / property_id 字段上,语义 1:1 对齐。 + */ + public void applyHardwareCleanLogRowFilter(LambdaQueryWrapper wrapper, User user) { + RowScope scope = resolveRowScope(user); + if (scope.headquarters) { + return; + } + if (scope.salesPropertyIds != null) { + wrapper.in(HardwareCleanLog::getPropertyId, scope.salesPropertyIds); + return; + } + if (!scope.managedPropertyIds.isEmpty()) { + wrapper.and(w -> w.in(HardwareCleanLog::getAgentId, scope.ids) + .or().in(HardwareCleanLog::getPropertyId, scope.managedPropertyIds)); + } else { + wrapper.in(HardwareCleanLog::getAgentId, scope.ids); + } + } + /** 电池故障日志行权限:与出纸机列表复用同一代理/物业可见范围。 */ public void applyBatteryFaultLogRowFilter(LambdaQueryWrapper wrapper, User user) { RowScope scope = resolveRowScope(user); diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/HardwareCleanLogToolsTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/HardwareCleanLogToolsTest.java new file mode 100644 index 0000000..3715860 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/HardwareCleanLogToolsTest.java @@ -0,0 +1,314 @@ +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.HardwareCleanLog; +import me.iiv.agent.entity.m0.HardwareCleanPic; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.mapper.m0.HardwareCleanLogMapper; +import me.iiv.agent.mapper.m0.HardwareCleanPicMapper; +import me.iiv.agent.mapper.m0.UserMapper; +import me.iiv.agent.mcp.McpErrorCode; +import me.iiv.agent.mcp.ToolCallException; +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 org.springframework.data.redis.core.ValueOperations; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Date; +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.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.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class HardwareCleanLogToolsTest { + + @Mock + HardwareCleanLogMapper cleanLogMapper; + @Mock + HardwareCleanPicMapper cleanPicMapper; + @Mock + UserMapper userMapper; + @Mock + PermissionService permissionService; + @Mock + StringRedisTemplate redisTemplate; + + HardwareCleanLogTools tools; + + @BeforeEach + void setUp() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), + HardwareCleanLog.class); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), + HardwareCleanPic.class); + tools = new HardwareCleanLogTools(cleanLogMapper, cleanPicMapper, userMapper, + permissionService, redisTemplate); + User user = new User(); + user.setId(7L); + AuthContextHolder.set(new AuthContext(null, user)); + } + + @AfterEach + void tearDown() { + AuthContextHolder.clear(); + } + + private ValueOperations stubRedisNames() { + ValueOperations valueOperations = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.multiGet(anyList())).thenAnswer(invocation -> { + List keys = invocation.getArgument(0); + return keys.stream().map(key -> switch (key) { + case "name:Agent:12" -> "测试代理"; + case "name:Properyt:11" -> "测试物业"; + case "name:District:5" -> "测试站点"; + default -> null; + }).collect(Collectors.toList()); + }); + return valueOperations; + } + + @Test + @SuppressWarnings("unchecked") + void listBuildsFiltersAppliesAclAndTruncatesRows() { + HardwareCleanLog log = new HardwareCleanLog(); + log.setId(9L); + log.setSnCode("D2AB001"); + log.setUserId(8L); + log.setUserRoleType(2); + log.setType(0); + log.setCleanRfidCount(3); + log.setCleanRfidDays(7); + log.setMemo("大清洁"); + log.setAgentId(12L); + log.setPropertyId(11L); + log.setDistrictId(5L); + log.setPositions("3F 男卫"); + log.setWorkNo("WO001"); + log.setAssignmentId(66L); + log.setMaintenanceAgentId(21L); + log.setMaintenanceAgentName("运维代理"); + log.setMaintenancePropertyId(22L); + log.setMaintenancePropertyName("运维物业"); + log.setPoweredType(1); + log.setBeforeCleanPicurl("http://before.jpg"); + log.setAfterCleanPicurl("http://after.jpg"); + log.setCreateTime(Date.from(Instant.parse("2026-10-30T02:00:00Z"))); + log.setModifyTime(Date.from(Instant.parse("2026-10-31T02:00:00Z"))); + when(cleanLogMapper.selectCount(any())).thenReturn(1L); + when(cleanLogMapper.selectList(any())).thenReturn(List.of(log)); + + stubRedisNames(); + User operator = new User(); + operator.setId(8L); + operator.setName("保洁员"); + when(userMapper.selectBatchIds(any())).thenReturn(List.of(operator)); + + Map result = tools.hardwareCleanLogListQuery( + 5L, 11L, 12L, "D2AB", 8L, 0, 2, + "2026-10-01 00:00:00", "2026-11-01 00:00:00", 1, 10, 1, 500); + + assertEquals(1, result.get("page")); + assertEquals(100, result.get("rows")); + assertEquals("rows 超过上限 100,已截断为 100", result.get("notice")); + + // 过滤拼装与老接口一一对应(时间落 modify_time),status=1 硬过滤 + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(cleanLogMapper).selectList(captor.capture()); + String sql = captor.getValue().getSqlSegment(); + assertTrue(sql.contains("soft_delete =")); + assertTrue(sql.contains("status =")); + assertTrue(sql.contains("district_id =")); + assertTrue(sql.contains("property_id =")); + assertTrue(sql.contains("agent_id =")); + assertTrue(sql.contains("sn_code LIKE")); + assertTrue(sql.contains("user_id =")); + assertTrue(sql.contains("type =")); + assertTrue(sql.contains("user_role_type =")); + assertTrue(sql.contains("modify_time >=")); + assertTrue(sql.contains("modify_time <=")); + assertTrue(sql.contains("clean_rfid_count >=")); + assertTrue(sql.contains("clean_rfid_count <=")); + assertTrue(sql.contains("ORDER BY id DESC")); + // agentId 传了仍叠加行级 ACL(修老接口 HardwareCleanLogController.java:73 绕过 bug) + verify(permissionService).applyHardwareCleanLogRowFilter(any(), any()); + + Map record = ((List>) result.get("records")).get(0); + assertEquals("测试代理", record.get("agentName")); + assertEquals("测试物业", record.get("propertyName")); + assertEquals("测试站点", record.get("districtName")); + assertEquals("保洁员", record.get("userName")); + assertEquals("保洁", record.get("userRoleTypeText")); + assertEquals("大清洁", record.get("typeText")); + assertEquals("电池", record.get("poweredTypeText")); + assertEquals("2026-10-31 10:00:00", record.get("modifyTime")); + + // type=0 大清洁:行内 picurl,不查图片表 + assertEquals(List.of("http://before.jpg"), record.get("beforeCleanPics")); + assertEquals(List.of("http://after.jpg"), record.get("afterCleanPics")); + verify(cleanPicMapper, never()).selectList(any()); + } + + @Test + @SuppressWarnings("unchecked") + void smallCleanBatchLoadsPicsOrderedByOrderNo() { + HardwareCleanLog log = new HardwareCleanLog(); + log.setId(9L); + log.setType(1); + when(cleanLogMapper.selectCount(any())).thenReturn(1L); + when(cleanLogMapper.selectList(any())).thenReturn(List.of(log)); + + HardwareCleanPic second = new HardwareCleanPic(); + second.setHardwareCleanId(9L); + second.setOrderNo(2); + second.setOssbucketname("bucket"); + second.setOssfolder("clean/"); + second.setFileName("b.jpg"); + HardwareCleanPic first = new HardwareCleanPic(); + first.setHardwareCleanId(9L); + first.setOrderNo(1); + first.setOssbucketname("bucket"); + first.setOssfolder("clean/"); + first.setFileName("a.jpg"); + when(cleanPicMapper.selectList(any())).thenReturn(List.of(first, second)); + + Map result = tools.hardwareCleanLogListQuery( + null, null, null, null, null, null, null, null, null, null, null, 1, 20); + + // 图片按页 IN 批量查:图片行 type=0、按 order_no 排序 + ArgumentCaptor> picCaptor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(cleanPicMapper).selectList(picCaptor.capture()); + String picSql = picCaptor.getValue().getSqlSegment(); + assertTrue(picSql.contains("soft_delete =")); + assertTrue(picSql.contains("type =")); + assertTrue(picSql.contains("hardware_clean_id IN")); + assertTrue(picSql.contains("ORDER BY hardware_clean_id ASC,order_no ASC")); + + Map record = ((List>) result.get("records")).get(0); + // type≠0:before 恒空(老系统如此),图片按 order_no 归入 after + assertEquals(List.of(), record.get("beforeCleanPics")); + assertEquals(List.of( + "http://bucket.oss-cn-beijing.aliyuncs.com/clean/a.jpg", + "http://bucket.oss-cn-beijing.aliyuncs.com/clean/b.jpg"), record.get("afterCleanPics")); + } + + @Test + void rejectsInvalidParamsBeforeQuerying() { + // 非正 id + assertThrows(ToolCallException.class, () -> tools.hardwareCleanLogListQuery( + 0L, null, null, null, null, null, null, null, null, null, null, 1, 20)); + // 枚举越界 + assertThrows(ToolCallException.class, () -> tools.hardwareCleanLogListQuery( + null, null, null, null, null, 2, null, null, null, null, null, 1, 20)); + assertThrows(ToolCallException.class, () -> tools.hardwareCleanLogListQuery( + null, null, null, null, null, null, 3, null, null, null, null, 1, 20)); + // 时间格式非法 + assertThrows(ToolCallException.class, () -> tools.hardwareCleanLogListQuery( + null, null, null, null, null, null, null, "2026/11/01", null, null, null, 1, 20)); + assertThrows(ToolCallException.class, () -> tools.hardwareCleanLogListQuery( + null, null, null, null, null, null, null, null, "2026-11-01", null, null, 1, 20)); + // startTime 晚于 endTime + assertThrows(ToolCallException.class, () -> tools.hardwareCleanLogListQuery( + null, null, null, null, null, null, null, + "2026-11-02 00:00:00", "2026-11-01 00:00:00", null, null, 1, 20)); + // 卷数范围非法 + assertThrows(ToolCallException.class, () -> tools.hardwareCleanLogListQuery( + null, null, null, null, null, null, null, null, null, -1, null, 1, 20)); + assertThrows(ToolCallException.class, () -> tools.hardwareCleanLogListQuery( + null, null, null, null, null, null, null, null, null, 10, 5, 1, 20)); + + ToolCallException exception = assertThrows(ToolCallException.class, + () -> tools.hardwareCleanLogListQuery( + 0L, null, null, null, null, null, null, null, null, null, null, 1, 20)); + assertEquals(McpErrorCode.INVALID_ARGUMENT, exception.getCode()); + assertTrue(exception.getRawMessage().contains("districtId")); + verifyNoInteractions(cleanLogMapper, cleanPicMapper, userMapper); + } + + @Test + @SuppressWarnings("unchecked") + void emptyPageSkipsEnrichment() { + when(cleanLogMapper.selectCount(any())).thenReturn(0L); + when(cleanLogMapper.selectList(any())).thenReturn(List.of()); + + Map result = tools.hardwareCleanLogListQuery( + null, null, null, null, null, null, null, null, null, null, null, null, null); + + assertEquals(0L, result.get("total")); + assertEquals(1, result.get("page")); + assertEquals(20, result.get("rows")); + assertEquals(List.of(), result.get("records")); + assertNull(result.get("notice")); + verify(permissionService).applyHardwareCleanLogRowFilter(any(), any()); + verifyNoInteractions(cleanPicMapper, userMapper, redisTemplate); + } + + @Test + @SuppressWarnings("unchecked") + void missingNamesStayNullAndUserNameFallsBackToNull() { + HardwareCleanLog log = new HardwareCleanLog(); + log.setId(9L); + log.setType(0); + log.setUserId(8L); + log.setAgentId(12L); + when(cleanLogMapper.selectCount(any())).thenReturn(1L); + when(cleanLogMapper.selectList(any())).thenReturn(List.of(log)); + + stubRedisNames(); + when(userMapper.selectBatchIds(any())).thenReturn(List.of()); + + Map result = tools.hardwareCleanLogListQuery( + null, null, null, null, null, null, null, null, null, null, null, 1, 20); + + Map record = ((List>) result.get("records")).get(0); + assertEquals("测试代理", record.get("agentName")); + assertNull(record.get("propertyName")); + assertNull(record.get("userName")); + assertNull(record.get("userRoleTypeText")); + assertEquals(List.of(), record.get("beforeCleanPics")); + assertEquals(List.of(), record.get("afterCleanPics")); + } + + @Test + void springAiPublishesReviewedSchema() { + 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("hardware.cleanlog.list.query")); + String schema = schemas.get("hardware.cleanlog.list.query"); + assertTrue(schema.contains("startTime")); + assertTrue(schema.contains("userRoleType")); + assertTrue(schema.contains("minCleanRfidCount")); + } +}