diff --git a/xiaoqu-agent-service/doc/hardware-statistics-query-mcp-tool-design.md b/xiaoqu-agent-service/doc/hardware-statistics-query-mcp-tool-design.md new file mode 100644 index 0000000..b08068d --- /dev/null +++ b/xiaoqu-agent-service/doc/hardware-statistics-query-mcp-tool-design.md @@ -0,0 +1,86 @@ +# hardware.statistics.query · MCP 工具设计 + +> 状态:待评审(评审通过后回写追加决策) +> 关联:CRM 客户经营画像 v1.1「设备台数 / 设备离线」因子 +> 日期:2026-10-20 + +## 1. 背景 + +CRM 经营画像需要"某客户有几台设备、几台离线",用于: + +- 价值分因子「设备台数」; +- 风险分因子「设备异常(离线占比)」。 + +当前下游做法是同步 `devices.changes.list`(`m0.hardware_view` 全量)后在 CRM 侧聚合,实测问题: + +| 问题 | 数据 | +|---|---| +| 未绑定物业的设备占绝大多数 | 抽样 1000 台,`property_id != 0` 的仅 **13 台(1.3%)**;未绑定用字符串 `"0"` 表示(不是空串) | +| 全量 71,034 行 | 同步 98.7% 无效行,占磁盘、拖慢同步 | +| 下游还要自己维护投影表 | 与"只读查询"的定位不符 | + +结论:下游只需要统计值,应由 MCP 直接提供聚合结果。 + +## 2. 老系统行为与坑 + +| 项 | 结论 | +|---|---| +| 老接口 | `hardware/listByPage`(`HardwareTools.hardware.list.query` 已对齐),支持 `propertyId` / `districtId` / `status` / `online` 过滤并返回 `total` | +| 无现成统计接口 | 工具目录 60 个工具中**没有**站点设备数统计工具;`district.paper.statistics.query` 只统计用纸(米/人次/换纸卷数),不含设备数 | +| 数据源 | `m0.hardware_view` 是**当前状态视图**,无历史 → 统计只反映调用时刻 | +| 状态枚举 | `status`:1=在线 2=故障 3=离线;`soft_delete=0` 为有效行 | +| 未绑定表示 | `property_id = 0`(字符串/数值零),不是空 | + +## 3. 决策表 + +| 决策 | 选择 | 备选 | 理由 | +|---|---|---|---| +| 工具名 | `hardware.statistics.query` | `device.statistics.query` / `hardware.count.query` | 与既有 `hardware.*`、`district.paper.statistics.query` 命名一致 | +| 入参 | `propertyId`(单个)或 `propertyIds`(≤20) | 全租户一次返回 | 下游按客户查询;全量返回等于把聚合成本转给调用方,且无分页语义 | +| 默认是否含未绑定 | **不含**(`includeUnbound=false`) | 含 | 未绑定无法归属客户,计入会污染"客户设备数" | +| 返回结构 | `items[]` 每物业一行:`total/online/fault/offline` + `data_cutoff_at` + `notice` | 只返回四个总数 | 下游需要多客户时一次拿全 | +| 权限 | 复用 `PermissionService.applyHardwareRowFilter` | 新写过滤逻辑 | 与 `hardware.list.query` 完全一致,避免第二套权限口径 | +| 查询方式 | 每个物业 4 次 `selectCount` | 单条 `GROUP BY` | 项目未注册分页拦截器;`LambdaQueryWrapper` 无 clone 先例,重建 wrapper 更安全。20 物业 = 80 次轻量 count,可接受 | + +## 4. 参数契约 + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `propertyId` | Long | 否(与 propertyIds 二选一) | 物业 id,正整数 | +| `propertyIds` | Long[] | 否 | 物业 id 列表,上限 20;重复值去重 | +| `includeUnbound` | Boolean | 否 | 默认 false;true 时计入 `property_id = 0` 的设备 | + +校验:两者至少提供一个;id 必须为正;数量 ≤ 20;否则 `IllegalArgumentException`。 + +## 5. 返回契约 + +```json +{ + "items": [ + {"property_id": 17, "total": 10, "online": 6, "fault": 2, "offline": 2} + ], + "data_cutoff_at": "2026-10-20T08:40:00Z", + "notice": "数据源为 hardware_view 当前状态视图,仅反映调用时刻;未绑定物业设备未计入" +} +``` + +## 6. 与老系统差异 + +- 老系统无对应统计接口(只有分页列表 + total);本工具是其聚合封装,口径一致(同一张视图、同一套 status 枚举、同一套行级权限)。 +- 不返回设备明细,避免下游误当列表用。 + +## 7. 测试要点 + +| # | 用例 | +|---|---| +| 1 | 未提供 propertyId / propertyIds → 报错 | +| 2 | 非正整数 / 超过 20 个 → 报错 | +| 3 | 单物业 → 4 个计数正确、返回 `data_cutoff_at` | +| 4 | 每个计数都调用行级权限过滤 | +| 5 | propertyIds 重复值去重 | + +## 8. 待评审确认 + +1. 是否同意"默认排除未绑定设备"? +2. 单次上限 20 个物业是否合适? +3. 是否需要同时返回 `district_id` 维度(当前只按物业)? diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/DeviceStatisticsTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/DeviceStatisticsTools.java new file mode 100644 index 0000000..4c79946 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/DeviceStatisticsTools.java @@ -0,0 +1,125 @@ +package me.iiv.agent.mcp.tool; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import me.iiv.agent.entity.m0.HardwareView; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.mapper.m0.HardwareViewMapper; +import me.iiv.agent.mcp.ToolObject; +import me.iiv.agent.mcp.XqTool; +import me.iiv.agent.permission.PermissionService; +import me.iiv.agent.security.AuthContextHolder; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 按物业统计出纸机数量(总数 / 在线 / 故障 / 离线)。 + * + *

