From 91d8e6e7a8d08c04166874ee78db770ffc54d164 Mon Sep 17 00:00:00 2001
From: xiaoqu-ai
Date: Tue, 20 Oct 2026 16:34:08 +0800
Subject: [PATCH] =?UTF-8?q?feat(mcp):=20hardware.statistics.query=20?=
=?UTF-8?q?=E6=8C=89=E7=89=A9=E4=B8=9A=E7=BB=9F=E8=AE=A1=E8=AE=BE=E5=A4=87?=
=?UTF-8?q?=E6=95=B0/=E5=9C=A8=E7=BA=BF/=E6=95=85=E9=9A=9C/=E7=A6=BB?=
=?UTF-8?q?=E7=BA=BF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
背景:CRM 经营画像需要"每客户设备数、离线数"。当前做法同步 devices 全量
(71,034 行,抽样 1000 台仅 13 台绑定物业,其余 property_id="0"),代价远大于收益。
本工具直接返回聚合结果:
- 入参 propertyId 或 propertyIds(≤20,去重),includeUnbound 默认 false
- 返回每物业 total/online/fault/offline + data_cutoff_at + notice
- 口径与 hardware.list.query 一致:hardware_view、soft_delete=0、status 1/2/3
- 行级权限复用 PermissionService.applyHardwareRowFilter
- 每物业 4 次 selectCount(项目未注册分页拦截器;无 clone 先例,重建 wrapper)
测试:DeviceStatisticsToolsTest 5 例(参数校验/超限/计数/权限调用/去重)全过。
设计文档:doc/hardware-statistics-query-mcp-tool-design.md(含待评审三问)。
---
...rdware-statistics-query-mcp-tool-design.md | 86 ++++++++++++
.../agent/mcp/tool/DeviceStatisticsTools.java | 125 ++++++++++++++++++
.../mcp/tool/DeviceStatisticsToolsTest.java | 114 ++++++++++++++++
3 files changed, 325 insertions(+)
create mode 100644 xiaoqu-agent-service/doc/hardware-statistics-query-mcp-tool-design.md
create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/DeviceStatisticsTools.java
create mode 100644 xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/DeviceStatisticsToolsTest.java
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 完全一致:
+ *
+ * - 数据源 m0.hardware_view(当前状态视图,非历史);
+ * - status:1=在线 2=故障 3=离线;
+ * - soft_delete=0;
+ * - 行级权限复用 {@link PermissionService#applyHardwareRowFilter};
+ * - 默认排除未绑定物业设备(property_id=0),可用 includeUnbound 打开。
+ *
+ */
+@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