From 5b0e59feb2e58d3ccc90bd482313b2b52808338f Mon Sep 17 00:00:00 2001 From: my_local_kimi_dev2 Date: Tue, 13 Oct 2026 11:39:32 +0800 Subject: [PATCH] feat(mcp): add toilet.airquality list/detail/metrics/statelog query tools (REQ-20261012-0004) --- .../doc/agent-mcp-tools-reference.md | 62 +- .../doc/toilet-airquality-mcp-tool-design.md | 232 +++++++ xiaoqu-agent-service/sql/seed-dev.sql | 10 + .../iiv/agent/entity/m0/ToiletAirquality.java | 67 ++ .../agent/entity/m0/ToiletAirqualityLog.java | 31 + .../es/ToiletAirqualityMetricsEsGateway.java | 122 ++++ .../ToiletAirqualityMetricsEsGatewayImpl.java | 129 ++++ .../mapper/m0/ToiletAirqualityLogMapper.java | 7 + .../mapper/m0/ToiletAirqualityMapper.java | 7 + .../agent/mcp/tool/ToiletAirqualityTools.java | 610 ++++++++++++++++++ .../agent/permission/PermissionService.java | 24 + ...letAirqualityMetricsEsGatewayImplTest.java | 138 ++++ .../mcp/tool/ToiletAirqualityToolsTest.java | 434 +++++++++++++ 13 files changed, 1872 insertions(+), 1 deletion(-) create mode 100644 xiaoqu-agent-service/doc/toilet-airquality-mcp-tool-design.md create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/ToiletAirquality.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/ToiletAirqualityLog.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/es/ToiletAirqualityMetricsEsGateway.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/es/ToiletAirqualityMetricsEsGatewayImpl.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/ToiletAirqualityLogMapper.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/ToiletAirqualityMapper.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/ToiletAirqualityTools.java create mode 100644 xiaoqu-agent-service/src/test/java/me/iiv/agent/es/ToiletAirqualityMetricsEsGatewayImplTest.java create mode 100644 xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/ToiletAirqualityToolsTest.java diff --git a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md index 824e542..3d5d674 100644 --- a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md +++ b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md @@ -997,7 +997,65 @@ --- -## 40. agent.user.list.query +## 40. toilet.airquality.list.query + +空感(空气质量检测器)设备列表分页查询,复刻老 `/toiletAirQuality/listByPage`。 + +- 分类:toilet | 类型:读 +- 数据来源:m0 `toilet_airquality` 主档,固定 `soft_delete=0`;airmac 模糊匹配,其余过滤精确匹配; + 固定 id DESC 稳定排序(老接口无 order by);selectCount+LIMIT 物理分页,rows 上限 100 截断 + notice。 +- 入参:`airmac`(模糊)、`agentId`/`propertyId`/`districtId`/`buildingId`、`floor`、`state`(0=异常 1=正常)、 + `runState`(0=离线 1=在线)、`onlineState`(0=初始化 1=上线 2=下线)、`hardware`/`firmware`/`protocol`、 + `imsi`、`muteFlag`/`shockTimeFlag`/`pilotLightFlag`(0=关闭 1=开启)、`page`/`rows`。 +- 返回主档固定字段集(不搬老系统 webId 字段级裁剪),枚举字段配 `*Text` 文案; + pm10 即老页面「烟感」、tvoc 即「异味」(不另起 smoke/odour 别名)。 +- 富化批量修 N+1:agentName/propertyName/districtName 走 Redis multiGet; + 物联网卡按页 IN 查 networkcard 并覆盖主档 factoryType(对齐老 :153 语义)。 +- 行级权限:`PermissionService.applyToiletAirqualityRowFilter`(镜像 hardware 同款过滤组合); + tenant_service 按总部视角全量。 + +## 41. toilet.airquality.detail.query + +按 `id` 或 `airmac`(二选一必填,airmac 精确)查询空感设备详情。 + +- 分类:toilet | 类型:读 +- record 同 list 单条,内嵌 `site` 站点块(district 表 districtName/省市区县/address/contact/phone + + buildingId/floor)。 +- 不存在或无权访问统一返回 `record=null` + notice(不区分,防探测)。 + +## 42. toilet.airquality.metrics.query + +单台空感设备指标上报明细(ES **云集群** `toilet_airquality_detail_*` 通配索引), +对应老 `/toiletAirQualityLog/listByPage`。 + +- 分类:toilet | 类型:读 +- 入参:`airmac` 必填(**match_phrase** 精确短语,修老系统 matchQuery 分词误配); + `startDate`/`endDate` **非必填** yyyy-MM-dd 闭区间,**缺省=当天**(与老系统一致,王恒 2026-10-12 拍板; + 只填一个时另一个取同一天),endDate 不得晚于今天,区间硬限 31 天;`page`/`rows` 默认 1/20 上限 100。 +- updateTime range 发 epoch 毫秒闭区间;按 updateTime DESC;from+size + track_total_hits, + 超 max_result_window(10000) 截断 + notice。 +- record:updateTime(epoch/ISO 双形态归一为 Asia/Shanghai `yyyy-MM-dd HH:mm:ss`)+ 十项指标原值 + (temp/hum/pm2d5/pm10/pm1d0/co2/tvoc/ammonia/hydrogensulfide/formaldehyde,数值/文本双形态归一)。 +- 权限:先经主档行过滤校验 airmac 可见性(补老接口无鉴权漏洞),不可见抛 `NOT_FOUND_OR_FORBIDDEN`。 +- ES 低阶 `esCloudRestClient` 实现(生产 ES 7.7.1 与高阶客户端不兼容);ES 异常 fail fast 抛 IllegalStateException。 + +## 43. toilet.airquality.statelog.list.query + +单台空感设备状态变更日志(m0 `toilet_airquality_log`),对应老 `/toiletAirQualityLog/logListByPage`。 + +- 分类:toilet | 类型:读 +- 入参:`airmac` 必填(精确);`startUpdateTime`/`endUpdateTime` 可选 yyyy-MM-dd 闭区间(缺省不过滤, + 与老系统一致);`state` 可选(0=异常 1=正常 2=离线 3=在线);`page`/`rows` 默认 1/20 上限 100。 +- 返回 updateTime+state+stateText,按 updateTime DESC、id DESC。 +- 权限同 metrics:先经主档行过滤校验 airmac,不可见抛 `NOT_FOUND_OR_FORBIDDEN`。 + +> 故障日志 `toilet.airquality.fault.list.query` 本期不做(王恒 2026-10-12 拍板),契约见设计文档 §5.5。 + +详细设计见 [toilet-airquality-mcp-tool-design](toilet-airquality-mcp-tool-design.md)。 + +--- + +## 44. agent.user.list.query 代理角色用户分页查询(m0 `user`,强制 `enterprise_type=1` + `soft_delete=0`),对应老 `/user/listByPage` 的代理用户子集。 @@ -1018,6 +1076,8 @@ --- +--- + ## 附录:在线查看工具清单 ```bash diff --git a/xiaoqu-agent-service/doc/toilet-airquality-mcp-tool-design.md b/xiaoqu-agent-service/doc/toilet-airquality-mcp-tool-design.md new file mode 100644 index 0000000..3d5b581 --- /dev/null +++ b/xiaoqu-agent-service/doc/toilet-airquality-mcp-tool-design.md @@ -0,0 +1,232 @@ +# toilet.airquality.* 设计文档(空感设备查询 MCP 工具) + +> 状态:已拍板(王恒 2026-10-12):本期只做 4 个工具(list/detail/metrics/statelog), +> **故障日志 toilet.airquality.fault.list.query 本期不做**;metrics.query 日期区间**非必填,缺省查当天**(与老系统一致)。 +> 老接口(xiaoqu-franchisee,行号级依据): +> - 列表:`POST /toiletAirQuality/listByPage`,`controller/konggan/ToiletAirQualityController.java:108-165` +> - 指标明细(ES):`POST /toiletAirQualityLog/listByPage`,`controller/konggan/ToiletAirQualityDetailController.java:67-129` +> - 上下线/状态日志:`POST /toiletAirQualityLog/logListByPage`,同文件 :131-157 +> - 故障日志:`POST /airqualityFaultLog/listByPage`,`controller/konggan/AirqualityFaultLogController.java:52-86` +> 参考模板:doc/paper-position-detail-mcp-design.md;ES 低阶客户端模式参照 ElectricityMessageEsGatewayImpl + +## 1. 定位 + +把老系统「空感管理」域的查询能力搬成 MCP 工具:空感(空气质量检测器)设备列表、单设备详情、 +指标上报历史(ES)、状态变更日志、故障日志。面向 LLM 消费,统一扁平 rows / record 结构, +不搬 webId 字段级权限、不搬导出、不搬 MQTT 下发类写接口。 + +## 2. 数据源结论(调研要点 1/2) + +| 数据 | 老接口 | 数据源 | MCP 侧 | +|---|---|---|---| +| 设备列表 | ToiletAirQualityController.listByPage | **MySQL m0 `toilet_airquality`**(MyBatis-Plus 分页) | 纯 MySQL,直连 m0 | +| 指标明细 | ToiletAirQualityDetailController.listByPage | **ES 索引 `toilet_airquality_detail_*`**(KongGanLogESServiceImpl 继承 BaseSearchSupportImpl,注入 `restClient` bean = EsCluster = 阿里云**云集群**,对应本服务 `esCloudRestClient`) | ES 云集群,低阶 RestClient | +| 状态变更日志 | ToiletAirQualityDetailController.logListByPage | **MySQL m0 `toilet_airquality_log`**(airmac/update_time/state 五字段小表) | 纯 MySQL | +| 故障日志 | AirqualityFaultLogController.listByPage | **MySQL m0 `airquality_fault_log`**(KongGanTask 每分钟消费 Redis 队列 `ToiletAirQualityLogTemp` 生成:pm10 ≥ 3500 且持续 Dictionary.KongGanAbnormalSustainTime 分钟 → 故障;连续 KongGanNormalSustainTimes 次正常 → 恢复,task/KongGanTask.java:46-139) | 纯 MySQL | + +- `toilet_airquality` 行即设备主档:agent_id/property_id/district_id/building_id/floor/position_id/toilet_id + 在绑定(/binding,:244-317)时从 positions/toilet 拷入;解绑(/unBinding)全部置 0。 + **未绑定设备 agent_id=0**,代理商用户天然不可见(IN 过滤不含 0),总部可见——语义合理,保留。 +- 空感设备与 hardware(出纸机)是两张独立主档表,无外键关系;挂在站点/物业上的方式完全相同 + (agent_id + property_id + district_id),行级权限可直接复用 hardware 同款过滤组合。 +- 老列表富化:agentName/propertyName/districtName 走 Redis `name:Agent:{id}` / `name:Properyt:{id}` / + `name:District:{id}`(:137-142),factoryType 逐行按 imsi 查 networkcard(:147-156)——MCP 全部改批量 + (Redis multiGet + networkcard 按页 IN),修 N+1,参照 HardwareTools.enrich。 + +## 3. 老接口查询逻辑与坑(调研要点 1/4) + +### 3.1 列表 `/toiletAirQuality/listByPage` + +- 过滤:`WrapperUtils.getWrapper(false)` 带 `soft_delete=0`;`beanToWrapper`(WrapperUtils.java:270-310) + **String 字段一律 like 模糊、Integer/Long 一律 eq**——airmac/floor/hardware/firmware/protocol/imsi 是模糊匹配, + agentId/propertyId/districtId/buildingId/state/runState/onlineState/upInterval/muteFlag/shockTimeFlag/pilotLightFlag 是精确匹配。 +- **行级权限:有**。:122 走 `getByIsAgent(agentFlag, agentId, wrapper, user, true, "property_id", redis)` + (WrapperUtils.java:679-…)——与 district/hardware 完全相同的 20250401 变体:代理商=自己+直属下级+ + 授权代管代理(warehouse_agent_auth)+ 代管物业(agent_manage_district_auth,OR property_id IN); + 销售部门非负责人只看自己创建的物业(property_id IN salesPropertyIds);总部不加过滤。 +- 字段级裁剪:`fieldWebpageAuthorityService.getFieldList(webId, authorityId)` 动态 select 列(:124-126)—— + **MCP 不搬**,固定字段集(与 hardware.list.query 「不做字段级权限裁剪」一致)。 +- 别名:输出 `smoke=pm10`(烟感)、`odour=tvoc`(异味)(:135-136)。 +- 排序:**无 order by**(分页顺序依赖 MySQL 默认,不稳定)——MCP 固定 id DESC。 + +### 3.2 指标明细 `/toiletAirQualityLog/listByPage`(ES) + +- 入参 ToiletAirInput:airmac 必填、startUpdateTime/endUpdateTime 可选,**缺省=当天 00:00:00~23:59:59**(:97-107)。 +- **坑 1:airmac 走 `matchQuery`**(BaseSearchSupportImpl.java:705,type=0 字符串分支)——standard analyzer + 对 SN 分词造成误配(与 electricitymessage 同款坑),MCP 改 match_phrase。 +- **坑 2:无任何行级权限**——知道 airmac 即可查任意设备指标。MCP 必须先经 `toilet_airquality` 主档 + 行过滤校验 airmac 可见性,不可见抛 NOT_FOUND_OR_FORBIDDEN。 +- 排序 updateTime DESC;MCP 保留(ES 侧 from+size 分页 + track_total_hits)。 +- updateTime 的索引 mapping 类型未验证(参照 electricitymessage 经验:date 类型需发 epoch 毫秒, + _source 可能是 ISO 文本)——**冒烟阶段实测确认**,读取侧按 epoch/ISO 双形态归一。 + +### 3.3 状态日志 `/toiletAirQualityLog/logListByPage` + +- MySQL `toilet_airquality_log`:仅 id/airmac/update_time/state(0=异常,1=正常,2=离线,3=在线)+ 审计字段。 +- startUpdateTime/endUpdateTime 经 beanToWrapper `start*`/`end*` 前缀规则转 ge/le(按 yyyy-MM-dd 字符串比较 + datetime 列,老语义)。 +- **坑:无任何行级权限**(连 getByIsAgent 都没调),且表内无 agent_id/district_id 列——MCP 权限只能 + 经 airmac → toilet_airquality 主档行过滤解析(同 3.2 坑 2 的补法)。 + +### 3.4 故障日志 `/airqualityFaultLog/listByPage` + +- MySQL `airquality_fault_log`,行权限是**旧版 2 参 getByIsAgent**(WrapperUtils.java:652,仅代理商 + 自己+直属下级,**不含**代管代理/代管物业/销售分支)——比列表的权限窄,属历史遗漏。 +- **坑:faultType 文案自相矛盾**——实体注释 `0:PM2.5`,KongGanTask:40/102 实际按 **pm10 ≥ 3500** 判定且 + 注释写"PM10"。MCP faultTypeText 定为「PM10 超标」并在文档留痕。 +- faultType 目前只有 0 一种值(task 写死)。 + +### 3.5 其他已确认不照搬的老系统问题 + +- `/toiletAirQuality/unBinding` :364 在 toiletId 已置 0 后 `toiletService.getById(0)`,解绑后厕所 + airdetectorFlag 永不复位——查询工具不涉及,仅记录。 +- getTaskParamSet/getShockParamSet 读 Redis Dictionary 缺 key 时 `"null"` parseInt 直接 + NumberFormatException——查询工具不涉及。 +- 列表/export 的 networkcard 查询 `getOne` 无 imsi 唯一约束兜底(`limit 1` 防多行)——MCP 批量 IN 天然规避。 + +## 4. 工具清单与决策表 + +| # | 工具名 | 对应老接口 | 建议 | 备选 | +|---|---|---|---|---| +| 1 | `toilet.airquality.list.query` | /toiletAirQuality/listByPage | **做** | — | +| 2 | `toilet.airquality.detail.query` | 列表行即详情主档(老无独立详情接口) | **做**,按 id/airmac 二选一 | 并入 list(否:详情带站点富化+物联网卡) | +| 3 | `toilet.airquality.metrics.query` | /toiletAirQualityLog/listByPage(ES) | **做**,「查看详情」的核心数据 | 不做(失去指标历史) | +| 4 | `toilet.airquality.statelog.list.query` | /toiletAirQualityLog/logListByPage | **做**,成本极低(单表分页) | 并入 metrics(否:数据源不同) | +| 5 | `toilet.airquality.fault.list.query` | /airqualityFaultLog/listByPage | **本期不做**(王恒 2026-10-12 拍板,故障日志砍掉) | 后续单独排期 | + +统一约定:`@XqTool(category="toilet", kind="read")`;`Map` 返回; +列表 `{total, page, rows, records[], notice?}`(rows 上限 100 截断+notice), +详情 `{record, notice?}`;枚举字段配 `*Text` 中文文案;错误用 ToolCallException +(INVALID_ARGUMENT / NOT_FOUND_OR_FORBIDDEN)。 + +## 5. 入参/返回契约 + +### 5.1 toilet.airquality.list.query + +| 参数 | 必填 | 说明 | +|---|---|---| +| airmac | 否 | 设备 SN/mac,**模糊匹配**(对齐老 beanToWrapper String→like) | +| agentId / propertyId / districtId / buildingId | 否 | 正整数,精确匹配 | +| floor | 否 | 楼层,精确匹配(老系统 String like,但楼层值短,like 与 eq 实操等价;MCP 定 eq 与 hardware 一致) | +| state | 否 | 设备状态:0=异常 1=正常 | +| runState | 否 | 运行状态:0=离线 1=在线 | +| onlineState | 否 | 上线状态:0=初始化 1=上线 2=下线 | +| hardware / firmware / protocol | 否 | 硬件/嵌入式/语音版本,精确匹配 | +| imsi | 否 | 物联网卡 imsi,精确匹配 | +| muteFlag / shockTimeFlag / pilotLightFlag | 否 | 0=关闭 1=开启 | +| page / rows | 否 | 默认 1/20,rows 上限 100 截断 | + +record 字段(主档子集,去审计/任务完成值噪音):id、airmac、state+stateText、runState+runStateText、 +onlineState+onlineStateText、onlineTime、agentId/agentName、propertyId/propertyName、 +districtId/districtName、buildingId、floor、installPosition、toiletId、positionId、 +temp/hum/pm2d5/pm10/pm1d0/co2/tvoc/ammonia/hydrogensulfide/formaldehyde(当前值)、 +hardware/firmware/protocol、upInterval、imsi、signalValue、factoryType+factoryTypeText +(0=未知 1=合宙 2=合宙 3=SIMBOOS;networkcard 覆盖主档值,对齐老 :153)、 +voiceFlag/shockFlag/muteFlag/shockTimeFlag/pilotLightFlag + 各 *Text、createTime/modifyTime。 +**不返回** checkCode/checkCodeUrl(OSS 签名图对 LLM 无意义)、`*_check`/`h5_model_id`/经纬度 +(longitude/latitude 老系统基本不用,如需再加)、`*_finish` 任务完成值。 +smoke/odour 不另起别名字段,在 description 注明 pm10=老页面「烟感」、tvoc=「异味」。 + +### 5.2 toilet.airquality.detail.query + +- 入参:id / airmac 二选一必填(airmac 精确)。 +- 返回 `{record, site}`:record 同 5.1 单条;site 同 hardware.detail.query 的站点块 + (district 表 districtName/省市区县/address/contact/phone + buildingId/floor)。 +- 不存在或无权:`record=null` + notice「设备不存在或无权访问」(不区分,防探测)。 + +### 5.3 toilet.airquality.metrics.query + +- 入参:airmac 必填(精确);startDate/endDate **非必填** yyyy-MM-dd 闭区间,**缺省=当天** + (与老系统一致,王恒 2026-10-12 拍板;只填一个时另一个取同一天),endDate≤今天, + 跨度≤31 天;page/rows 默认 1/20 上限 100。 +- ES 查询:`POST /toilet_airquality_detail_*/_search`,filter=[match_phrase airmac, + range updateTime epoch 毫秒闭区间],sort updateTime DESC,from+size + track_total_hits。 +- 先查主档行权限(airmac→toilet_airquality+行过滤),不可见抛 NOT_FOUND_OR_FORBIDDEN + 「设备不存在或无权访问」。 +- record:updateTime(归一 yyyy-MM-dd HH:mm:ss Asia/Shanghai)+ 10 项指标原值 + (temp/hum/pm2d5/pm10/pm1d0/co2/tvoc/ammonia/hydrogensulfide/formaldehyde,ES _source 数值/文本双形态归一)。 +- 集群:esCloudRestClient(云集群,见 §2);ES 异常抛 IllegalStateException 不静默。 + +### 5.4 toilet.airquality.statelog.list.query + +- 入参:airmac 必填(精确);startUpdateTime/endUpdateTime 可选 yyyy-MM-dd(闭区间, + 缺省不过滤——老系统不过滤,MCP 保持);state 可选 0/1/2/3;page/rows 默认 1/20 上限 100。 +- 权限同 5.3 先经主档解析;返回 updateTime+state+stateText(0=异常 1=正常 2=离线 3=在线), + 按 updateTime DESC、id DESC。 + +### 5.5 toilet.airquality.fault.list.query(本期不做,王恒 2026-10-12 拍板;契约留存备后续排期) + +- 入参:districtId/propertyId/agentId/snCode(模糊)/faultState(0=未恢复 1=已恢复)/ + faultDateFrom/faultDateTo(yyyy-MM-dd 闭区间,落 fault_start_time 左闭右开)/page/rows。 + faultType 暂不入参(库里只有 0)。 +- 返回:id、snCode、faultType+faultTypeText(「PM10 超标」,见 §3.4 坑)、faultState+faultStateText、 + faultStartTime/faultAddTime/faultRecoveryTime、installPosition、 + agentId/agentName、propertyId/propertyName、districtId/districtName(表内冗余名称直出, + 无需 Redis 富化);faultStartTime DESC、id DESC。 + +## 6. 权限(调研要点 3) + +- 新增 `PermissionService.applyToiletAirqualityRowFilter(wrapper, user)`:逐行镜像 + applyHardwareRowFilter(PermissionService.java:820-835),落到 `toilet_airquality` 的 + agent_id/property_id——老列表用的就是同一 getByIsAgent 20250401 变体(propertyFlag=true, + className="property_id"),语义 1:1 对齐。 +- tenant_service(user==null)按总部视角全量(resolveRowScope 既有逻辑,无需改动)。 +- list/detail 直接把过滤套在主表 wrapper 上;metrics/statelog 先按 airmac 查主档 + (带行过滤),查不到 → ToolCallException(NOT_FOUND_OR_FORBIDDEN, "设备不存在或无权访问"), + 通过后才查 ES/日志表。**补齐老接口 3.2/3.3 的权限漏洞**。 +- fault 工具本期不做;其权限口径(老仅代理商+直属下级 → 全局标准含代管/销售分支)留待后续排期时再定,见 §7。 +- scope 绑定:seed-dev.sql 追加一段(照 district.paper.trace.list.query 的通用段模式): + 用户委托作用域(service_enabled=0)+ ops-query;生产上线后按惯例绑 scope 1(test)/2(operation)/ + 3(xiaoqu-ai)/5(ops-query),sync 不绑。 + +## 7. 与老系统的差异点(有意为之) + +| 点 | 老系统 | 本工具 | 理由 | +|---|---|---|---| +| 字段级裁剪 | webId+page_field 动态 select | 固定字段集 | 与 hardware.list.query 一致;权限组元数据不适合 LLM 场景 | +| 列表排序 | 无 order by | id DESC | 分页稳定 | +| 富化 | Redis 逐行 get + networkcard 逐行查 | multiGet + 按页 IN | 修 N+1 | +| metrics airmac 匹配 | matchQuery 分词误配 | match_phrase | 修误配(electricitymessage 同款修复) | +| metrics/statelog 权限 | 无 | 主档行过滤前置校验 | 补越权漏洞 | +| fault 权限 | 仅代理商+直属下级 | (本期不做;后续按全局标准行过滤) | 对齐 hardware/district 口径 | +| metrics 日期 | 缺省当天 | 缺省当天(非必填,与老系统一致) | 王恒 2026-10-12 拍板 | +| checkCodeUrl | OSS 签名图 | 不返回 | LLM 无意义 | +| ES 客户端 | RestHighLevelClient | 低阶 esCloudRestClient | 生产 ES 7.7.1 与高阶客户端不兼容(AGENTS.md 已沉淀) | + +## 8. 改动文件清单 + +1. 新建 `entity/m0/ToiletAirquality.java`、`ToiletAirqualityLog.java` + (极简字段子集;AirqualityFaultLog 随故障工具本期不做)+ `mapper/m0/` 对应两个空接口 Mapper。 +2. `permission/PermissionService.java` 追加 `applyToiletAirqualityRowFilter`。 +3. 新建 `es/ToiletAirqualityMetricsEsGateway.java` + `es/ToiletAirqualityMetricsEsGatewayImpl.java` + (注入 esCloudRestClient,骨架照 ElectricityMessageEsGatewayImpl)。 +4. 新建 `mcp/tool/ToiletAirqualityTools.java`(4 个 @Tool 方法,骨架照 HardwareTools/BatteryFaultTools)。 +5. 单测 `src/test/java/me/iiv/agent/mcp/tool/ToiletAirqualityToolsTest.java`(Mockito + + AuthContextHolder.set();ES gateway 测试 mock(Response.class)+stub getEntity(), + 注意先建 Response 再 stub,防 UnfinishedStubbingException)。 +6. `sql/seed-dev.sql` 追加幂等 scope_tool 绑定段。 +7. `doc/agent-mcp-tools-reference.md` 追加工具章节(修复现存两个 33 的编号冲突后,percapita 起 + 顺延为 34-39,新工具章节编号 40 起)。 +8. 装配层零改动(McpToolConfig 自动收集 ToolObject bean,ToolSyncService 自动同步 tool 表)。 + +## 9. 单测要点 + +- 参数校验:枚举越界(state/runState/onlineState/flag 系)、id 非正数、日期格式/start>end/end>今天/ + 跨度>31 天、metrics/statelog 缺 airmac、detail 二选一、metrics 日期缺省当天。 +- 权限:代理商身份行过滤落 agent_id IN + property_id OR 分支;销售分支 property_id IN; + tenant_service 全量;metrics/statelog 主档不可见 → NOT_FOUND_OR_FORBIDDEN。 +- 分页:selectCount+LIMIT 物理分页(项目无 MP 分页拦截器,禁 selectPage);rows>100 截断+notice。 +- 枚举映射:state/runState/onlineState/factoryType/stateLog.state 的 *Text 文案。 +- 富化:Redis 名称 multiGet 缺失给 null;factoryType 被 networkcard 覆盖(回归老 :153 语义)。 +- ES gateway:airmac 生成 match_phrase(非 match);range 发 epoch 毫秒;_source 数值/ISO 文本双形态归一; + ES 异常抛 IllegalStateException。 +- 空数据:rows=[] / record=null+notice。 + +## 10. 上线验证与回滚 + +- 冒烟(生产,ops-query Token 工具口径见 AGENTS.md §6):list 按 districtId 过滤命中; + detail 按 airmac;metrics 缺省日期(当天)与显式近 1 天各查一次,验证**索引确在云集群**、 + updateTime epoch range 不 400(若 400 则按实测 mapping 调整);statelog 翻一页; + 越权 airmac 返回 NOT_FOUND_OR_FORBIDDEN。 +- 回滚:纯新增代码 + svc 库 scope_tool 绑定,无业务库 DDL。回滚=git revert + 重新部署 + + `DELETE FROM scope_tool`(按 tool_id)即可,tool 表记录由 ToolSyncService 管理可保留停用。 diff --git a/xiaoqu-agent-service/sql/seed-dev.sql b/xiaoqu-agent-service/sql/seed-dev.sql index 652ff6d..92fbddd 100644 --- a/xiaoqu-agent-service/sql/seed-dev.sql +++ b/xiaoqu-agent-service/sql/seed-dev.sql @@ -83,6 +83,16 @@ WHERE s.soft_delete = 0 AND s.status = 1 AND t.name = 'district.paper.trace.list.query' AND NOT EXISTS (SELECT 1 FROM scope_tool st WHERE st.scope_id = s.id AND st.tool_id = t.id); +-- 空感(toiletAirquality)查询工具:口径同 district.paper.trace.list.query—— +-- 用户委托作用域 + ops-query 服务身份(总部视角),不进入 sync。 +INSERT INTO scope_tool (scope_id, tool_id, create_id) +SELECT s.id, t.id, 0 FROM agent_scope s JOIN tool t +WHERE s.soft_delete = 0 AND s.status = 1 + AND (s.service_enabled = 0 OR s.code = 'ops-query') + AND t.name IN ('toilet.airquality.list.query', 'toilet.airquality.detail.query', + 'toilet.airquality.metrics.query', 'toilet.airquality.statelog.list.query') + AND NOT EXISTS (SELECT 1 FROM scope_tool st WHERE st.scope_id = s.id AND st.tool_id = t.id); + -- 代理角色用户查询(agent.user.list.query):2026-10-13 王恒拍板只绑 scope 1 -- (prod scope 1 = code 'test'),用户数据敏感从严;不进入 operation/xiaoqu-ai/ops-query/sync。 INSERT INTO scope_tool (scope_id, tool_id, create_id) diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/ToiletAirquality.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/ToiletAirquality.java new file mode 100644 index 0000000..7ade988 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/ToiletAirquality.java @@ -0,0 +1,67 @@ +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.toilet_airquality),极简字段子集。 */ +@Getter +@Setter +@ToString +@TableName("toilet_airquality") +public class ToiletAirquality implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + private Integer softDelete; + /** 设备 SN/mac */ + private String airmac; + /** 设备状态:0=异常 1=正常 */ + private Integer state; + /** 运行状态:0=离线 1=在线 */ + private Integer runState; + /** 上线状态:0=初始化 1=上线 2=下线 */ + private Integer onlineState; + private Date onlineTime; + private Long agentId; + private Long propertyId; + private Long districtId; + private Long buildingId; + private String floor; + private String installPosition; + private Long toiletId; + private Long positionId; + private Double temp; + private Double hum; + private Double pm2d5; + private Double pm10; + private Double pm1d0; + private Double co2; + private Double tvoc; + private Double ammonia; + private Double hydrogensulfide; + private Double formaldehyde; + private String hardware; + private String firmware; + private String protocol; + private Integer upInterval; + private String imsi; + private String signalValue; + /** 厂商类型:0=未知 1=合宙 2=合宙 3=SIMBOOS(列表输出时被 networkcard 覆盖,对齐老系统) */ + private Integer factoryType; + private Integer voiceFlag; + private Integer shockFlag; + private Integer muteFlag; + private Integer shockTimeFlag; + private Integer pilotLightFlag; + private Date createTime; + private Date modifyTime; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/ToiletAirqualityLog.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/ToiletAirqualityLog.java new file mode 100644 index 0000000..3185960 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/ToiletAirqualityLog.java @@ -0,0 +1,31 @@ +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.toilet_airquality_log)。 */ +@Getter +@Setter +@ToString +@TableName("toilet_airquality_log") +public class ToiletAirqualityLog implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + private Integer softDelete; + /** 设备 SN/mac */ + private String airmac; + /** 状态变更时间 */ + private Date updateTime; + /** 状态:0=异常 1=正常 2=离线 3=在线 */ + private Integer state; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/ToiletAirqualityMetricsEsGateway.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/ToiletAirqualityMetricsEsGateway.java new file mode 100644 index 0000000..873e699 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/ToiletAirqualityMetricsEsGateway.java @@ -0,0 +1,122 @@ +package me.iiv.agent.es; + +import java.util.List; + +/** + * toilet_airquality_detail_*(空感指标上报明细)ES 查询网关。 + * 索引建在阿里云 ES 云集群(老 KongGanLogESServiceImpl 注入的 restClient = EsCluster 云集群), + * 索引名固定为通配 toilet_airquality_detail_* 由调用方传入,本接口只做单次查询,便于单测 mock。 + */ +public interface ToiletAirqualityMetricsEsGateway { + + /** + * 分页查询单台空感设备的指标上报明细。 + * + * @param index 索引名/通配,如 toilet_airquality_detail_* + * @param airmac 设备 SN/mac,match_phrase 精确短语匹配 + * (老系统 matchQuery 经 standard analyzer 分词误配,不照搬) + * @param gteMillis updateTime 下界(含),epoch 毫秒(date 字段发字符串会被 400 拒绝) + * @param lteMillis updateTime 上界(含),epoch 毫秒 + * @param from from+size 分页起始偏移 + * @param size 每页条数 + * @return 命中总数(track_total_hits)与当页记录,按 updateTime DESC + */ + Result search(String index, String airmac, long gteMillis, long lteMillis, int from, int size); + + /** 单次查询结果。 */ + class Result { + private final long total; + private final List records; + + public Result(long total, List records) { + this.total = total; + this.records = records; + } + + public long getTotal() { + return total; + } + + public List getRecords() { + return records; + } + } + + /** + * 单条指标明细:updateTime 已归一为 Asia/Shanghai yyyy-MM-dd HH:mm:ss 文本; + * 10 项指标原值(_source 数值/文本双形态归一为 Double,非数值为 null)。 + */ + class Record { + private final String updateTime; + private final Double temp; + private final Double hum; + private final Double pm2d5; + private final Double pm10; + private final Double pm1d0; + private final Double co2; + private final Double tvoc; + private final Double ammonia; + private final Double hydrogensulfide; + private final Double formaldehyde; + + public Record(String updateTime, Double temp, Double hum, Double pm2d5, Double pm10, + Double pm1d0, Double co2, Double tvoc, Double ammonia, + Double hydrogensulfide, Double formaldehyde) { + this.updateTime = updateTime; + this.temp = temp; + this.hum = hum; + this.pm2d5 = pm2d5; + this.pm10 = pm10; + this.pm1d0 = pm1d0; + this.co2 = co2; + this.tvoc = tvoc; + this.ammonia = ammonia; + this.hydrogensulfide = hydrogensulfide; + this.formaldehyde = formaldehyde; + } + + public String getUpdateTime() { + return updateTime; + } + + public Double getTemp() { + return temp; + } + + public Double getHum() { + return hum; + } + + public Double getPm2d5() { + return pm2d5; + } + + public Double getPm10() { + return pm10; + } + + public Double getPm1d0() { + return pm1d0; + } + + public Double getCo2() { + return co2; + } + + public Double getTvoc() { + return tvoc; + } + + public Double getAmmonia() { + return ammonia; + } + + public Double getHydrogensulfide() { + return hydrogensulfide; + } + + public Double getFormaldehyde() { + return formaldehyde; + } + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/ToiletAirqualityMetricsEsGatewayImpl.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/ToiletAirqualityMetricsEsGatewayImpl.java new file mode 100644 index 0000000..f1ce154 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/ToiletAirqualityMetricsEsGatewayImpl.java @@ -0,0 +1,129 @@ +package me.iiv.agent.es; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.elasticsearch.client.Request; +import org.elasticsearch.client.Response; +import org.elasticsearch.client.RestClient; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.List; + +/** + * toilet_airquality_detail_* ES 查询实现,骨架照 ElectricityMessageEsGatewayImpl。 + * 只注入 esCloudRestClient(低阶 RestClient:生产 ES 7.7.1 与高阶客户端不兼容,见 AGENTS.md); + * 空感指标索引在云集群(老 KongGanLogESServiceImpl 的 restClient bean = EsCluster 云集群)。 + * 修复老系统坑:airmac 用 match_phrase(老 BaseSearchSupportImpl type=0 走 matchQuery, + * standard analyzer 对 SN 分词误配);updateTime range 发 epoch 毫秒;ES 异常不静默, + * 抛 IllegalStateException。 + */ +@Component +public class ToiletAirqualityMetricsEsGatewayImpl implements ToiletAirqualityMetricsEsGateway { + + private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + private static final DateTimeFormatter TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(BUSINESS_ZONE); + + private final RestClient cloudRestClient; + private final ObjectMapper mapper = new ObjectMapper(); + + public ToiletAirqualityMetricsEsGatewayImpl(@Qualifier("esCloudRestClient") RestClient cloudRestClient) { + this.cloudRestClient = cloudRestClient; + } + + @Override + public Result search(String index, String airmac, long gteMillis, long lteMillis, int from, int size) { + ArrayNode filters = mapper.createArrayNode(); + filters.addObject().putObject("match_phrase").putObject("airmac").put("query", airmac); + ObjectNode range = filters.addObject().putObject("range").putObject("updateTime"); + range.put("gte", gteMillis).put("lte", lteMillis); + + ObjectNode body = mapper.createObjectNode(); + body.put("from", from).put("size", size); + body.put("track_total_hits", true); + ObjectNode bool = body.putObject("query").putObject("bool"); + bool.set("filter", filters); + body.putArray("sort").addObject().putObject("updateTime").put("order", "desc"); + try { + Request req = new Request("POST", "/" + index + "/_search"); + // setJsonEntity 自动带 Content-Type: application/json(无 vendor 头) + req.setJsonEntity(mapper.writeValueAsString(body)); + Response resp = cloudRestClient.performRequest(req); + JsonNode root = mapper.readTree(resp.getEntity().getContent()); + List records = new ArrayList<>(); + for (JsonNode hit : root.at("/hits/hits")) { + JsonNode source = hit.get("_source"); + if (source == null) { + continue; + } + records.add(new Record(updateTimeText(source.get("updateTime")), + doubleValue(source.get("temp")), + doubleValue(source.get("hum")), + doubleValue(source.get("pm2d5")), + doubleValue(source.get("pm10")), + doubleValue(source.get("pm1d0")), + doubleValue(source.get("co2")), + doubleValue(source.get("tvoc")), + doubleValue(source.get("ammonia")), + doubleValue(source.get("hydrogensulfide")), + doubleValue(source.get("formaldehyde")))); + } + return new Result(root.at("/hits/total/value").asLong(), records); + } catch (IOException | RuntimeException e) { + throw new IllegalStateException("ES 查询空感指标明细失败: " + e.getMessage(), e); + } + } + + /** + * updateTime 在 _source 里可能是 epoch 毫秒数或 ISO 文本(mapping 类型冒烟阶段实测确认), + * 两种形态都归一为 Asia/Shanghai yyyy-MM-dd HH:mm:ss 文本输出。 + */ + private static String updateTimeText(JsonNode value) { + if (value == null || value.isNull()) { + return null; + } + if (value.isNumber()) { + return TIME_FORMATTER.format(Instant.ofEpochMilli(value.longValue())); + } + if (value.isTextual()) { + String text = value.asText(); + if (text.indexOf('T') >= 0) { + try { + return TIME_FORMATTER.format(OffsetDateTime.parse(text)); + } catch (DateTimeParseException e) { + return text; + } + } + return text; + } + return null; + } + + /** 指标值在 _source 里可能是数字也可能是字符串,统一解析为 Double;非数值文本返回 null。 */ + private static Double doubleValue(JsonNode value) { + if (value == null || value.isNull()) { + return null; + } + if (value.isNumber()) { + return value.doubleValue(); + } + if (value.isTextual()) { + try { + return Double.valueOf(value.asText().trim()); + } catch (NumberFormatException e) { + return null; + } + } + return null; + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/ToiletAirqualityLogMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/ToiletAirqualityLogMapper.java new file mode 100644 index 0000000..657ae6d --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/ToiletAirqualityLogMapper.java @@ -0,0 +1,7 @@ +package me.iiv.agent.mapper.m0; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m0.ToiletAirqualityLog; + +public interface ToiletAirqualityLogMapper extends BaseMapper { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/ToiletAirqualityMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/ToiletAirqualityMapper.java new file mode 100644 index 0000000..14e0f2e --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/ToiletAirqualityMapper.java @@ -0,0 +1,7 @@ +package me.iiv.agent.mapper.m0; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m0.ToiletAirquality; + +public interface ToiletAirqualityMapper extends BaseMapper { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/ToiletAirqualityTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/ToiletAirqualityTools.java new file mode 100644 index 0000000..3ef6802 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/ToiletAirqualityTools.java @@ -0,0 +1,610 @@ +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.Networkcard; +import me.iiv.agent.entity.m0.ToiletAirquality; +import me.iiv.agent.entity.m0.ToiletAirqualityLog; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.es.ToiletAirqualityMetricsEsGateway; +import me.iiv.agent.mapper.m0.DistrictDistrictStrategyMapper; +import me.iiv.agent.mapper.m0.NetworkcardMapper; +import me.iiv.agent.mapper.m0.ToiletAirqualityLogMapper; +import me.iiv.agent.mapper.m0.ToiletAirqualityMapper; +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.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoUnit; +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; + +/** + * 空感(空气质量检测器)查询工具,仿老系统空感管理域: + * /toiletAirQuality/listByPage(列表)、/toiletAirQualityLog/listByPage(ES 指标明细)、 + * /toiletAirQualityLog/logListByPage(状态变更日志)。故障日志本期不做(王恒 2026-10-12 拍板)。 + * 富化修正老接口 N+1:名称走 Redis multiGet,物联网卡按页 IN 批量查; + * metrics/statelog 补齐老接口缺失的行级权限(先经主档 airmac 校验可见性)。 + * 设计文档:doc/toilet-airquality-mcp-tool-design.md + */ +@Component +public class ToiletAirqualityTools implements ToolObject { + + private static final int DEFAULT_ROWS = 20; + private static final int MAX_ROWS = 100; + /** ES from+size 深分页上限 max_result_window */ + private static final int MAX_RESULT_WINDOW = 10000; + /** 指标明细 ES 索引通配(云集群) */ + private static final String METRICS_INDEX = "toilet_airquality_detail_*"; + /** 日期区间硬限 31 天(一个月) */ + private static final int MAX_QUERY_DAYS = 31; + private static final ZoneId SHANGHAI = ZoneId.of("Asia/Shanghai"); + private static final DateTimeFormatter TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(SHANGHAI); + + /** 老接口 Redis 字典 key(ToiletAirQualityController /listByPage :137-142) */ + 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:"; + + final ToiletAirqualityMapper toiletAirqualityMapper; + final ToiletAirqualityLogMapper toiletAirqualityLogMapper; + final NetworkcardMapper networkcardMapper; + final DistrictDistrictStrategyMapper districtMapper; + final PermissionService permissionService; + final StringRedisTemplate redisTemplate; + final ToiletAirqualityMetricsEsGateway metricsEsGateway; + + public ToiletAirqualityTools(ToiletAirqualityMapper toiletAirqualityMapper, + ToiletAirqualityLogMapper toiletAirqualityLogMapper, + NetworkcardMapper networkcardMapper, + DistrictDistrictStrategyMapper districtMapper, + PermissionService permissionService, + StringRedisTemplate redisTemplate, + ToiletAirqualityMetricsEsGateway metricsEsGateway) { + this.toiletAirqualityMapper = toiletAirqualityMapper; + this.toiletAirqualityLogMapper = toiletAirqualityLogMapper; + this.networkcardMapper = networkcardMapper; + this.districtMapper = districtMapper; + this.permissionService = permissionService; + this.redisTemplate = redisTemplate; + this.metricsEsGateway = metricsEsGateway; + } + + @XqTool(category = "toilet", kind = "read") + @Tool(name = "toilet.airquality.list.query", description = "分页查询当前用户可见的空感(空气质量检测器)设备列表," + + "对应老系统 /toiletAirQuality/listByPage。支持 airmac(设备 SN/mac)模糊匹配," + + "agentId、propertyId、districtId、buildingId、floor、state、runState、onlineState、" + + "hardware、firmware、protocol、imsi、muteFlag、shockTimeFlag、pilotLightFlag 精确匹配。" + + "page 默认 1,rows 默认 20、上限 100,固定按 id 倒序。" + + "agentName/propertyName/districtName 使用 Redis 批量富化,物联网卡 factoryType 按页批量查询并覆盖主档值。" + + "返回主档固定字段集(不做老系统 webId 字段级裁剪),pm10 即老页面「烟感」、tvoc 即「异味」。") + public Map toiletAirqualityListQuery( + @ToolParam(required = false, description = "设备 SN/mac,模糊匹配") String airmac, + @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 = "设备状态:0=异常 1=正常") Integer state, + @ToolParam(required = false, description = "运行状态:0=离线 1=在线") Integer runState, + @ToolParam(required = false, description = "上线状态:0=初始化 1=上线 2=下线") Integer onlineState, + @ToolParam(required = false, description = "硬件版本,精确匹配") String hardware, + @ToolParam(required = false, description = "嵌入式版本,精确匹配") String firmware, + @ToolParam(required = false, description = "语音版本,精确匹配") String protocol, + @ToolParam(required = false, description = "物联网卡 imsi,精确匹配") String imsi, + @ToolParam(required = false, description = "静音模式:0=关闭 1=开启") Integer muteFlag, + @ToolParam(required = false, description = "定时震动功能:0=关闭 1=开启") Integer shockTimeFlag, + @ToolParam(required = false, description = "指示灯状态:0=关闭 1=开启") Integer pilotLightFlag, + @ToolParam(required = false, description = "页码,从 1 开始,默认 1") Integer page, + @ToolParam(required = false, description = "每页条数,默认 20,上限 100") Integer rows) { + validatePositive(agentId, "agentId"); + validatePositive(propertyId, "propertyId"); + validatePositive(districtId, "districtId"); + validatePositive(buildingId, "buildingId"); + validateEnum(state, "state", 0, 1); + validateEnum(runState, "runState", 0, 1); + validateEnum(onlineState, "onlineState", 0, 1, 2); + validateEnum(muteFlag, "muteFlag", 0, 1); + validateEnum(shockTimeFlag, "shockTimeFlag", 0, 1); + validateEnum(pilotLightFlag, "pilotLightFlag", 0, 1); + + 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(ToiletAirquality::getSoftDelete, 0); + if (StringUtils.hasText(airmac)) { + wrapper.like(ToiletAirquality::getAirmac, airmac.trim()); + } + if (agentId != null) { + wrapper.eq(ToiletAirquality::getAgentId, agentId); + } + if (propertyId != null) { + wrapper.eq(ToiletAirquality::getPropertyId, propertyId); + } + if (districtId != null) { + wrapper.eq(ToiletAirquality::getDistrictId, districtId); + } + if (buildingId != null) { + wrapper.eq(ToiletAirquality::getBuildingId, buildingId); + } + if (StringUtils.hasText(floor)) { + wrapper.eq(ToiletAirquality::getFloor, floor.trim()); + } + if (state != null) { + wrapper.eq(ToiletAirquality::getState, state); + } + if (runState != null) { + wrapper.eq(ToiletAirquality::getRunState, runState); + } + if (onlineState != null) { + wrapper.eq(ToiletAirquality::getOnlineState, onlineState); + } + if (StringUtils.hasText(hardware)) { + wrapper.eq(ToiletAirquality::getHardware, hardware.trim()); + } + if (StringUtils.hasText(firmware)) { + wrapper.eq(ToiletAirquality::getFirmware, firmware.trim()); + } + if (StringUtils.hasText(protocol)) { + wrapper.eq(ToiletAirquality::getProtocol, protocol.trim()); + } + if (StringUtils.hasText(imsi)) { + wrapper.eq(ToiletAirquality::getImsi, imsi.trim()); + } + if (muteFlag != null) { + wrapper.eq(ToiletAirquality::getMuteFlag, muteFlag); + } + if (shockTimeFlag != null) { + wrapper.eq(ToiletAirquality::getShockTimeFlag, shockTimeFlag); + } + if (pilotLightFlag != null) { + wrapper.eq(ToiletAirquality::getPilotLightFlag, pilotLightFlag); + } + permissionService.applyToiletAirqualityRowFilter(wrapper, user); + + Long total = toiletAirqualityMapper.selectCount(wrapper); + wrapper.orderByDesc(ToiletAirquality::getId) + .last("LIMIT " + (long) (pageNo - 1) * pageSize + "," + pageSize); + List list = toiletAirqualityMapper.selectList(wrapper); + + List> records = new ArrayList<>(); + Enrichment enrichment = enrich(list); + for (ToiletAirquality device : list) { + records.add(toRecord(device, 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 = "toilet", kind = "read") + @Tool(name = "toilet.airquality.detail.query", description = "按 id 或 airmac 查询当前用户可见的空感设备详情,二选一必填" + + "(airmac 精确匹配)。返回主档固定字段集 + agentName/propertyName/districtName、物联网卡 factoryType " + + "+ 关联站点信息 site(districtName、省市区县、详细地址、联系人、楼座/楼层)。" + + "设备不存在或无权访问时返回 record=null 并在 notice 说明(不区分,防探测)。") + public Map toiletAirqualityDetailQuery( + @ToolParam(required = false, description = "设备 id,正整数,精确匹配;与 airmac 二选一必填") Long id, + @ToolParam(required = false, description = "设备 SN/mac,精确匹配;与 id 二选一必填") String airmac) { + if (id == null && !StringUtils.hasText(airmac)) { + throw new IllegalArgumentException("id 与 airmac 至少填一个"); + } + validatePositive(id, "id"); + + User user = AuthContextHolder.get().getUser(); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(ToiletAirquality::getSoftDelete, 0); + if (id != null) { + wrapper.eq(ToiletAirquality::getId, id); + } else { + wrapper.eq(ToiletAirquality::getAirmac, airmac.trim()); + } + permissionService.applyToiletAirqualityRowFilter(wrapper, user); + ToiletAirquality device = toiletAirqualityMapper.selectOne(wrapper); + + Map result = new LinkedHashMap<>(); + if (device == null) { + result.put("record", null); + result.put("notice", "设备不存在或无权访问"); + return result; + } + Map record = toRecord(device, enrich(List.of(device))); + record.put("site", siteInfo(device)); + result.put("record", record); + return result; + } + + @XqTool(category = "toilet", kind = "read") + @Tool(name = "toilet.airquality.metrics.query", description = "分页查询单台空感设备的指标上报明细" + + "(ES 云集群 toilet_airquality_detail_* 索引,对应老系统 /toiletAirQualityLog/listByPage)。" + + "airmac 必填(match_phrase 精确短语匹配);startDate/endDate 非必填 yyyy-MM-dd 闭区间," + + "缺省查当天(与老系统一致),只填一个时另一个取同一天;endDate 不得晚于今天,区间最长 31 天。" + + "按 updateTime 倒序,page 默认 1、rows 默认 20、上限 100。" + + "record 含 updateTime 与 temp/hum/pm2d5/pm10/pm1d0/co2/tvoc/ammonia/hydrogensulfide/formaldehyde 十项指标原值" + + "(pm10 即老页面「烟感」、tvoc 即「异味」)。" + + "先经主档行权限校验 airmac 可见性,设备不存在或无权访问返回 NOT_FOUND_OR_FORBIDDEN。") + public Map toiletAirqualityMetricsQuery( + @ToolParam(description = "设备 SN/mac,必填,非空,精确匹配") String airmac, + @ToolParam(required = false, description = "开始日期,yyyy-MM-dd,含当天;缺省=当天") String startDate, + @ToolParam(required = false, description = "结束日期,yyyy-MM-dd,含当天,不早于 startDate、不得晚于今天;缺省=当天") String endDate, + @ToolParam(required = false, description = "页码,从 1 开始,默认 1") Integer page, + @ToolParam(required = false, description = "每页条数,默认 20,上限 100,超出截断为 100") Integer rows) { + if (!StringUtils.hasText(airmac)) { + throw new IllegalArgumentException("airmac 必填,非空"); + } + LocalDate start = parseOptionalDay(startDate, "startDate"); + LocalDate end = parseOptionalDay(endDate, "endDate"); + LocalDate today = LocalDate.now(SHANGHAI); + // 缺省=当天(老系统口径);只填一个时另一个取同一天 + if (start == null && end == null) { + start = today; + end = today; + } else if (start == null) { + start = end; + } else if (end == null) { + end = start; + } + if (start.isAfter(end)) { + throw new IllegalArgumentException("startDate 不能晚于 endDate"); + } + if (end.isAfter(today)) { + throw new IllegalArgumentException("endDate 不得晚于今天"); + } + if (ChronoUnit.DAYS.between(start, end) + 1 > MAX_QUERY_DAYS) { + throw new IllegalArgumentException( + "查询时间段最长 " + MAX_QUERY_DAYS + " 天(一个月),请修改日期范围"); + } + + User user = AuthContextHolder.get().getUser(); + String trimmedAirmac = airmac.trim(); + requireVisible(trimmedAirmac, user); + + int pageNo = page == null || page < 1 ? 1 : page; + boolean truncatedRows = rows != null && rows > MAX_ROWS; + int pageSize = rows == null || rows < 1 ? DEFAULT_ROWS : Math.min(rows, MAX_ROWS); + long fromLong = (long) (pageNo - 1) * pageSize; + String windowNotice = null; + if (fromLong >= MAX_RESULT_WINDOW) { + // from+size 超出 ES max_result_window 会被 ES 拒绝,截断为窗口内最后可用的完整页 + fromLong = MAX_RESULT_WINDOW - pageSize; + windowNotice = "查询起始位置超过 ES max_result_window(10000),已截断为第 " + + (fromLong / pageSize + 1) + " 页起的最后可用窗口"; + } else if (fromLong + pageSize > MAX_RESULT_WINDOW) { + windowNotice = "本页超出 ES max_result_window(10000),已截断为 " + (MAX_RESULT_WINDOW - fromLong) + " 条"; + pageSize = MAX_RESULT_WINDOW - (int) fromLong; + } + + long gteMillis = start.atStartOfDay(SHANGHAI).toInstant().toEpochMilli(); + long lteMillis = end.plusDays(1).atStartOfDay(SHANGHAI).toInstant().toEpochMilli() - 1; + ToiletAirqualityMetricsEsGateway.Result search = metricsEsGateway.search( + METRICS_INDEX, trimmedAirmac, gteMillis, lteMillis, (int) fromLong, pageSize); + + List> records = new ArrayList<>(); + for (ToiletAirqualityMetricsEsGateway.Record record : search.getRecords()) { + Map row = new LinkedHashMap<>(); + row.put("updateTime", record.getUpdateTime()); + row.put("temp", record.getTemp()); + row.put("hum", record.getHum()); + row.put("pm2d5", record.getPm2d5()); + row.put("pm10", record.getPm10()); + row.put("pm1d0", record.getPm1d0()); + row.put("co2", record.getCo2()); + row.put("tvoc", record.getTvoc()); + row.put("ammonia", record.getAmmonia()); + row.put("hydrogensulfide", record.getHydrogensulfide()); + row.put("formaldehyde", record.getFormaldehyde()); + records.add(row); + } + + Map result = new LinkedHashMap<>(); + result.put("airmac", trimmedAirmac); + result.put("index", METRICS_INDEX); + result.put("total", search.getTotal()); + result.put("page", pageNo); + result.put("rows", pageSize); + String notice = notice(truncatedRows, windowNotice); + if (notice != null) { + result.put("notice", notice); + } + result.put("records", records); + return result; + } + + @XqTool(category = "toilet", kind = "read") + @Tool(name = "toilet.airquality.statelog.list.query", description = "分页查询单台空感设备的状态变更日志" + + "(m0 toilet_airquality_log,对应老系统 /toiletAirQualityLog/logListByPage)。" + + "airmac 必填(精确匹配);startUpdateTime/endUpdateTime 可选 yyyy-MM-dd 闭区间,缺省不过滤;" + + "state 可选(0=异常 1=正常 2=离线 3=在线)。按 updateTime 倒序、id 倒序," + + "page 默认 1、rows 默认 20、上限 100。" + + "先经主档行权限校验 airmac 可见性,设备不存在或无权访问返回 NOT_FOUND_OR_FORBIDDEN。") + public Map toiletAirqualityStatelogListQuery( + @ToolParam(description = "设备 SN/mac,必填,非空,精确匹配") String airmac, + @ToolParam(required = false, description = "变更时间起,yyyy-MM-dd,含当天;缺省不过滤") String startUpdateTime, + @ToolParam(required = false, description = "变更时间止,yyyy-MM-dd,含当天,不早于 startUpdateTime;缺省不过滤") String endUpdateTime, + @ToolParam(required = false, description = "状态:0=异常 1=正常 2=离线 3=在线") Integer state, + @ToolParam(required = false, description = "页码,从 1 开始,默认 1") Integer page, + @ToolParam(required = false, description = "每页条数,默认 20,上限 100") Integer rows) { + if (!StringUtils.hasText(airmac)) { + throw new IllegalArgumentException("airmac 必填,非空"); + } + LocalDate start = parseOptionalDay(startUpdateTime, "startUpdateTime"); + LocalDate end = parseOptionalDay(endUpdateTime, "endUpdateTime"); + if (start != null && end != null && start.isAfter(end)) { + throw new IllegalArgumentException("startUpdateTime 不能晚于 endUpdateTime"); + } + validateEnum(state, "state", 0, 1, 2, 3); + + User user = AuthContextHolder.get().getUser(); + String trimmedAirmac = airmac.trim(); + requireVisible(trimmedAirmac, user); + + 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(ToiletAirqualityLog::getSoftDelete, 0) + .eq(ToiletAirqualityLog::getAirmac, trimmedAirmac); + if (state != null) { + wrapper.eq(ToiletAirqualityLog::getState, state); + } + if (start != null) { + wrapper.ge(ToiletAirqualityLog::getUpdateTime, + Date.from(start.atStartOfDay(SHANGHAI).toInstant())); + } + if (end != null) { + wrapper.lt(ToiletAirqualityLog::getUpdateTime, + Date.from(end.plusDays(1).atStartOfDay(SHANGHAI).toInstant())); + } + + Long total = toiletAirqualityLogMapper.selectCount(wrapper); + wrapper.orderByDesc(ToiletAirqualityLog::getUpdateTime) + .orderByDesc(ToiletAirqualityLog::getId) + .last("LIMIT " + (long) (pageNo - 1) * pageSize + "," + pageSize); + List> records = toiletAirqualityLogMapper.selectList(wrapper).stream() + .map(this::stateLogRecord).collect(Collectors.toList()); + + Map result = new LinkedHashMap<>(); + result.put("airmac", trimmedAirmac); + 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; + } + + /** metrics/statelog 无权限列:先经主档行过滤校验 airmac 可见性,不可见直接拒绝(补老接口权限漏洞)。 */ + private ToiletAirquality requireVisible(String airmac, User user) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(ToiletAirquality::getSoftDelete, 0) + .eq(ToiletAirquality::getAirmac, airmac); + permissionService.applyToiletAirqualityRowFilter(wrapper, user); + ToiletAirquality device = toiletAirqualityMapper.selectOne(wrapper); + if (device == null) { + throw new ToolCallException(McpErrorCode.NOT_FOUND_OR_FORBIDDEN, "设备不存在或无权访问"); + } + return device; + } + + /** 站点信息:district 表现有字段;楼座/楼层取主档。 */ + private Map siteInfo(ToiletAirquality device) { + Map site = new LinkedHashMap<>(); + DistrictDistrictStrategy district = device.getDistrictId() == null + ? null : districtMapper.selectById(device.getDistrictId()); + site.put("districtId", device.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", device.getBuildingId()); + site.put("floor", device.getFloor()); + return site; + } + + /** 批量富化上下文:Redis 名称 multiGet + 本页物联网卡 IN 查询,一次查询替代老接口 N+1。 */ + private Enrichment enrich(List list) { + Enrichment enrichment = new Enrichment(); + if (list.isEmpty()) { + return enrichment; + } + enrichment.agentNames = redisNames(list, ToiletAirquality::getAgentId, NAME_AGENT_PREFIX); + enrichment.propertyNames = redisNames(list, ToiletAirquality::getPropertyId, NAME_PROPERTY_PREFIX); + enrichment.districtNames = redisNames(list, ToiletAirquality::getDistrictId, NAME_DISTRICT_PREFIX); + 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; + } + + /** 本页非空 imsi 一次性 IN 查 networkcard(老接口按条 limit 1,取首条语义保留)。 */ + private Map networkcards(List list) { + List imsis = list.stream().map(ToiletAirquality::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(ToiletAirquality d, Enrichment enrichment) { + Map record = new LinkedHashMap<>(); + record.put("id", d.getId()); + record.put("airmac", d.getAirmac()); + record.put("state", d.getState()); + record.put("stateText", text(d.getState(), "异常", "正常")); + record.put("runState", d.getRunState()); + record.put("runStateText", text(d.getRunState(), "离线", "在线")); + record.put("onlineState", d.getOnlineState()); + record.put("onlineStateText", text(d.getOnlineState(), "初始化", "上线", "下线")); + record.put("onlineTime", format(d.getOnlineTime())); + record.put("agentId", d.getAgentId()); + record.put("agentName", mapGet(enrichment.agentNames, d.getAgentId())); + record.put("propertyId", d.getPropertyId()); + record.put("propertyName", mapGet(enrichment.propertyNames, d.getPropertyId())); + record.put("districtId", d.getDistrictId()); + record.put("districtName", mapGet(enrichment.districtNames, d.getDistrictId())); + record.put("buildingId", d.getBuildingId()); + record.put("floor", d.getFloor()); + record.put("installPosition", d.getInstallPosition()); + record.put("toiletId", d.getToiletId()); + record.put("positionId", d.getPositionId()); + record.put("temp", d.getTemp()); + record.put("hum", d.getHum()); + record.put("pm2d5", d.getPm2d5()); + record.put("pm10", d.getPm10()); + record.put("pm1d0", d.getPm1d0()); + record.put("co2", d.getCo2()); + record.put("tvoc", d.getTvoc()); + record.put("ammonia", d.getAmmonia()); + record.put("hydrogensulfide", d.getHydrogensulfide()); + record.put("formaldehyde", d.getFormaldehyde()); + record.put("hardware", d.getHardware()); + record.put("firmware", d.getFirmware()); + record.put("protocol", d.getProtocol()); + record.put("upInterval", d.getUpInterval()); + record.put("imsi", d.getImsi()); + record.put("signalValue", d.getSignalValue()); + // 物联网卡覆盖主档 factoryType(对齐老列表 :153 语义) + Networkcard card = mapGet(enrichment.networkcards, d.getImsi()); + Integer factoryType = card == null ? d.getFactoryType() : card.getFactoryType(); + record.put("factoryType", factoryType); + record.put("factoryTypeText", text(factoryType, "未知", "合宙", "合宙", "SIMBOOS")); + record.put("voiceFlag", d.getVoiceFlag()); + record.put("voiceFlagText", text(d.getVoiceFlag(), "不支持", "支持")); + record.put("shockFlag", d.getShockFlag()); + record.put("shockFlagText", text(d.getShockFlag(), "不支持", "支持")); + record.put("muteFlag", d.getMuteFlag()); + record.put("muteFlagText", text(d.getMuteFlag(), "关闭", "开启")); + record.put("shockTimeFlag", d.getShockTimeFlag()); + record.put("shockTimeFlagText", text(d.getShockTimeFlag(), "关闭", "开启")); + record.put("pilotLightFlag", d.getPilotLightFlag()); + record.put("pilotLightFlagText", text(d.getPilotLightFlag(), "关闭", "开启")); + record.put("createTime", format(d.getCreateTime())); + record.put("modifyTime", format(d.getModifyTime())); + return record; + } + + private Map stateLogRecord(ToiletAirqualityLog log) { + Map row = new LinkedHashMap<>(); + row.put("updateTime", format(log.getUpdateTime())); + row.put("state", log.getState()); + row.put("stateText", text(log.getState(), "异常", "正常", "离线", "在线")); + return row; + } + + private static final class Enrichment { + Map agentNames = Map.of(); + Map propertyNames = Map.of(); + Map districtNames = Map.of(); + Map networkcards = Map.of(); + } + + private static V mapGet(Map map, K key) { + return key == null ? null : map.get(key); + } + + private static String text(Integer value, String... labels) { + return value != null && value >= 0 && value < labels.length ? labels[value] : null; + } + + private static String format(Date value) { + return value == null ? null : TIME_FORMATTER.format(value.toInstant()); + } + + private static LocalDate parseOptionalDay(String value, String name) { + if (!StringUtils.hasText(value)) { + return null; + } + try { + return LocalDate.parse(value.trim(), DateTimeFormatter.ISO_LOCAL_DATE); + } catch (DateTimeParseException ex) { + throw new IllegalArgumentException(name + " 格式非法,要求 yyyy-MM-dd: " + value); + } + } + + private static String notice(boolean truncatedRows, String windowNotice) { + StringBuilder builder = new StringBuilder(); + if (truncatedRows) { + builder.append("rows 超过上限 100,已截断为 100"); + } + if (windowNotice != null) { + if (builder.length() > 0) { + builder.append(";"); + } + builder.append(windowNotice); + } + return builder.length() == 0 ? null : builder.toString(); + } + + 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 9ec418e..491350c 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 @@ -8,6 +8,7 @@ import me.iiv.agent.entity.m0.BatteryFaultLog; 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.ToiletAirquality; import me.iiv.agent.entity.m0.User; import me.iiv.agent.entity.m1.MallGoodsOrder; import me.iiv.agent.entity.m2.ContractNew; @@ -858,6 +859,29 @@ public class PermissionService { } } + /** + * 空感(toilet_airquality)行权限:逐行镜像 applyHardwareRowFilter—— + * 老列表 /toiletAirQuality/listByPage 用的就是同一 getByIsAgent 20250401 变体 + * (propertyFlag=true, className="property_id",ToiletAirQualityController.java:122), + * 落到 toilet_airquality 的 agent_id / property_id 字段上,语义 1:1 对齐。 + */ + public void applyToiletAirqualityRowFilter(LambdaQueryWrapper wrapper, User user) { + RowScope scope = resolveRowScope(user); + if (scope.headquarters) { + return; + } + if (scope.salesPropertyIds != null) { + wrapper.in(ToiletAirquality::getPropertyId, scope.salesPropertyIds); + return; + } + if (!scope.managedPropertyIds.isEmpty()) { + wrapper.and(w -> w.in(ToiletAirquality::getAgentId, scope.ids) + .or().in(ToiletAirquality::getPropertyId, scope.managedPropertyIds)); + } else { + wrapper.in(ToiletAirquality::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/es/ToiletAirqualityMetricsEsGatewayImplTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/es/ToiletAirqualityMetricsEsGatewayImplTest.java new file mode 100644 index 0000000..7fcae55 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/es/ToiletAirqualityMetricsEsGatewayImplTest.java @@ -0,0 +1,138 @@ +package me.iiv.agent.es; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; +import org.apache.http.util.EntityUtils; +import org.elasticsearch.client.Request; +import org.elasticsearch.client.Response; +import org.elasticsearch.client.RestClient; +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 java.io.IOException; + +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.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ToiletAirqualityMetricsEsGatewayImplTest { + + private static final String RESPONSE_JSON = "{" + + "\"took\":1,\"timed_out\":false," + + "\"_shards\":{\"total\":1,\"successful\":1,\"skipped\":0,\"failed\":0}," + + "\"hits\":{\"total\":{\"value\":2,\"relation\":\"eq\"}," + + "\"hits\":[" + + "{\"_index\":\"toilet_airquality_detail_2026\"," + + "\"_source\":{\"airmac\":\"AIR-001\",\"updateTime\":\"2026-10-12T10:00:00.000+08:00\"," + + "\"temp\":\"26.5\",\"hum\":60,\"pm2d5\":12.0,\"pm10\":20.0,\"pm1d0\":8.0," + + "\"co2\":\"600\",\"tvoc\":0.3,\"ammonia\":0.1,\"hydrogensulfide\":0.01,\"formaldehyde\":\"0.05\"}}," + + "{\"_index\":\"toilet_airquality_detail_2026\"," + + "\"_source\":{\"airmac\":\"AIR-001\",\"updateTime\":1790000000000,\"temp\":null,\"hum\":\"abc\"}}" + + "]}}"; + + @Mock + RestClient cloudRestClient; + + ToiletAirqualityMetricsEsGatewayImpl gateway; + + private final ObjectMapper mapper = new ObjectMapper(); + + @BeforeEach + void setUp() { + gateway = new ToiletAirqualityMetricsEsGatewayImpl(cloudRestClient); + } + + private static Response esResponse(String jsonBody) throws IOException { + Response response = org.mockito.Mockito.mock(Response.class); + when(response.getEntity()).thenReturn(new StringEntity(jsonBody, ContentType.APPLICATION_JSON)); + return response; + } + + private JsonNode captureRequestBody(String airmac) throws Exception { + Response esResponse = esResponse(RESPONSE_JSON); + when(cloudRestClient.performRequest(any(Request.class))).thenReturn(esResponse); + gateway.search("toilet_airquality_detail_*", airmac, 1789920000000L, 1790006399000L, 0, 20); + ArgumentCaptor captor = ArgumentCaptor.forClass(Request.class); + verify(cloudRestClient).performRequest(captor.capture()); + Request captured = captor.getValue(); + assertEquals("POST", captured.getMethod()); + assertEquals("/toilet_airquality_detail_*/_search", captured.getEndpoint()); + return mapper.readTree(EntityUtils.toString(captured.getEntity())); + } + + @Test + void buildsMatchPhraseRangeSortDescAndPagination() throws Exception { + JsonNode body = captureRequestBody("AIR-001"); + + assertEquals(0, body.get("from").asInt()); + assertEquals(20, body.get("size").asInt()); + assertTrue(body.get("track_total_hits").asBoolean()); + assertEquals("desc", body.get("sort").get(0).get("updateTime").get("order").asText()); + + JsonNode filters = body.at("/query/bool/filter"); + // airmac 必须 match_phrase(老系统 matchQuery 分词误配的修复) + assertTrue(anyFilter(filters, f -> f.has("match_phrase") + && f.get("match_phrase").get("airmac").get("query").asText().equals("AIR-001"))); + // updateTime 闭区间 range 发 epoch 毫秒 + assertTrue(anyFilter(filters, f -> f.has("range") + && f.get("range").get("updateTime").get("gte").asLong() == 1789920000000L + && f.get("range").get("updateTime").get("lte").asLong() == 1790006399000L)); + } + + @Test + void mapsHitsWithEpochAndIsoUpdateTimeAndTextMetrics() throws Exception { + Response esResponse = esResponse(RESPONSE_JSON); + when(cloudRestClient.performRequest(any(Request.class))).thenReturn(esResponse); + + ToiletAirqualityMetricsEsGateway.Result result = gateway.search( + "toilet_airquality_detail_*", "AIR-001", 1789920000000L, 1790006399000L, 0, 20); + + assertEquals(2, result.getTotal()); + assertEquals(2, result.getRecords().size()); + + ToiletAirqualityMetricsEsGateway.Record first = result.getRecords().get(0); + // ISO 文本归一为 Asia/Shanghai yyyy-MM-dd HH:mm:ss + assertEquals("2026-10-12 10:00:00", first.getUpdateTime()); + // 文本/数值双形态归一为 Double + assertEquals(26.5, first.getTemp()); + assertEquals(60.0, first.getHum()); + assertEquals(600.0, first.getCo2()); + assertEquals(0.05, first.getFormaldehyde()); + + ToiletAirqualityMetricsEsGateway.Record second = result.getRecords().get(1); + // epoch 毫秒归一为文本(1790000000000 = 2026-09-21 22:13:20 Asia/Shanghai) + assertEquals("2026-09-21 22:13:20", second.getUpdateTime()); + // null 与非数值文本归一为 null + assertNull(second.getTemp()); + assertNull(second.getHum()); + } + + @Test + void wrapsEsFailureInIllegalStateException() throws Exception { + when(cloudRestClient.performRequest(any(Request.class))) + .thenThrow(new IOException("connection refused")); + + assertThrows(IllegalStateException.class, () -> gateway.search( + "toilet_airquality_detail_*", "AIR-001", 1789920000000L, 1790006399000L, 0, 20)); + } + + private static boolean anyFilter(JsonNode filters, java.util.function.Predicate predicate) { + for (JsonNode filter : filters) { + if (predicate.test(filter)) { + return true; + } + } + return false; + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/ToiletAirqualityToolsTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/ToiletAirqualityToolsTest.java new file mode 100644 index 0000000..c387f64 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/ToiletAirqualityToolsTest.java @@ -0,0 +1,434 @@ +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.Networkcard; +import me.iiv.agent.entity.m0.ToiletAirquality; +import me.iiv.agent.entity.m0.ToiletAirqualityLog; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.es.ToiletAirqualityMetricsEsGateway; +import me.iiv.agent.mapper.m0.DistrictDistrictStrategyMapper; +import me.iiv.agent.mapper.m0.NetworkcardMapper; +import me.iiv.agent.mapper.m0.ToiletAirqualityLogMapper; +import me.iiv.agent.mapper.m0.ToiletAirqualityMapper; +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.LocalDate; +import java.time.ZoneId; +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.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +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 ToiletAirqualityToolsTest { + + private static final ZoneId SHANGHAI = ZoneId.of("Asia/Shanghai"); + + @Mock + ToiletAirqualityMapper toiletAirqualityMapper; + @Mock + ToiletAirqualityLogMapper toiletAirqualityLogMapper; + @Mock + NetworkcardMapper networkcardMapper; + @Mock + DistrictDistrictStrategyMapper districtMapper; + @Mock + PermissionService permissionService; + @Mock + StringRedisTemplate redisTemplate; + @Mock + ToiletAirqualityMetricsEsGateway metricsEsGateway; + + ToiletAirqualityTools tools; + User user; + + @BeforeEach + void setUp() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), + ToiletAirquality.class); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), + ToiletAirqualityLog.class); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), + Networkcard.class); + tools = new ToiletAirqualityTools(toiletAirqualityMapper, toiletAirqualityLogMapper, + networkcardMapper, districtMapper, permissionService, redisTemplate, metricsEsGateway); + user = new User(); + user.setId(7L); + AuthContextHolder.set(new AuthContext(null, user)); + } + + @AfterEach + void tearDown() { + AuthContextHolder.clear(); + } + + @Test + @SuppressWarnings("unchecked") + void listBuildsFiltersStableSortAndEnrichment() { + ToiletAirquality device = new ToiletAirquality(); + device.setId(9L); + device.setAirmac("AIR-001"); + device.setState(1); + device.setRunState(1); + device.setOnlineState(1); + device.setAgentId(12L); + device.setPropertyId(11L); + device.setDistrictId(5L); + device.setImsi("460001234567890"); + device.setFactoryType(0); + device.setMuteFlag(1); + when(toiletAirqualityMapper.selectCount(any())).thenReturn(1L); + when(toiletAirqualityMapper.selectList(any())).thenReturn(List.of(device)); + + ValueOperations valueOperations = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + // name:Agent:12 / name:Properyt:11 / name:District:5 + 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()); + }); + Networkcard card = new Networkcard(); + card.setImsi("460001234567890"); + card.setFactoryType(3); + when(networkcardMapper.selectList(any())).thenReturn(List.of(card)); + + Map result = tools.toiletAirqualityListQuery( + "AIR", 12L, 11L, 5L, 3L, "3F", 1, 1, 1, "hw1", "fw1", "pt1", + "460001234567890", 1, 0, 1, 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(toiletAirqualityMapper).selectList(captor.capture()); + String sql = captor.getValue().getSqlSegment(); + assertTrue(sql.contains("airmac LIKE")); + 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("state =")); + assertTrue(sql.contains("run_state =")); + assertTrue(sql.contains("online_state =")); + assertTrue(sql.contains("hardware =")); + assertTrue(sql.contains("firmware =")); + assertTrue(sql.contains("protocol =")); + assertTrue(sql.contains("imsi =")); + assertTrue(sql.contains("mute_flag =")); + assertTrue(sql.contains("shock_time_flag =")); + assertTrue(sql.contains("pilot_light_flag =")); + assertTrue(sql.contains("soft_delete =")); + assertTrue(sql.contains("ORDER BY id DESC")); + verify(permissionService).applyToiletAirqualityRowFilter(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("stateText")); + assertEquals("在线", record.get("runStateText")); + assertEquals("上线", record.get("onlineStateText")); + assertEquals("开启", record.get("muteFlagText")); + // factoryType 被 networkcard 覆盖(回归老 :153 语义) + assertEquals(3, record.get("factoryType")); + assertEquals("SIMBOOS", record.get("factoryTypeText")); + } + + @Test + @SuppressWarnings("unchecked") + void listKeepsMainFactoryTypeAndNullNamesWhenEnrichmentMissing() { + ToiletAirquality device = new ToiletAirquality(); + device.setId(9L); + device.setAirmac("AIR-002"); + device.setFactoryType(1); + when(toiletAirqualityMapper.selectCount(any())).thenReturn(1L); + when(toiletAirqualityMapper.selectList(any())).thenReturn(List.of(device)); + + // 设备无 agentId/propertyId/districtId,不触发 Redis 名称查询 + + Map result = tools.toiletAirqualityListQuery( + null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, 1, 20); + + Map record = ((List>) result.get("records")).get(0); + assertEquals(1, record.get("factoryType")); + assertEquals("合宙", record.get("factoryTypeText")); + assertNull(record.get("agentName")); + // 无 imsi 不查物联网卡 + verify(networkcardMapper, never()).selectList(any()); + } + + @Test + void listRejectsInvalidEnumsAndIdsBeforeQuerying() { + assertThrows(IllegalArgumentException.class, () -> tools.toiletAirqualityListQuery( + null, 0L, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, 1, 20)); + assertThrows(IllegalArgumentException.class, () -> tools.toiletAirqualityListQuery( + null, null, null, null, null, null, 2, null, null, null, null, null, + null, null, null, null, 1, 20)); + assertThrows(IllegalArgumentException.class, () -> tools.toiletAirqualityListQuery( + null, null, null, null, null, null, null, null, 3, null, null, null, + null, null, null, null, 1, 20)); + assertThrows(IllegalArgumentException.class, () -> tools.toiletAirqualityListQuery( + null, null, null, null, null, null, null, null, null, null, null, null, + null, 2, null, null, 1, 20)); + verifyNoInteractions(toiletAirqualityMapper); + } + + @Test + void detailRequiresIdOrAirmac() { + assertThrows(IllegalArgumentException.class, () -> tools.toiletAirqualityDetailQuery(null, null)); + assertThrows(IllegalArgumentException.class, () -> tools.toiletAirqualityDetailQuery(0L, " ")); + verifyNoInteractions(toiletAirqualityMapper); + } + + @Test + void detailReturnsNullRecordWithNoticeWhenMissingOrUnauthorized() { + when(toiletAirqualityMapper.selectOne(any())).thenReturn(null); + + Map result = tools.toiletAirqualityDetailQuery(null, "AIR-404"); + + assertNull(result.get("record")); + assertEquals("设备不存在或无权访问", result.get("notice")); + verify(permissionService).applyToiletAirqualityRowFilter(any(), any()); + } + + @Test + @SuppressWarnings("unchecked") + void detailByIdReturnsRecordWithSite() { + ToiletAirquality device = new ToiletAirquality(); + device.setId(9L); + device.setAirmac("AIR-001"); + device.setDistrictId(5L); + device.setBuildingId(3L); + device.setFloor("3F"); + when(toiletAirqualityMapper.selectOne(any())).thenReturn(device); + + ValueOperations valueOperations = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.multiGet(anyList())).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.toiletAirqualityDetailQuery(9L, null); + + Map record = (Map) result.get("record"); + assertEquals("AIR-001", record.get("airmac")); + 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 metricsRequiresAirmac() { + assertThrows(IllegalArgumentException.class, + () -> tools.toiletAirqualityMetricsQuery(" ", null, null, 1, 20)); + verifyNoInteractions(toiletAirqualityMapper, metricsEsGateway); + } + + @Test + void metricsRejectsInvalidDateRange() { + assertThrows(IllegalArgumentException.class, + () -> tools.toiletAirqualityMetricsQuery("AIR-001", "2026-10-11", "2026-10-10", 1, 20)); + assertThrows(IllegalArgumentException.class, + () -> tools.toiletAirqualityMetricsQuery("AIR-001", null, "2999-01-01", 1, 20)); + assertThrows(IllegalArgumentException.class, + () -> tools.toiletAirqualityMetricsQuery("AIR-001", "2026-08-01", "2026-09-15", 1, 20)); + assertThrows(IllegalArgumentException.class, + () -> tools.toiletAirqualityMetricsQuery("AIR-001", "2026/10/01", null, 1, 20)); + verifyNoInteractions(toiletAirqualityMapper, metricsEsGateway); + } + + @Test + void metricsRejectsInvisibleAirmac() { + when(toiletAirqualityMapper.selectOne(any())).thenReturn(null); + + ToolCallException exception = assertThrows(ToolCallException.class, + () -> tools.toiletAirqualityMetricsQuery("AIR-404", null, null, 1, 20)); + + assertEquals(McpErrorCode.NOT_FOUND_OR_FORBIDDEN, exception.getCode()); + verify(permissionService).applyToiletAirqualityRowFilter(any(), any()); + verifyNoInteractions(metricsEsGateway); + } + + @Test + @SuppressWarnings("unchecked") + void metricsDefaultsToTodayAndMapsRecords() { + ToiletAirquality device = new ToiletAirquality(); + device.setId(9L); + device.setAirmac("AIR-001"); + when(toiletAirqualityMapper.selectOne(any())).thenReturn(device); + when(metricsEsGateway.search(anyString(), anyString(), anyLong(), anyLong(), + eq(0), eq(20))).thenReturn(new ToiletAirqualityMetricsEsGateway.Result(1, + List.of(new ToiletAirqualityMetricsEsGateway.Record("2026-10-12 10:00:00", + 26.5, 60.0, 12.0, 20.0, 8.0, 600.0, 0.3, 0.1, 0.01, 0.05)))); + + Map result = tools.toiletAirqualityMetricsQuery("AIR-001", null, null, 1, 20); + + // 缺省=当天闭区间(epoch 毫秒) + LocalDate today = LocalDate.now(SHANGHAI); + long expectedGte = today.atStartOfDay(SHANGHAI).toInstant().toEpochMilli(); + long expectedLte = today.plusDays(1).atStartOfDay(SHANGHAI).toInstant().toEpochMilli() - 1; + verify(metricsEsGateway).search(eq("toilet_airquality_detail_*"), eq("AIR-001"), + eq(expectedGte), eq(expectedLte), eq(0), eq(20)); + + assertEquals("AIR-001", result.get("airmac")); + assertEquals("toilet_airquality_detail_*", result.get("index")); + assertEquals(1L, result.get("total")); + Map record = ((List>) result.get("records")).get(0); + assertEquals("2026-10-12 10:00:00", record.get("updateTime")); + assertEquals(26.5, record.get("temp")); + assertEquals(0.05, record.get("formaldehyde")); + } + + @Test + void metricsSingleDateFillsTheOtherSide() { + ToiletAirquality device = new ToiletAirquality(); + device.setId(9L); + device.setAirmac("AIR-001"); + when(toiletAirqualityMapper.selectOne(any())).thenReturn(device); + when(metricsEsGateway.search(anyString(), anyString(), anyLong(), anyLong(), + eq(0), eq(20))).thenReturn(new ToiletAirqualityMetricsEsGateway.Result(0, List.of())); + + tools.toiletAirqualityMetricsQuery("AIR-001", "2026-10-01", null, 1, 20); + + long expectedGte = LocalDate.parse("2026-10-01").atStartOfDay(SHANGHAI).toInstant().toEpochMilli(); + long expectedLte = LocalDate.parse("2026-10-02").atStartOfDay(SHANGHAI).toInstant().toEpochMilli() - 1; + verify(metricsEsGateway).search(anyString(), anyString(), eq(expectedGte), eq(expectedLte), eq(0), eq(20)); + } + + @Test + void statelogRequiresAirmacAndValidatesParams() { + assertThrows(IllegalArgumentException.class, + () -> tools.toiletAirqualityStatelogListQuery(null, null, null, null, 1, 20)); + assertThrows(IllegalArgumentException.class, + () -> tools.toiletAirqualityStatelogListQuery("AIR-001", "2026-10-11", "2026-10-10", null, 1, 20)); + assertThrows(IllegalArgumentException.class, + () -> tools.toiletAirqualityStatelogListQuery("AIR-001", null, null, 4, 1, 20)); + verifyNoInteractions(toiletAirqualityMapper, toiletAirqualityLogMapper); + } + + @Test + void statelogRejectsInvisibleAirmac() { + when(toiletAirqualityMapper.selectOne(any())).thenReturn(null); + + ToolCallException exception = assertThrows(ToolCallException.class, + () -> tools.toiletAirqualityStatelogListQuery("AIR-404", null, null, null, 1, 20)); + + assertEquals(McpErrorCode.NOT_FOUND_OR_FORBIDDEN, exception.getCode()); + verifyNoInteractions(toiletAirqualityLogMapper); + } + + @Test + @SuppressWarnings("unchecked") + void statelogBuildsFiltersAndMapsStateText() { + ToiletAirquality device = new ToiletAirquality(); + device.setId(9L); + device.setAirmac("AIR-001"); + when(toiletAirqualityMapper.selectOne(any())).thenReturn(device); + + ToiletAirqualityLog log = new ToiletAirqualityLog(); + log.setId(3L); + log.setAirmac("AIR-001"); + log.setState(3); + log.setUpdateTime(new Date(1789920000000L)); + when(toiletAirqualityLogMapper.selectCount(any())).thenReturn(1L); + when(toiletAirqualityLogMapper.selectList(any())).thenReturn(List.of(log)); + + Map result = tools.toiletAirqualityStatelogListQuery( + "AIR-001", "2026-10-01", "2026-10-12", 3, 1, 20); + + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(toiletAirqualityLogMapper).selectList(captor.capture()); + String sql = captor.getValue().getSqlSegment(); + assertTrue(sql.contains("soft_delete =")); + assertTrue(sql.contains("airmac =")); + assertTrue(sql.contains("state =")); + assertTrue(sql.contains("update_time >=")); + assertTrue(sql.contains("update_time <")); + assertTrue(sql.contains("ORDER BY update_time DESC,id DESC")); + + Map record = ((List>) result.get("records")).get(0); + assertEquals(3, record.get("state")); + assertEquals("在线", record.get("stateText")); + String expectedTime = java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + .withZone(SHANGHAI).format(java.time.Instant.ofEpochMilli(1789920000000L)); + assertEquals(expectedTime, record.get("updateTime")); + } + + @Test + void springAiPublishesFourToolSchemas() { + 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(4, schemas.size()); + assertTrue(schemas.containsKey("toilet.airquality.list.query")); + assertTrue(schemas.containsKey("toilet.airquality.detail.query")); + assertTrue(schemas.containsKey("toilet.airquality.metrics.query")); + assertTrue(schemas.containsKey("toilet.airquality.statelog.list.query")); + assertTrue(schemas.get("toilet.airquality.metrics.query").contains("airmac")); + assertTrue(schemas.get("toilet.airquality.statelog.list.query").contains("startUpdateTime")); + } +}