为什么需要它:下游(CRM 经营画像)只需要"某客户有几台设备、几台离线", + * 如果为此同步整张 hardware_view(7 万行,其中 98% 未绑定物业),代价远大于收益。 + * 本工具一次调用直接给出统计结果,下游按客户查询 + 缓存即可。

+ * + *

口径与 hardware.list.query 完全一致: + *

+ */ +@Component +public class DeviceStatisticsTools implements ToolObject { + + /** 单次最多统计 20 个物业,避免一次请求打出上百条 count 查询。 */ + private static final int MAX_PROPERTIES = 20; + + final HardwareViewMapper hardwareViewMapper; + final PermissionService permissionService; + + public DeviceStatisticsTools(HardwareViewMapper hardwareViewMapper, PermissionService permissionService) { + this.hardwareViewMapper = hardwareViewMapper; + this.permissionService = permissionService; + } + + @XqTool(category = "hardware", kind = "read") + @Tool(name = "hardware.statistics.query", description = "按物业统计出纸机数量:总数、在线、故障、离线。" + + "一次最多统计 20 个物业(propertyId 与 propertyIds 二选一);" + + "默认排除未绑定物业的设备(property_id=0),includeUnbound=true 时计入。" + + "数据来源 m0.hardware_view 当前状态视图,仅反映调用时刻,不是历史统计。" + + "行级权限与 hardware.list.query 一致(当前用户可见范围)。") + public Map hardwareStatisticsQuery( + @ToolParam(required = false, description = "物业 id,正整数;与 propertyIds 二选一") Long propertyId, + @ToolParam(required = false, description = "物业 id 列表,上限 20 个") List propertyIds, + @ToolParam(required = false, description = "是否统计未绑定物业的设备,默认 false") Boolean includeUnbound) { + + validatePositive(propertyId, "propertyId"); + List targets = new ArrayList<>(); + if (propertyId != null) { + targets.add(propertyId); + } + if (propertyIds != null) { + for (Long id : propertyIds) { + validatePositive(id, "propertyIds"); + if (id != null && !targets.contains(id)) { + targets.add(id); + } + } + } + if (targets.isEmpty()) { + throw new IllegalArgumentException("propertyId 或 propertyIds 至少提供一个"); + } + if (targets.size() > MAX_PROPERTIES) { + throw new IllegalArgumentException("一次最多统计 " + MAX_PROPERTIES + " 个物业"); + } + + boolean withUnbound = Boolean.TRUE.equals(includeUnbound); + User user = AuthContextHolder.get().getUser(); + + List> items = new ArrayList<>(targets.size()); + for (Long target : targets) { + Map row = new LinkedHashMap<>(); + row.put("property_id", target); + // 每个计数重建 wrapper:项目内没有 clone() 使用先例,避免依赖未验证的克隆语义。 + row.put("total", countFor(user, target, withUnbound, null)); + row.put("online", countFor(user, target, withUnbound, 1)); + row.put("fault", countFor(user, target, withUnbound, 2)); + row.put("offline", countFor(user, target, withUnbound, 3)); + items.add(row); + } + + Map result = new LinkedHashMap<>(); + result.put("items", items); + result.put("data_cutoff_at", Instant.now().toString()); + result.put("notice", "数据源为 hardware_view 当前状态视图,仅反映调用时刻;未绑定物业设备" + + (withUnbound ? "已计入" : "未计入")); + return result; + } + + /** 统计某物业在指定状态下的设备数;status 为 null 表示不按状态过滤。 */ + private Long countFor(User user, Long target, boolean withUnbound, Integer status) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(HardwareView::getSoftDelete, 0) + .eq(HardwareView::getPropertyId, target); + if (!withUnbound) { + wrapper.gt(HardwareView::getPropertyId, 0); + } + if (status != null) { + wrapper.eq(HardwareView::getStatus, status); + } + permissionService.applyHardwareRowFilter(wrapper, user); + return hardwareViewMapper.selectCount(wrapper); + } + + private static void validatePositive(Long value, String name) { + if (value != null && value < 1) { + throw new IllegalArgumentException(name + " 必须为正整数"); + } + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/DeviceStatisticsToolsTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/DeviceStatisticsToolsTest.java new file mode 100644 index 0000000..2bb1040 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/DeviceStatisticsToolsTest.java @@ -0,0 +1,114 @@ +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.HardwareView; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.mapper.m0.HardwareViewMapper; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** hardware.statistics.query:参数校验 / 计数口径 / 权限过滤调用。 */ +@ExtendWith(MockitoExtension.class) +class DeviceStatisticsToolsTest { + + @Mock + HardwareViewMapper hardwareViewMapper; + + @Mock + PermissionService permissionService; + + DeviceStatisticsTools tools; + + @BeforeEach + void setUp() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), HardwareView.class); + tools = new DeviceStatisticsTools(hardwareViewMapper, permissionService); + User user = new User(); + user.setId(7L); + AuthContextHolder.set(new AuthContext(null, user)); + } + + @AfterEach + void tearDown() { + AuthContextHolder.clear(); + } + + @Test + void rejectsRequestWithoutAnyProperty() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> tools.hardwareStatisticsQuery(null, null, false)); + assertTrue(error.getMessage().contains("至少提供")); + } + + @Test + void rejectsNonPositivePropertyId() { + assertThrows(IllegalArgumentException.class, + () -> tools.hardwareStatisticsQuery(0L, null, false)); + } + + @Test + void rejectsMoreThanTwentyProperties() { + List ids = new ArrayList<>(); + for (long i = 1; i <= 21; i++) { + ids.add(i); + } + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> tools.hardwareStatisticsQuery(null, ids, false)); + assertTrue(error.getMessage().contains("20")); + } + + @Test + @SuppressWarnings("unchecked") + void returnsFourCountsPerPropertyAndAppliesRowFilter() { + // 依次返回 total/online/fault/offline + when(hardwareViewMapper.selectCount(any(LambdaQueryWrapper.class))) + .thenReturn(10L, 6L, 2L, 2L); + + Map result = tools.hardwareStatisticsQuery(17L, null, false); + + List> items = (List>) result.get("items"); + assertEquals(1, items.size()); + Map row = items.get(0); + assertEquals(17L, row.get("property_id")); + assertEquals(10L, row.get("total")); + assertEquals(6L, row.get("online")); + assertEquals(2L, row.get("fault")); + assertEquals(2L, row.get("offline")); + assertTrue(result.containsKey("data_cutoff_at")); + // 总/在线/故障/离线 各一次查询 = 4 次,且每次都过行级权限 + verify(permissionService, times(4)).applyHardwareRowFilter(any(LambdaQueryWrapper.class), any(User.class)); + } + + @Test + void deduplicatesPropertyIdsAndAcceptsList() { + when(hardwareViewMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(1L); + + Map result = tools.hardwareStatisticsQuery(null, List.of(17L, 17L), false); + + @SuppressWarnings("unchecked") + List> items = (List>) result.get("items"); + assertEquals(1, items.size(), "重复物业只统计一次"); + } +}