From ff452d89d8dd9dd7660e77571f880539b016397b Mon Sep 17 00:00:00 2001 From: xiaoqu Date: Thu, 10 Sep 2026 12:35:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20=E6=96=B0=E5=A2=9E=E5=87=BA?= =?UTF-8?q?=E7=BA=B8=E6=9C=BA=E6=9F=A5=E8=AF=A2=E5=B7=A5=E5=85=B7=20hardwa?= =?UTF-8?q?re.list/detail.query=20(REQ-20260910-0012)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../doc/agent-mcp-tools-reference.md | 56 +++ xiaoqu-agent-service/sql/seed-dev.sql | 11 + .../me/iiv/agent/entity/m0/HardwareView.java | 295 +++++++++++++ .../me/iiv/agent/entity/m0/Networkcard.java | 118 +++++ .../agent/mapper/m0/HardwareViewMapper.java | 7 + .../agent/mapper/m0/NetworkcardMapper.java | 7 + .../me/iiv/agent/mcp/tool/HardwareTools.java | 413 ++++++++++++++++++ .../agent/permission/PermissionService.java | 79 +++- .../iiv/agent/mcp/tool/HardwareToolsTest.java | 283 ++++++++++++ ...ermissionServiceHardwareRowFilterTest.java | 197 +++++++++ 10 files changed, 1454 insertions(+), 12 deletions(-) create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/HardwareView.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/Networkcard.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/HardwareViewMapper.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/NetworkcardMapper.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/HardwareTools.java create mode 100644 xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/HardwareToolsTest.java create mode 100644 xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceHardwareRowFilterTest.java diff --git a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md index d643a6f..8a167c8 100644 --- a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md +++ b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md @@ -279,6 +279,62 @@ --- +## 10. hardware.list.query + +分页查询出纸机列表,对应老系统 `POST /hardware/listByPage`(HardwareViewController)。 + +- 分类:hardware | 类型:读 +- 数据来源:m0 库 `hardware_view` 视图,固定 `soft_delete = 0`、`id DESC` +- 行级权限(`PermissionService.applyHardwareRowFilter`,与站点同一 `getByIsAgent(propertyFlag=true, className="property_id")` 变体): + - 总部(isagent=0):全部; + - 代理商(isagent=1):`agent_id IN (自己 + 直属下级 + warehouse_agent_auth 代管代理)`,有代管物业授权时再 `OR property_id IN (agent_manage_district_auth 代管物业)`; + - 销售部门非负责人:`property_id IN (自己创建的物业 + 代管代理名下物业)`,与上一条互斥。 +- 富化(修正老接口 N+1,按页批量):`agentName`/`propertyName`/`districtName` 走 Redis key `name:Agent:{id}` / `name:Properyt:{id}` / `name:District:{id}` multiGet;断纸状态 `piStatus` 批量读 `Assignment:{snCode}:29`(无 key 返回 0);本页非空 `imsi` 一次性 IN 查 `networkcard` 表。返回 `hardware_view` 全字段,不做字段级权限裁剪。 + +**入参**: + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| snCode | string | 否 | sn码,模糊匹配 | +| deviceType | number | 否 | 设备类型:0=厕纸机 1=擦手纸机 | +| status | number | 否 | 设备状态:1=在线 2=故障 3=离线 | +| online | number | 否 | 上下线状态:1=上线 2=下线 | +| lockMachineState | number | 否 | 锁机状态:0=未锁机 1=已锁机 | +| agentId | number | 否 | 代理商 id,正整数,精确匹配 | +| propertyId | number | 否 | 物业 id,正整数,精确匹配 | +| districtId | number | 否 | 站点 id,正整数,精确匹配 | +| buildingId | number | 否 | 楼 id,正整数,精确匹配 | +| floor | string | 否 | 楼层,精确匹配 | +| imsi | string | 否 | imsi码,精确匹配 | +| page | number | 否 | 页码,从 1 开始,默认 1 | +| rows | number | 否 | 每页条数,默认 20,上限 100(超出截断为 100 并在 notice 提示) | + +**出参 records 字段**:`hardware_view` 全字段(snCode、deviceType、status/online/lockMachineState 状态四元组、sysversion/firmwareVersion/voiceVersion/advertiseVersion、imsi、电量/纸量/电池族、各功能开关、memo 等,时间格式 `yyyy-MM-dd HH:mm:ss`)+ 富化字段 `agentName`/`propertyName`/`districtName`/`piStatus` + 物联网卡字段 `factoryType`/`carrier`/`dataBalance`/`dataUsage`/`iccid`/`msisdn`(按 imsi 匹配,无卡为 null)。 + +--- + +## 11. hardware.detail.query + +按 snCode 或 id 查询出纸机详情(二选一必填),行级权限与 `hardware.list.query` 一致。 + +- 分类:hardware | 类型:读 +- 数据来源:m0 库 `hardware_view` + `district_district_strategy`(站点信息)+ `networkcard`(物联网卡) +- 设备不存在或无权查看时 `record=null` 并在 notice 说明(两者不区分,不泄露设备是否存在)。 + +**入参**: + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| snCode | string | 条件必填 | sn码,精确匹配;与 id 二选一 | +| id | number | 条件必填 | 设备 id,正整数,精确匹配;与 snCode 二选一 | + +**出参**: + +- `record`:hardware_view 全字段 + 与列表同一套富化(agentName/propertyName/districtName、piStatus、物联网卡 factoryType/carrier/dataBalance/dataUsage/iccid/msisdn)。 +- `record.site`(关联站点,district 表现有字段):`districtId`、`districtName`、`province`、`city`、`countyseat`、`address`、`contact`、`phone`、`buildingId`、`floor`。注意 district 表无经纬度和楼名字段,楼座/楼层取 hardware_view。 + +--- + ## 附录:在线查看工具清单 ```bash diff --git a/xiaoqu-agent-service/sql/seed-dev.sql b/xiaoqu-agent-service/sql/seed-dev.sql index a1aa9b1..c2d915b 100644 --- a/xiaoqu-agent-service/sql/seed-dev.sql +++ b/xiaoqu-agent-service/sql/seed-dev.sql @@ -21,6 +21,17 @@ SELECT s.id, t.id, 0 FROM agent_scope s JOIN tool t WHERE s.code = 'operation' AND t.name IN ('sys.ping', 'agent.count', 'agent.list.query', 'property.list.query', 'agent.detail.query', 'property.detail.query', 'mall.order.list.query', 'mall.order.detail.query', 'mall.order.logistics.query') AND NOT EXISTS (SELECT 1 FROM scope_tool st WHERE st.scope_id = s.id AND st.tool_id = t.id); +-- 作用域:hardware(出纸机查询) +INSERT INTO agent_scope (name, code, memo, status, create_id) +SELECT '出纸机', 'hardware', 'dev 种子:出纸机查询作用域', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM agent_scope WHERE code = 'hardware'); + +-- 作用域-工具:hardware.list.query / hardware.detail.query +INSERT INTO scope_tool (scope_id, tool_id, create_id) +SELECT s.id, t.id, 0 FROM agent_scope s JOIN tool t +WHERE s.code = 'hardware' AND t.name IN ('hardware.list.query', 'hardware.detail.query') + AND NOT EXISTS (SELECT 1 FROM scope_tool st WHERE st.scope_id = s.id AND st.tool_id = t.id); + COMMIT; -- 校验 diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/HardwareView.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/HardwareView.java new file mode 100644 index 0000000..ee58f42 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/HardwareView.java @@ -0,0 +1,295 @@ +package me.iiv.agent.entity.m0; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.io.Serializable; +import java.util.Date; + +/** + * hardware_view 视图(m0 库),字段 1:1 照搬老项目 + * xiaoqu-publicbeanandmap beans/b0/HardwareView.java。 + */ +@Getter +@Setter +@ToString +@TableName("hardware_view") +public class HardwareView implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** 创建id */ + private Long createId; + + /** 创建时间 */ + private Date createTime; + + /** 修改id */ + private Long modifyId; + + /** 修改时间 */ + private Date modifyTime; + + /** 数据状态 */ + private Integer softDelete; + + /** 设备编号 */ + private String no; + + /** 位置信息 */ + private String address; + + /** 位置id */ + private Long positionId; + + /** 位置信息 */ + private String position; + + /** sim卡号 */ + private String simNo; + + /** sn码 */ + private String snCode; + + /** 状态1:在线;2:故障;3:离线 */ + private Integer status; + + /** 设备随机码 */ + private String checkCode; + + /** 固件版本 */ + private String firmwareVersion; + + /** 厕所id */ + private Long toiletId; + + /** 楼id */ + private Long buildingId; + + /** 楼层 */ + private String floor; + + /** 区域id */ + private Long districtId; + + /** 物业id */ + private Long propertyId; + + /** 代理id */ + private Long agentId; + + /** 设备型号 */ + private String version; + + /** 备注 */ + private String memo; + + /** 保洁id */ + private Long userId; + + /** 电力支持 */ + private Integer powerSupply; + + /** 感应抽纸长度 */ + private Integer senseLength; + + /** 扫码抽纸长度 */ + private Integer scanLength; + + /** 出纸模式 */ + private Integer paperType; + + /** 纸规格 */ + private String paperNorms; + + /** 感应出纸次数 */ + private Integer senseCount; + + /** 扫码出纸次数 */ + private Integer scanCount; + + /** 剩余次数 */ + private Integer paperSurplus; + + /** 出纸计数 */ + private Long paperCount; + + /** sn码 */ + private String temSnCode; + + /** rfid */ + private String rfid; + + /** 硬件名称 */ + private String name; + + /** 安装场景0:厕位内:1:厕位外:2其它 */ + private Integer installScenes; + + /** 安装方式0:免打孔 1:打孔 */ + private Integer installMode; + + /** 供电方式 0:电源 1:电池 */ + private Integer poweredType; + + /** 设备上下线状态 */ + private Integer online; + + /** 蓝牙名称 */ + private String bluetoothName; + + /** imsi码 */ + private String imsi; + + /** 蓝牙地址 */ + private String bluetoothMac; + + /** 上线时间 */ + private Date onlineTime; + + /** 芯片号 */ + private String chip; + + /** 主板号 */ + private String motherboard; + + private String sysversion; + + /** 感应出纸次数 */ + private Integer paperState; + + /** 扫码出纸次数 */ + private Integer powerState; + + private Integer positionState; + + /** 剩余次数 */ + private Integer useState; + + /** 质保期 */ + private Integer warrantyPeriod; + + /** 运行模式 */ + private Integer runlMode; + + /** 锁状态 */ + private Integer lockState; + + /** 设备类型 */ + private Integer deviceType; + + /** 清理用纸卷计数 */ + private Integer cleanRfidCount; + + /** 工程模式0:关;1:开 */ + private Integer projectModel; + + /** 出纸模式0:关闭;1:开启 */ + private Integer paperOutState; + + /** 机芯更换时间 */ + private Date machineChipTime; + + /** 机芯编号 */ + private String machineChipCode; + + /** 当前机芯编号 */ + private String currentChip; + + /** 锁定机器状态0:未锁定;1:已锁定 */ + private Integer lockMachineState; + + /** 语音版本 */ + private String voiceVersion; + + /** 校准标识0:否;1:是 */ + private Integer calibratFlag; + + /** 满卷标识0:否;1:是 */ + private Integer fullRollFlag; + + /** 中校准锁定开关 */ + private Integer middleFlag; + + /** 低校准锁定开关 */ + private Integer lowerFlag; + + /** 纸校准锁定开关 */ + private Integer paperLockFlag; + + /** 低故障开关(0:故障;1:正常) */ + private Integer lowerFaultFlag; + + /** 中故障开关(0:故障;1:正常) */ + private Integer middleFaultFlag; + + /** 当前锁定状态出纸次数 */ + private Integer paperLockCount; + + /** 保修截止日期 */ + private Date warrantyExpirationTime; + + /** 首次上线时间 */ + private Date firstOnTime; + + /** 广告机版本 */ + private String advertiseVersion; + + /** 非法纸禁出状态(0:禁出;1:未禁出) */ + private Integer illegalPaperDisableState; + + /** 是否绑定NFC 0:否;1:是 */ + private Integer nfcFlag; + + /** nfcId */ + private String nfcId; + + /** 定时语音播放0:关;1:开 */ + private Integer timerVoiceFlag; + + /** 设定时长(分) */ + private Integer timerVoiceTiming; + + /** 定时出纸0:关;1:开 */ + private Integer timerPaperoutFlag; + + /** 设定时长(分) */ + private Integer timerPaperoutTiming; + + /** 定时出纸长度 */ + private Integer timerPaperoutLength; + + /** 电池电量异常标记0:否;1:是 */ + private Integer batteryFaultFlag; + + /** 电池温度 */ + private Integer batteryTemp; + + /** 网络信号 */ + private Integer networkSignal; + + /** 充电循环次数 */ + private Integer batteryCircle; + + /** 生产备注 */ + private String prdMemo; + + /** 备注 */ + private String runMemo; + + /** 电流 */ + private Integer current; + + /** 电压 */ + private Integer voltage; + + /** 计租标记(0否1是) */ + private Integer rentFlag; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/Networkcard.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/Networkcard.java new file mode 100644 index 0000000..9057b27 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/Networkcard.java @@ -0,0 +1,118 @@ +package me.iiv.agent.entity.m0; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; + +/** + * networkcard 联网卡列表(m0 库),字段照搬老项目 + * xiaoqu-publicbeanandmap beans/b0/Networkcard.java。 + */ +@Getter +@Setter +@ToString +@TableName("networkcard") +public class Networkcard implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** 创建id */ + private Long createId; + + /** 创建时间 */ + private Date createTime; + + /** 修改id */ + private Long modifyId; + + /** 修改时间 */ + private Date modifyTime; + + /** 数据状态 */ + private Integer softDelete; + + /** sim卡号 */ + private String simNo; + + /** 设备编号 */ + private String sncode; + + /** 绑定状态 */ + private Integer state; + + /** msisdn */ + private String msisdn; + + /** iccid */ + private String iccid; + + /** imsi码 */ + private String imsi; + + /** 本月流量 */ + private BigDecimal dataUsage; + + /** 运营商 */ + private String carrier; + + /** 套餐大小 */ + private Integer dataPlan; + + /** 卡状态 */ + private Integer accountStatus; + + /** 计费结束日期 */ + private Integer expiryDate; + + /** 激活状态 0,1 */ + private Integer active; + + /** 激活日期 */ + private Integer activeDate; + + /** 剩余流量,单位M */ + private BigDecimal dataBalance; + + /** 出库日期 */ + private Integer outboundDate; + + /** 区域/站id */ + private Integer districtId; + + /** 所属物业 */ + private Long propertyId; + + /** 代理商id */ + private Long agentId; + + /** 楼id */ + private Long buildingId; + + /** 位置id */ + private Long positionId; + + /** 厕所id */ + private Long toiletId; + + /** 厂商类型0:厂商1;1:厂商2.... */ + private Integer factoryType; + + /** 卡状态 */ + private String status; + + /** 所属机构名称 */ + private String orgName; + + /** 卡当前套餐名称 */ + private String iratePlanName; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/HardwareViewMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/HardwareViewMapper.java new file mode 100644 index 0000000..5fb6248 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/HardwareViewMapper.java @@ -0,0 +1,7 @@ +package me.iiv.agent.mapper.m0; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m0.HardwareView; + +public interface HardwareViewMapper extends BaseMapper { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/NetworkcardMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/NetworkcardMapper.java new file mode 100644 index 0000000..7c4caf8 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/NetworkcardMapper.java @@ -0,0 +1,7 @@ +package me.iiv.agent.mapper.m0; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m0.Networkcard; + +public interface NetworkcardMapper extends BaseMapper { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/HardwareTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/HardwareTools.java new file mode 100644 index 0000000..7bcf22c --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/HardwareTools.java @@ -0,0 +1,413 @@ +package me.iiv.agent.mcp.tool; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import me.iiv.agent.entity.m0.DistrictDistrictStrategy; +import me.iiv.agent.entity.m0.HardwareView; +import me.iiv.agent.entity.m0.Networkcard; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.mapper.m0.DistrictDistrictStrategyMapper; +import me.iiv.agent.mapper.m0.HardwareViewMapper; +import me.iiv.agent.mapper.m0.NetworkcardMapper; +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.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; + +/** + * 出纸机查询工具,仿老系统 HardwareViewController /listByPage。 + * 富化修正老接口 N+1:名称/断纸状态走 Redis multiGet,物联网卡按页 IN 批量查。 + */ +@Component +public class HardwareTools implements ToolObject { + + private static final int DEFAULT_ROWS = 20; + private static final int MAX_ROWS = 100; + + /** 老接口 Redis 字典 key(HardwareViewController /listByPage) */ + 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:"; + /** 断纸(缺纸工单)状态 key,老接口 :29 为纸状态业务类型;无 key 视为 0 */ + private static final String ASSIGNMENT_KEY_TEMPLATE = "Assignment:%s:29"; + + final HardwareViewMapper hardwareViewMapper; + final NetworkcardMapper networkcardMapper; + final DistrictDistrictStrategyMapper districtMapper; + final PermissionService permissionService; + final StringRedisTemplate redisTemplate; + + public HardwareTools(HardwareViewMapper hardwareViewMapper, NetworkcardMapper networkcardMapper, + DistrictDistrictStrategyMapper districtMapper, + PermissionService permissionService, StringRedisTemplate redisTemplate) { + this.hardwareViewMapper = hardwareViewMapper; + this.networkcardMapper = networkcardMapper; + this.districtMapper = districtMapper; + this.permissionService = permissionService; + this.redisTemplate = redisTemplate; + } + + @XqTool(category = "hardware", kind = "read") + @Tool(name = "hardware.list.query", description = "分页查询当前用户可见的出纸机列表,对应老系统 /hardware/listByPage。" + + "支持 snCode 模糊匹配,deviceType、status、online、lockMachineState、agentId、propertyId、districtId、buildingId、floor、imsi 精确匹配。" + + "page 默认 1,rows 默认 20、上限 100,固定按 id 倒序。" + + "agentName/propertyName/districtName 与断纸状态 piStatus 使用 Redis 批量富化,物联网卡信息按页批量查询。" + + "返回 hardware_view 全字段,不做字段级权限裁剪。") + public Map hardwareListQuery( + @ToolParam(required = false, description = "sn码,模糊匹配") String snCode, + @ToolParam(required = false, description = "设备类型:0=厕纸机 1=擦手纸机") Integer deviceType, + @ToolParam(required = false, description = "设备状态:1=在线 2=故障 3=离线") Integer status, + @ToolParam(required = false, description = "上下线状态:1=上线 2=下线") Integer online, + @ToolParam(required = false, description = "锁机状态:0=未锁机 1=已锁机") Integer lockMachineState, + @ToolParam(required = false, description = "代理商 id,正整数,精确匹配") Long agentId, + @ToolParam(required = false, description = "物业 id,正整数,精确匹配") Long propertyId, + @ToolParam(required = false, description = "站点 id,正整数,精确匹配") Long districtId, + @ToolParam(required = false, description = "楼 id,正整数,精确匹配") Long buildingId, + @ToolParam(required = false, description = "楼层,精确匹配") String floor, + @ToolParam(required = false, description = "imsi码,精确匹配") String imsi, + @ToolParam(required = false, description = "页码,从 1 开始,默认 1") Integer page, + @ToolParam(required = false, description = "每页条数,默认 20,上限 100") Integer rows) { + validateEnum(deviceType, "deviceType", 0, 1); + validateEnum(status, "status", 1, 2, 3); + validateEnum(online, "online", 1, 2); + validateEnum(lockMachineState, "lockMachineState", 0, 1); + validatePositive(agentId, "agentId"); + validatePositive(propertyId, "propertyId"); + validatePositive(districtId, "districtId"); + validatePositive(buildingId, "buildingId"); + + 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(HardwareView::getSoftDelete, 0); + if (StringUtils.hasText(snCode)) { + wrapper.like(HardwareView::getSnCode, snCode); + } + if (deviceType != null) { + wrapper.eq(HardwareView::getDeviceType, deviceType); + } + if (status != null) { + wrapper.eq(HardwareView::getStatus, status); + } + if (online != null) { + wrapper.eq(HardwareView::getOnline, online); + } + if (lockMachineState != null) { + wrapper.eq(HardwareView::getLockMachineState, lockMachineState); + } + if (agentId != null) { + wrapper.eq(HardwareView::getAgentId, agentId); + } + if (propertyId != null) { + wrapper.eq(HardwareView::getPropertyId, propertyId); + } + if (districtId != null) { + wrapper.eq(HardwareView::getDistrictId, districtId); + } + if (buildingId != null) { + wrapper.eq(HardwareView::getBuildingId, buildingId); + } + if (StringUtils.hasText(floor)) { + wrapper.eq(HardwareView::getFloor, floor); + } + if (StringUtils.hasText(imsi)) { + wrapper.eq(HardwareView::getImsi, imsi); + } + permissionService.applyHardwareRowFilter(wrapper, user); + + Long total = hardwareViewMapper.selectCount(wrapper); + wrapper.orderByDesc(HardwareView::getId) + .last("LIMIT " + (long) (pageNo - 1) * pageSize + "," + pageSize); + List list = hardwareViewMapper.selectList(wrapper); + + List> records = new ArrayList<>(); + Enrichment enrichment = enrich(list); + for (HardwareView hardware : list) { + records.add(toRecord(hardware, enrichment)); + } + + 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; + } + + @XqTool(category = "hardware", kind = "read") + @Tool(name = "hardware.detail.query", description = "按 snCode 或 id 查询当前用户可见的出纸机详情,二选一必填。" + + "返回 hardware_view 全字段 + agentName/propertyName/districtName、断纸状态 piStatus、" + + "物联网卡信息(factoryType/carrier/dataBalance/dataUsage/iccid/msisdn)+ 关联站点信息(districtName、省市区县、详细地址、联系人、楼座/楼层)。" + + "设备不存在或无权访问时返回 record=null 并在 notice 说明。") + public Map hardwareDetailQuery( + @ToolParam(required = false, description = "sn码,精确匹配;与 id 二选一必填") String snCode, + @ToolParam(required = false, description = "设备 id,正整数,精确匹配;与 snCode 二选一必填") Long id) { + if (!StringUtils.hasText(snCode) && id == null) { + throw new IllegalArgumentException("snCode 与 id 至少填一个"); + } + validatePositive(id, "id"); + + User user = AuthContextHolder.get().getUser(); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(HardwareView::getSoftDelete, 0); + if (StringUtils.hasText(snCode)) { + wrapper.eq(HardwareView::getSnCode, snCode); + } else { + wrapper.eq(HardwareView::getId, id); + } + permissionService.applyHardwareRowFilter(wrapper, user); + HardwareView hardware = hardwareViewMapper.selectOne(wrapper); + + Map result = new LinkedHashMap<>(); + if (hardware == null) { + result.put("record", null); + result.put("notice", "设备不存在或无权访问"); + return result; + } + Map record = toRecord(hardware, enrich(List.of(hardware))); + record.put("site", siteInfo(hardware)); + result.put("record", record); + return result; + } + + /** 站点信息:district 表现有字段(无经纬度/楼名字段);楼座/楼层取 hardware_view。 */ + private Map siteInfo(HardwareView hardware) { + Map site = new LinkedHashMap<>(); + DistrictDistrictStrategy district = hardware.getDistrictId() == null + ? null : districtMapper.selectById(hardware.getDistrictId()); + site.put("districtId", hardware.getDistrictId()); + site.put("districtName", district == null ? null : district.getDistrictName()); + site.put("province", district == null ? null : district.getProvince()); + site.put("city", district == null ? null : district.getCity()); + site.put("countyseat", district == null ? null : district.getCountyseat()); + site.put("address", district == null ? null : district.getAddress()); + site.put("contact", district == null ? null : district.getContact()); + site.put("phone", district == null ? null : district.getPhone()); + site.put("buildingId", hardware.getBuildingId()); + site.put("floor", hardware.getFloor()); + return site; + } + + /** 批量富化上下文:Redis 名称/断纸状态 + 本页物联网卡,一次查询替代老接口 N+1。 */ + private Enrichment enrich(List list) { + Enrichment enrichment = new Enrichment(); + if (list.isEmpty()) { + return enrichment; + } + enrichment.agentNames = redisNames(list, HardwareView::getAgentId, NAME_AGENT_PREFIX); + enrichment.propertyNames = redisNames(list, HardwareView::getPropertyId, NAME_PROPERTY_PREFIX); + enrichment.districtNames = redisNames(list, HardwareView::getDistrictId, NAME_DISTRICT_PREFIX); + enrichment.piStatus = piStatus(list); + enrichment.networkcards = networkcards(list); + 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; + } + + private Map piStatus(List list) { + List snCodes = list.stream().map(HardwareView::getSnCode) + .filter(StringUtils::hasText).distinct().collect(Collectors.toList()); + if (snCodes.isEmpty()) { + return Map.of(); + } + List keys = snCodes.stream() + .map(sn -> String.format(ASSIGNMENT_KEY_TEMPLATE, sn)).collect(Collectors.toList()); + List values = redisTemplate.opsForValue().multiGet(keys); + Map status = new LinkedHashMap<>(); + for (int i = 0; i < snCodes.size(); i++) { + String value = values == null || i >= values.size() ? null : values.get(i); + status.put(snCodes.get(i), value == null ? 0 : Integer.parseInt(value)); + } + return status; + } + + /** 本页非空 imsi 一次性 IN 查 networkcard(老接口按条 limit 1,取首条语义保留)。 */ + private Map networkcards(List list) { + List imsIs = list.stream().map(HardwareView::getImsi) + .filter(StringUtils::hasText).distinct().collect(Collectors.toList()); + if (imsIs.isEmpty()) { + return Map.of(); + } + return networkcardMapper.selectList(new LambdaQueryWrapper() + .eq(Networkcard::getSoftDelete, 0) + .in(Networkcard::getImsi, imsIs)) + .stream().collect(Collectors.toMap(Networkcard::getImsi, card -> card, (a, b) -> a)); + } + + private Map toRecord(HardwareView h, Enrichment enrichment) { + Map record = new LinkedHashMap<>(); + record.put("id", h.getId()); + record.put("no", h.getNo()); + record.put("name", h.getName()); + record.put("snCode", h.getSnCode()); + record.put("temSnCode", h.getTemSnCode()); + record.put("checkCode", h.getCheckCode()); + record.put("deviceType", h.getDeviceType()); + record.put("status", h.getStatus()); + record.put("online", h.getOnline()); + record.put("lockMachineState", h.getLockMachineState()); + record.put("agentId", h.getAgentId()); + record.put("agentName", mapGet(enrichment.agentNames, h.getAgentId())); + record.put("propertyId", h.getPropertyId()); + record.put("propertyName", mapGet(enrichment.propertyNames, h.getPropertyId())); + record.put("districtId", h.getDistrictId()); + record.put("districtName", mapGet(enrichment.districtNames, h.getDistrictId())); + record.put("buildingId", h.getBuildingId()); + record.put("floor", h.getFloor()); + record.put("toiletId", h.getToiletId()); + record.put("positionId", h.getPositionId()); + record.put("position", h.getPosition()); + record.put("address", h.getAddress()); + record.put("version", h.getVersion()); + record.put("sysversion", h.getSysversion()); + record.put("firmwareVersion", h.getFirmwareVersion()); + record.put("voiceVersion", h.getVoiceVersion()); + record.put("advertiseVersion", h.getAdvertiseVersion()); + record.put("imsi", h.getImsi()); + record.put("simNo", h.getSimNo()); + Networkcard card = mapGet(enrichment.networkcards, h.getImsi()); + record.put("factoryType", card == null ? null : card.getFactoryType()); + record.put("carrier", card == null ? null : card.getCarrier()); + record.put("dataBalance", card == null ? null : card.getDataBalance()); + record.put("dataUsage", card == null ? null : card.getDataUsage()); + record.put("iccid", card == null ? null : card.getIccid()); + record.put("msisdn", card == null ? null : card.getMsisdn()); + record.put("piStatus", h.getSnCode() == null ? 0 + : enrichment.piStatus.getOrDefault(h.getSnCode(), 0)); + record.put("userId", h.getUserId()); + record.put("powerSupply", h.getPowerSupply()); + record.put("batteryTemp", h.getBatteryTemp()); + record.put("batteryCircle", h.getBatteryCircle()); + record.put("batteryFaultFlag", h.getBatteryFaultFlag()); + record.put("networkSignal", h.getNetworkSignal()); + record.put("current", h.getCurrent()); + record.put("voltage", h.getVoltage()); + record.put("paperSurplus", h.getPaperSurplus()); + record.put("paperCount", h.getPaperCount()); + record.put("paperState", h.getPaperState()); + record.put("powerState", h.getPowerState()); + record.put("positionState", h.getPositionState()); + record.put("useState", h.getUseState()); + record.put("paperType", h.getPaperType()); + record.put("paperNorms", h.getPaperNorms()); + record.put("senseLength", h.getSenseLength()); + record.put("scanLength", h.getScanLength()); + record.put("senseCount", h.getSenseCount()); + record.put("scanCount", h.getScanCount()); + record.put("rfid", h.getRfid()); + record.put("cleanRfidCount", h.getCleanRfidCount()); + record.put("installScenes", h.getInstallScenes()); + record.put("installMode", h.getInstallMode()); + record.put("poweredType", h.getPoweredType()); + record.put("bluetoothName", h.getBluetoothName()); + record.put("bluetoothMac", h.getBluetoothMac()); + record.put("chip", h.getChip()); + record.put("motherboard", h.getMotherboard()); + record.put("onlineTime", format(h.getOnlineTime())); + record.put("warrantyPeriod", h.getWarrantyPeriod()); + record.put("warrantyExpirationTime", format(h.getWarrantyExpirationTime())); + record.put("firstOnTime", format(h.getFirstOnTime())); + record.put("runlMode", h.getRunlMode()); + record.put("lockState", h.getLockState()); + record.put("projectModel", h.getProjectModel()); + record.put("paperOutState", h.getPaperOutState()); + record.put("machineChipTime", format(h.getMachineChipTime())); + record.put("machineChipCode", h.getMachineChipCode()); + record.put("currentChip", h.getCurrentChip()); + record.put("calibratFlag", h.getCalibratFlag()); + record.put("fullRollFlag", h.getFullRollFlag()); + record.put("middleFlag", h.getMiddleFlag()); + record.put("lowerFlag", h.getLowerFlag()); + record.put("paperLockFlag", h.getPaperLockFlag()); + record.put("paperLockCount", h.getPaperLockCount()); + record.put("lowerFaultFlag", h.getLowerFaultFlag()); + record.put("middleFaultFlag", h.getMiddleFaultFlag()); + record.put("illegalPaperDisableState", h.getIllegalPaperDisableState()); + record.put("nfcFlag", h.getNfcFlag()); + record.put("nfcId", h.getNfcId()); + record.put("timerVoiceFlag", h.getTimerVoiceFlag()); + record.put("timerVoiceTiming", h.getTimerVoiceTiming()); + record.put("timerPaperoutFlag", h.getTimerPaperoutFlag()); + record.put("timerPaperoutTiming", h.getTimerPaperoutTiming()); + record.put("timerPaperoutLength", h.getTimerPaperoutLength()); + record.put("prdMemo", h.getPrdMemo()); + record.put("runMemo", h.getRunMemo()); + record.put("rentFlag", h.getRentFlag()); + record.put("createId", h.getCreateId()); + record.put("createTime", format(h.getCreateTime())); + record.put("modifyTime", format(h.getModifyTime())); + record.put("memo", h.getMemo()); + return record; + } + + private static final class Enrichment { + Map agentNames = Map.of(); + Map propertyNames = Map.of(); + Map districtNames = Map.of(); + Map piStatus = Map.of(); + Map networkcards = 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 void validatePositive(Long value, String name) { + if (value != null && value < 1) { + throw new IllegalArgumentException(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 new IllegalArgumentException(name + " 仅支持 " + options); + } +} 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 fe503dd..a63ef49 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 @@ -5,6 +5,7 @@ import me.iiv.agent.entity.m0.Agent; import me.iiv.agent.entity.m0.AgentManageDistrictAuth; import me.iiv.agent.entity.m0.Authority; import me.iiv.agent.entity.m0.DistrictDistrictStrategy; +import me.iiv.agent.entity.m0.HardwareView; import me.iiv.agent.entity.m0.Properyt; import me.iiv.agent.entity.m0.User; import me.iiv.agent.entity.m1.MallGoodsOrder; @@ -301,12 +302,28 @@ public class PermissionService { } /** - * 站点行权限:1:1 对齐老接口 districtlistByPage 使用的 - * getByIsAgent(..., propertyFlag=true, className="property_id")。 + * 行权限范围解析结果(老 getByIsAgent propertyFlag=true 变体的公共中间态)。 + * headquarters=true 表示 isagent!=1(总部),调用方不加任何过滤。 + * salesPropertyIds 非 null 表示销售部门非负责人互斥分支:只按物业 id 集合过滤 + * (空集合已归一为 [0],保证查不到数据)。 */ - public void applyDistrictRowFilter(LambdaQueryWrapper wrapper, User user) { + private static final class RowScope { + boolean headquarters; + List ids; + List salesPropertyIds; + List managedPropertyIds; + } + + /** + * 解析当前用户的行权限范围,district / hardware 等 propertyFlag=true、 + * className="property_id" 变体共用(老 WrapperUtils.java:679-764)。 + * 返回 headquarters 标记的 scope;调用方各自把结果落到自己的 wrapper 字段上。 + */ + private RowScope resolveRowScope(User user) { + RowScope scope = new RowScope(); if (!isAgentAuthority(user.getAuthorityId())) { - return; + scope.headquarters = true; + return scope; } if (user.getAgentId() == null) { throw new IllegalStateException("代理商用户未关联代理商,拒绝访问业务数据"); @@ -315,6 +332,7 @@ public class PermissionService { List ids = new ArrayList<>(subordinateAndSelfIds(user)); List agentlist = managedAuths(user.getId()); ids.addAll(agentlist.stream().map(WarehouseAgentAuth::getManagerAgentId).collect(Collectors.toList())); + scope.ids = ids; if (isSalesNonLeader(user)) { List propertyIds = properytMapper.selectList(new LambdaQueryWrapper() @@ -332,17 +350,54 @@ public class PermissionService { .stream().map(Properyt::getId).collect(Collectors.toList())); } } - wrapper.in(DistrictDistrictStrategy::getPropertyId, - propertyIds.isEmpty() ? List.of(0L) : propertyIds); - return; + scope.salesPropertyIds = propertyIds.isEmpty() ? List.of(0L) : propertyIds; + return scope; } - List managedPropertyIds = managedPropertyIds(ids); - if (!managedPropertyIds.isEmpty()) { - wrapper.and(w -> w.in(DistrictDistrictStrategy::getAgentId, ids) - .or().in(DistrictDistrictStrategy::getPropertyId, managedPropertyIds)); + scope.managedPropertyIds = managedPropertyIds(ids); + return scope; + } + + /** + * 站点行权限:1:1 对齐老接口 districtlistByPage 使用的 + * getByIsAgent(..., propertyFlag=true, className="property_id")。 + */ + public void applyDistrictRowFilter(LambdaQueryWrapper wrapper, User user) { + RowScope scope = resolveRowScope(user); + if (scope.headquarters) { + return; + } + if (scope.salesPropertyIds != null) { + wrapper.in(DistrictDistrictStrategy::getPropertyId, scope.salesPropertyIds); + return; + } + if (!scope.managedPropertyIds.isEmpty()) { + wrapper.and(w -> w.in(DistrictDistrictStrategy::getAgentId, scope.ids) + .or().in(DistrictDistrictStrategy::getPropertyId, scope.managedPropertyIds)); } else { - wrapper.in(DistrictDistrictStrategy::getAgentId, ids); + wrapper.in(DistrictDistrictStrategy::getAgentId, scope.ids); + } + } + + /** + * 出纸机(hardware_view)行权限:与站点同一 getByIsAgent 变体 + * (propertyFlag=true, className="property_id",老 HardwareViewController /listByPage), + * 仅落到 HardwareView 的 agent_id / property_id 字段上。 + */ + public void applyHardwareRowFilter(LambdaQueryWrapper wrapper, User user) { + RowScope scope = resolveRowScope(user); + if (scope.headquarters) { + return; + } + if (scope.salesPropertyIds != null) { + wrapper.in(HardwareView::getPropertyId, scope.salesPropertyIds); + return; + } + if (!scope.managedPropertyIds.isEmpty()) { + wrapper.and(w -> w.in(HardwareView::getAgentId, scope.ids) + .or().in(HardwareView::getPropertyId, scope.managedPropertyIds)); + } else { + wrapper.in(HardwareView::getAgentId, scope.ids); } } } diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/HardwareToolsTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/HardwareToolsTest.java new file mode 100644 index 0000000..0962593 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/HardwareToolsTest.java @@ -0,0 +1,283 @@ +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.DistrictDistrictStrategy; +import me.iiv.agent.entity.m0.HardwareView; +import me.iiv.agent.entity.m0.Networkcard; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.mapper.m0.DistrictDistrictStrategyMapper; +import me.iiv.agent.mapper.m0.HardwareViewMapper; +import me.iiv.agent.mapper.m0.NetworkcardMapper; +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.math.BigDecimal; +import java.util.Arrays; +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 HardwareToolsTest { + + @Mock + HardwareViewMapper hardwareViewMapper; + @Mock + NetworkcardMapper networkcardMapper; + @Mock + DistrictDistrictStrategyMapper districtMapper; + @Mock + PermissionService permissionService; + @Mock + StringRedisTemplate redisTemplate; + + HardwareTools tools; + User user; + + @BeforeEach + void setUp() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), + HardwareView.class); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), + Networkcard.class); + tools = new HardwareTools(hardwareViewMapper, networkcardMapper, districtMapper, + permissionService, redisTemplate); + user = new User(); + user.setId(7L); + AuthContextHolder.set(new AuthContext(null, user)); + } + + @AfterEach + void tearDown() { + AuthContextHolder.clear(); + } + + @Test + @SuppressWarnings("unchecked") + void listBuildsLegacyFiltersAndTruncatesRows() { + HardwareView hardware = new HardwareView(); + hardware.setId(9L); + hardware.setSnCode("D2AB001"); + hardware.setAgentId(12L); + hardware.setPropertyId(11L); + hardware.setDistrictId(5L); + hardware.setImsi("460001234567890"); + when(hardwareViewMapper.selectCount(any())).thenReturn(1L); + when(hardwareViewMapper.selectList(any())).thenReturn(List.of(hardware)); + + ValueOperations valueOperations = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + // name:Agent:12 / name:Properyt:11 / name:District:5 / Assignment:D2AB001:29 + 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" -> "测试站点"; + case "Assignment:D2AB001:29" -> "1"; + default -> null; + }).collect(Collectors.toList()); + }); + Networkcard card = new Networkcard(); + card.setImsi("460001234567890"); + card.setFactoryType(1); + card.setCarrier("移动"); + card.setDataBalance(new BigDecimal("512.5")); + card.setDataUsage(new BigDecimal("128.0")); + card.setIccid("8986001234"); + card.setMsisdn("14412345678"); + when(networkcardMapper.selectList(any())).thenReturn(List.of(card)); + + Map result = tools.hardwareListQuery( + "D2AB", 0, 1, 1, 0, 12L, 11L, 5L, 3L, "3F", "460001234567890", 1, 500); + + assertEquals(1, result.get("page")); + assertEquals(100, result.get("rows")); + assertEquals("rows 超过上限 100,已截断为 100", result.get("notice")); + + // 过滤拼装与老接口一一对应 + ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(hardwareViewMapper).selectList(captor.capture()); + String sql = captor.getValue().getSqlSegment(); + assertTrue(sql.contains("sn_code LIKE")); + assertTrue(sql.contains("device_type =")); + assertTrue(sql.contains("status =")); + assertTrue(sql.contains("online =")); + assertTrue(sql.contains("lock_machine_state =")); + assertTrue(sql.contains("agent_id =")); + assertTrue(sql.contains("property_id =")); + assertTrue(sql.contains("district_id =")); + assertTrue(sql.contains("building_id =")); + assertTrue(sql.contains("floor =")); + assertTrue(sql.contains("imsi =")); + assertTrue(sql.contains("soft_delete =")); + assertTrue(captor.getValue().getSqlSegment().contains("ORDER BY id DESC")); + verify(permissionService).applyHardwareRowFilter(any(), any()); + + // 富化:名称、断纸状态、物联网卡 + Map record = ((List>) result.get("records")).get(0); + assertEquals("测试代理", record.get("agentName")); + assertEquals("测试物业", record.get("propertyName")); + assertEquals("测试站点", record.get("districtName")); + assertEquals(1, record.get("piStatus")); + assertEquals(1, record.get("factoryType")); + assertEquals("移动", record.get("carrier")); + assertEquals(new BigDecimal("512.5"), record.get("dataBalance")); + assertEquals(new BigDecimal("128.0"), record.get("dataUsage")); + assertEquals("8986001234", record.get("iccid")); + assertEquals("14412345678", record.get("msisdn")); + } + + @Test + @SuppressWarnings("unchecked") + void listDefaultsPiStatusToZeroWhenAssignmentKeyMissing() { + HardwareView hardware = new HardwareView(); + hardware.setId(9L); + hardware.setSnCode("D2AB002"); + when(hardwareViewMapper.selectCount(any())).thenReturn(1L); + when(hardwareViewMapper.selectList(any())).thenReturn(List.of(hardware)); + + ValueOperations valueOperations = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.multiGet(anyList())).thenReturn(List.of()); + + Map result = tools.hardwareListQuery( + null, null, null, null, null, null, null, null, null, null, null, 1, 20); + + Map record = ((List>) result.get("records")).get(0); + assertEquals(0, record.get("piStatus")); + assertNull(record.get("agentName")); + // 无 imsi 不查物联网卡 + verify(networkcardMapper, never()).selectList(any()); + } + + @Test + void detailRequiresSnCodeOrId() { + assertThrows(IllegalArgumentException.class, () -> tools.hardwareDetailQuery(null, null)); + assertThrows(IllegalArgumentException.class, () -> tools.hardwareDetailQuery(" ", 0L)); + verifyNoInteractions(hardwareViewMapper); + } + + @Test + void detailReturnsNullRecordWithNoticeWhenMissingOrUnauthorized() { + when(hardwareViewMapper.selectOne(any())).thenReturn(null); + + Map result = tools.hardwareDetailQuery("D2AB404", null); + + assertNull(result.get("record")); + assertEquals("设备不存在或无权访问", result.get("notice")); + verify(permissionService).applyHardwareRowFilter(any(), any()); + } + + @Test + @SuppressWarnings("unchecked") + void detailBySnCodeReturnsFullRecordWithSiteAndEnrichment() { + HardwareView hardware = new HardwareView(); + hardware.setId(9L); + hardware.setSnCode("D2AB001"); + hardware.setDeviceType(0); + hardware.setStatus(1); + hardware.setOnline(1); + hardware.setLockMachineState(0); + hardware.setSysversion("v1.2.3"); + hardware.setFirmwareVersion("hw-9"); + hardware.setImsi("460001234567890"); + hardware.setDistrictId(5L); + hardware.setBuildingId(3L); + hardware.setFloor("3F"); + when(hardwareViewMapper.selectOne(any())).thenReturn(hardware); + + ValueOperations valueOperations = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.multiGet(anyList())).thenReturn(List.of()); + when(networkcardMapper.selectList(any())).thenReturn(List.of()); + + DistrictDistrictStrategy district = new DistrictDistrictStrategy(); + district.setId(5L); + district.setDistrictName("测试站点"); + district.setProvince("广东省"); + district.setCity("深圳市"); + district.setCountyseat("南山区"); + district.setAddress("科技园"); + district.setContact("张三"); + district.setPhone("13800000000"); + when(districtMapper.selectById(5L)).thenReturn(district); + + Map result = tools.hardwareDetailQuery("D2AB001", null); + + Map record = (Map) result.get("record"); + assertEquals("D2AB001", record.get("snCode")); + assertEquals(0, record.get("deviceType")); + assertEquals(1, record.get("status")); + assertEquals(1, record.get("online")); + assertEquals(0, record.get("lockMachineState")); + assertEquals("v1.2.3", record.get("sysversion")); + assertEquals("hw-9", record.get("firmwareVersion")); + assertEquals(0, record.get("piStatus")); + + Map site = (Map) record.get("site"); + assertEquals(5L, site.get("districtId")); + assertEquals("测试站点", site.get("districtName")); + assertEquals("广东省", site.get("province")); + assertEquals("深圳市", site.get("city")); + assertEquals("南山区", site.get("countyseat")); + assertEquals("科技园", site.get("address")); + assertEquals("张三", site.get("contact")); + assertEquals("13800000000", site.get("phone")); + assertEquals(3L, site.get("buildingId")); + assertEquals("3F", site.get("floor")); + } + + @Test + void listRejectsUnknownEnumsBeforeQuerying() { + assertThrows(IllegalArgumentException.class, () -> tools.hardwareListQuery( + null, 2, null, null, null, null, null, null, null, null, null, 1, 20)); + assertThrows(IllegalArgumentException.class, () -> tools.hardwareListQuery( + null, null, 0, null, null, null, null, null, null, null, null, 1, 20)); + assertThrows(IllegalArgumentException.class, () -> tools.hardwareListQuery( + null, null, null, 3, null, null, null, null, null, null, null, 1, 20)); + assertThrows(IllegalArgumentException.class, () -> tools.hardwareListQuery( + null, null, null, null, 2, null, null, null, null, null, null, 1, 20)); + verifyNoInteractions(hardwareViewMapper); + } + + @Test + void springAiPublishesBothReviewedToolSchemas() { + 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(2, schemas.size()); + assertTrue(schemas.containsKey("hardware.list.query")); + assertTrue(schemas.containsKey("hardware.detail.query")); + assertTrue(schemas.get("hardware.list.query").contains("lockMachineState")); + assertTrue(schemas.get("hardware.detail.query").contains("snCode")); + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceHardwareRowFilterTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceHardwareRowFilterTest.java new file mode 100644 index 0000000..771fd26 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceHardwareRowFilterTest.java @@ -0,0 +1,197 @@ +package me.iiv.agent.permission; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import me.iiv.agent.entity.m0.Agent; +import me.iiv.agent.entity.m0.AgentManageDistrictAuth; +import me.iiv.agent.entity.m0.Authority; +import me.iiv.agent.entity.m0.HardwareView; +import me.iiv.agent.entity.m0.Properyt; +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.ProperytMapper; +import me.iiv.agent.mapper.m2.WarehouseAgentAuthMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.Collection; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** PermissionService.applyHardwareRowFilter 直接单测:hardware_view 行级权限各分支(与站点同一 getByIsAgent 变体)。 */ +@ExtendWith(MockitoExtension.class) +class PermissionServiceHardwareRowFilterTest { + + @Mock + AuthorityMapper authorityMapper; + @Mock + AgentMapper agentMapper; + @Mock + WarehouseAgentAuthMapper warehouseAgentAuthMapper; + @Mock + AgentManageDistrictAuthMapper agentManageDistrictAuthMapper; + @Mock + ProperytMapper properytMapper; + @Mock + StringRedisTemplate redisTemplate; + @Mock + HashOperations hashOperations; + + PermissionService permissionService; + + @BeforeEach + void setUp() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), + HardwareView.class); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), + Properyt.class); + permissionService = new PermissionService(authorityMapper, agentMapper, + warehouseAgentAuthMapper, agentManageDistrictAuthMapper, properytMapper, redisTemplate); + } + + @Test + void headquartersHardwareFilterAddsNoBoundary() { + User user = user(46L, 1L, null); + when(authorityMapper.selectById(1L)).thenReturn(authority(1L, 0)); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + permissionService.applyHardwareRowFilter(wrapper, user); + + assertTrue(wrapper.getSqlSegment().isBlank()); + } + + @Test + void agentHardwareFilterUsesSelfSubordinateAndManagedAgents() { + User user = user(1000L, 108L, 1L); + when(authorityMapper.selectById(108L)).thenReturn(authority(108L, 1)); + 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(agentManageDistrictAuthMapper.selectList(any())).thenReturn(List.of()); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + permissionService.applyHardwareRowFilter(wrapper, user); + + String sql = wrapper.getSqlSegment(); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(sql.contains("agent_id IN")); + assertFalse(sql.contains("property_id")); + assertTrue(values.contains(1L)); + assertTrue(values.contains(2L)); + assertTrue(values.contains(3L)); + } + + @Test + void agentHardwareFilterAddsManagedPropertyOrBranch() { + User user = user(1000L, 108L, 1L); + when(authorityMapper.selectById(108L)).thenReturn(authority(108L, 1)); + when(agentMapper.selectList(any())).thenReturn(List.of()); + when(warehouseAgentAuthMapper.selectList(any())).thenReturn(List.of()); + AgentManageDistrictAuth districtAuth = new AgentManageDistrictAuth(); + districtAuth.setPropertyId(44L); + when(agentManageDistrictAuthMapper.selectList(any())).thenReturn(List.of(districtAuth)); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + permissionService.applyHardwareRowFilter(wrapper, user); + + String sql = wrapper.getSqlSegment(); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(sql.contains("agent_id IN")); + assertTrue(sql.contains("property_id IN")); + assertTrue(sql.contains("OR")); + assertTrue(values.contains(1L)); + assertTrue(values.contains(44L)); + } + + @Test + void salesNonLeaderHardwareFilterUsesOwnCreatedPropertyIds() { + User user = user(1000L, 108L, 1L); + user.setDeptId(5L); + user.setPostId(7L); + when(authorityMapper.selectById(108L)).thenReturn(authority(108L, 1)); + when(agentMapper.selectList(any())).thenReturn(List.of()); + when(warehouseAgentAuthMapper.selectList(any())).thenReturn(List.of()); + when(redisTemplate.opsForHash()).thenReturn(hashOperations); + when(hashOperations.get("Dictionary", "authDeptId")).thenReturn(",5,"); + when(hashOperations.get("Dictionary", "authDeptPostId")).thenReturn("8,"); + Properyt property = new Properyt(); + property.setId(55L); + when(properytMapper.selectList(any())).thenReturn(List.of(property)); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + permissionService.applyHardwareRowFilter(wrapper, user); + + String sql = wrapper.getSqlSegment(); + assertTrue(sql.contains("property_id IN")); + assertFalse(sql.contains("agent_id")); + assertTrue(wrapper.getParamNameValuePairs().values().contains(55L)); + verifyNoInteractions(agentManageDistrictAuthMapper); + } + + @Test + void salesNonLeaderWithoutVisiblePropertiesFiltersToZero() { + User user = user(1000L, 108L, 1L); + user.setDeptId(5L); + user.setPostId(7L); + when(authorityMapper.selectById(108L)).thenReturn(authority(108L, 1)); + when(agentMapper.selectList(any())).thenReturn(List.of()); + when(warehouseAgentAuthMapper.selectList(any())).thenReturn(List.of()); + when(redisTemplate.opsForHash()).thenReturn(hashOperations); + when(hashOperations.get("Dictionary", "authDeptId")).thenReturn(",5,"); + when(hashOperations.get("Dictionary", "authDeptPostId")).thenReturn("8,"); + when(properytMapper.selectList(any())).thenReturn(List.of()); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + permissionService.applyHardwareRowFilter(wrapper, user); + + String sql = wrapper.getSqlSegment(); + assertTrue(sql.contains("property_id IN")); + assertFalse(sql.contains("agent_id")); + assertTrue(wrapper.getParamNameValuePairs().values().contains(0L)); + verifyNoInteractions(agentManageDistrictAuthMapper); + } + + @Test + void agentUserWithoutAgentIdIsDeniedForHardwareFilter() { + User user = user(1000L, 108L, null); + when(authorityMapper.selectById(108L)).thenReturn(authority(108L, 1)); + + assertThrows(IllegalStateException.class, + () -> permissionService.applyHardwareRowFilter(new LambdaQueryWrapper<>(), user)); + } + + private static Authority authority(Long id, int isAgent) { + Authority authority = new Authority(); + authority.setId(id); + authority.setIsagent(isAgent); + authority.setSoftDelete(0); + return authority; + } + + private static User user(Long id, Long authorityId, Long agentId) { + User user = new User(); + user.setId(id); + user.setAuthorityId(authorityId); + user.setAgentId(agentId); + return user; + } +}