Merge remote-tracking branch 'origin/main' into feat/REQ-20260922-0004-paper-percapita
# Conflicts: # xiaoqu-agent-service/README.md # xiaoqu-agent-service/doc/agent-mcp-tools-reference.md
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
# xiaoqu-agent-service 项目指引
|
||||
|
||||
## ai-proj 关联信息
|
||||
|
||||
- **ai-proj 项目 ID:224**(项目名 xiaoqu_agent_service)。本仓所有需求/任务创建后应挂到该项目,关联任务前确保任务也在项目 224。
|
||||
- 近期需求示例:REQ-20260921-0007(id 5778,设备电量消息查询工具,released)→ 任务 22306;REQ-20260919-0003(id 5747,出入库工具复查修复,released)→ 任务 22123。
|
||||
|
||||
## ai-proj MCP 已知限制与真实契约
|
||||
|
||||
- MCP `update_requirement` 不支持改 project_id/owner;改归属必须走 REST 两步:
|
||||
1. `POST /api/v1/requirements/{id}/reassignment/preflight`,body=`{operation_id:"<uuid>", expected_version:<当前sync_version>, target_project_id:224, target_owner_id?/clear_owner?}`
|
||||
2. `POST /api/v1/requirements/{id}/reassignment/confirm`,body=`{operation_id, preflight_token}`(两个字段缺一不可;preflight_token 一次性,confirm 失败即作废,必须在同一条命令里 preflight+confirm 原子执行)
|
||||
- REST base 推导:mcp.json 里 ai-proj 的 url 去掉尾部 `/mcp/sse`(即 `https://ai.pipexerp.com/api/v1`),认证头 `X-API-Key` 同 mcp.json。
|
||||
- ⚠️ 本机 shell(Bash 工具)直读 `~/.kimi-code/mcp.json` 会被托管层拦截("local Shell bypass is disabled for a managed MCP route");用 Read 工具读取不受限。
|
||||
- 任务跨项目用 `move_task`,再 `link_tasks_to_requirement`(role=implementation)。
|
||||
- 本机无 ai-proj CLI,远程同步需在有 CLI 的机器执行 `ai-proj sync requirement --id <id> --direction to_remote`。
|
||||
|
||||
## 生产 ES 集群关键坑
|
||||
|
||||
- 生产两个 ES 集群(cloud/local,app.es.cloud/local.*)均为 **ES 7.7.1**:elasticsearch-java 7.17 高阶客户端(ElasticsearchClient)的 vendor Content-Type(`application/vnd.elasticsearch+json; compatible-with=7`)被 406 拒绝,且其 X-Elastic-Product 响应头校验(7.14 才引入)过不了——**业务 ES 查询一律注入低阶 `esCloudRestClient`/`esLocalRestClient`(RestClient)bean**,不要注入 esCloudClient/esLocalClient(EsClientConfig 里仅为兼容保留)。
|
||||
- 低阶 `org.elasticsearch.client.Response` 构造器包私有且参数是 HttpResponse,测试里用 `mock(Response.class)` + stub `getEntity()` 构造;注意 `thenReturn(esResponse(...))` 里嵌套 when() 会触发 UnfinishedStubbingException,必须先创建好 Response 再 stub。
|
||||
- electricitymessage 索引(电量消息类):`createTime` 是 date 类型(format strict_date_optional_time||epoch_millis),range 必须发 **epoch 毫秒**(`yyyy-MM-dd HH:mm:ss` 字符串 400);`_source` 里 `value` 是**文本**("28"/"100")、`createTime` 是 ISO 文本(`2026-09-21T10:00:00.000+08:00`),读取时都要归一。老系统能跑是因为 transport 客户端把 Date 序列化成 epoch 毫秒、Jackson 把字符串 value 反序列化进 Integer 字段。
|
||||
- paperuselog 索引(换纸日志):`rfid` 是 **text** 类型(mapping 有 `ignore_above=256` 的 keyword 子字段),terms 聚合必须用 `rfid.keyword`,直接聚合 text 字段被 7.7.1 以 400 "Text fields are not optimised for aggregations" 拒绝(2026-09-22 生产实测修复);老系统不做 ES 聚合,而是翻页拉全量文档后在 Java 侧按 `P_{positionsId}_{snCode}_{rfid}`/`T_{toiletId}_{snCode}_{rfid}` 复合 key 去重,MCP 用 terms 聚合近似(语义差异见设计文档)。
|
||||
- curl 直连 MCP 冒烟:JSON body 务必 `-d @file`(行内 `-d` 长 JSON 经网关会被截断报 "Invalid message format");响应是 SSE,`sed -n 's/^data://p'` 取 JSON。
|
||||
- 托管 MCP 工具(mcp__xiaoqu__*)在服务重启后 session 失效会报 AI_PROJ_MCP_SEND_OUTCOME_UNKNOWN,改走上面 curl 直连即可。
|
||||
|
||||
## 生产部署要点
|
||||
|
||||
- `./deploy/deploy.sh remote-deploy`(SSH 别名 xiaoqu-prd,目录 /data/xiaoqu-agent-service)。
|
||||
- **部署前必须先把 main 推 origin**:出现过本地 main 领先 origin 1 个修复提交、他人从 origin 构建部署导致生产跑到旧代码的情况(2026-09-22 paperuselog terms 结构 400)。
|
||||
- 新工具上线后必须幂等绑 prod scope(svc 库 MySQL,tool 表唯一标识列是 `name`;scope 表是 `agent_scope`):hardware/district 系列惯例绑 scope 1(test)/2(operation)/3(xiaoqu-ai)/5(ops-query),工单/仓库系列只绑 scope 1。
|
||||
- prod AK 的 Authorization 头值本身已含 "Bearer " 前缀,不要再拼。
|
||||
@@ -94,6 +94,8 @@ xiaoqu-agent-service/
|
||||
| `district.paper.statistics.query` | hardware | read | 客户用纸统计(复刻老 /MonthlyStatistics/getMonthlyStatisticsMap)。入参 districtId/startDate/endDate/positionMode/deviceType/buildingId/floor/toiletId;出参 toiletGenders、summaryByGender(paperLengthM 米/useCount/perUseLengthM/deviceDayCount/rfidCount)、daily 按日明细。DB 聚合 m0 monthly_statistics;rfidCount 查 ES paperuselog(90 天热/冷索引,LocalEsDate 云/本地集群分界,rfid_use_flag 分口径)。详细设计见 doc/district-paper-statistics-design.md |
|
||||
| `district.paper.percapita.query` | district | read | 人均用纸统计。groupBy=gender/floor/floorGender 与 daily 组合覆盖老系统 5 个接口;返回面向 LLM 的扁平 rows(维度、用纸米数、使用人次、人均值同一行),无数据不补零。数据源为 m0 monthly_statistics,不查 ES;仅绑定用户委托作用域。详细设计见 doc/paper-percapita-statistics-mcp-design.md |
|
||||
| `district.paper.device_average.query` | hardware | read | 设备日均用纸统计。view=gender/daily_gender/floor/daily_floor/floor_gender 覆盖老系统 5 个硬件统计接口;数据库按视图聚合,固定以 COUNT(*) 为设备日统计记录数分母,返回统一 rows 和 summary。用户委托作用域与 ops-query 服务身份均可调用。 |
|
||||
| `battery.fault.list.query` | hardware | read | 电池故障分页查询。按站点/物业/代理商/SN/设备类型/故障类型/恢复状态/故障日期筛选;沿用设备行级权限,按故障时间和 ID 倒序。详细设计见 doc/battery-fault-mcp-tool-design.md |
|
||||
| `hardware.electricity.message.list.query` | hardware | read | 设备电量/信号/温度等消息记录分页查询(ES electricitymessage_yyyyMM 月索引,只走本地集群,无权限过滤,对齐老 /batteryAnalyse/batteryAnalyseList)。入参 snCode(必填)/startTime/endTime(必填且须同一天,跨天拒绝)/messageType(0-5,不传=全部类型)/page/rows(默认 1/500,上限 1000);出参 snCode/index/total/page/rows/records(createTime/messageType/messageTypeText/value),createTime ASC。详细设计见 doc/electricity-message-mcp-tool-design.md |
|
||||
|
||||
分页统一规范:page 从 1 开始,rows 默认 20、上限 100,超出截断为 100 并在返回 `notice` 字段提示。
|
||||
|
||||
|
||||
@@ -782,7 +782,44 @@
|
||||
|
||||
---
|
||||
|
||||
## 31. district.paper.percapita.query
|
||||
## 31. battery.fault.list.query
|
||||
|
||||
分页查询电池故障记录,对应旧系统 `POST /batteryFault/batteryFaultLogList`。
|
||||
|
||||
- 分类:hardware | 类型:读
|
||||
- 数据来源:m0 `battery_fault_log`,固定 `soft_delete=0`。
|
||||
- 入参:`districtId/propertyId/agentId`(正整数)、`snCode`(模糊匹配)、`deviceType`(0 厕纸机/出纸机、1 擦手纸机)、`faultType`(0 温度、1 电压、2 电流、3 充电次数)、`faultState`(0 未恢复、1 已恢复)、`faultDateFrom/faultDateTo`(yyyy-MM-dd,结束日期含当天)、`page/rows`。
|
||||
- 分页默认 1/20、上限 100,固定按 `fault_start_time DESC, id DESC` 稳定排序。
|
||||
- 行级权限与 `hardware.list.query` 一致:总部查看全部;代理查看自己、直属下级、代管代理及代管物业;销售非负责人限自己创建及授权范围内物业。服务身份仍由 MCP scope 控制。
|
||||
- 返回 `id/snCode/deviceType/deviceTypeText/faultType/faultTypeText/faultState/faultStateText/peakValue/standardValue/faultStartTime/faultAddTime/faultRecoveryTime/installPosition/agentId/agentName/propertyId/propertyName/districtId/districtName`。时间按 Asia/Shanghai 输出;峰值和标准值保留数据库原始整数,不推断单位。
|
||||
|
||||
详细设计见 `battery-fault-mcp-tool-design.md`。
|
||||
|
||||
---
|
||||
|
||||
## 32. hardware.electricity.message.list.query
|
||||
|
||||
分页查询设备电量/信号/温度/充电循环次数/电压/电流消息记录,对应旧系统
|
||||
`POST /batteryAnalyse/batteryAnalyseList`(BatteryAnalyseController)的数据查询语义,并修掉其坑。
|
||||
|
||||
- 分类:hardware | 类型:读
|
||||
- 数据来源:本地 ES 集群 `electricitymessage_yyyyMM` 月索引(索引名 = electricitymessage_ + startTime 月份 yyyyMM);
|
||||
**只走 esLocal 客户端**(索引只在本地集群,不走 esCloud);**无任何权限校验**(对齐老系统,用户拍板 2026-09-21)。
|
||||
- 入参:`snCode`(必填非空)、`startTime`/`endTime`(必填,严格 `yyyy-MM-dd HH:mm:ss`,
|
||||
**服务端强校验两者同一天,跨天直接拒绝**——老系统跨月范围静默丢数据、只按开始时间月份选索引;
|
||||
startTime 不得晚于 endTime)、`messageType`(可选 0-5,0 电量/1 信号/2 温度/3 充电循环次数/4 电压/5 电流,
|
||||
**不传=查全部类型**——老系统默认值 0 把"不传"歧义成"电量",此处为刻意的显式设计差异)、
|
||||
`page`/`rows`(默认 1/500,rows 上限 1000,超出截断为 1000 并返回 notice)。
|
||||
- 查询构造:`snCode` 用 **match_phrase**(老系统 matchQuery 经 standard analyzer 会把 AB-123 切成 ab/123 误配);
|
||||
messageType 传了才加 term;createTime 闭区间 range;createTime ASC 排序;from+size 分页(老系统 scroll 拉全量后内存抽样,本工具全量分页、不做抽样)。
|
||||
- 深分页防御:from+size 超 ES `max_result_window`(10000) 时截断为窗口内可用范围并在 notice 说明(整页越过窗口截断为最后可用窗口;部分越界截断本页条数)。
|
||||
- 返回 `snCode/index/total/page/rows/records`;record 含 `createTime`(yyyy-MM-dd HH:mm:ss,
|
||||
epoch 毫秒形态归一为 Asia/Shanghai 文本)、`messageType`、`messageTypeText`(服务端映射)、`value`(原始 Integer,不加单位标注)。
|
||||
- 与老系统的差异(详见 `electricity-message-mcp-tool-design.md`):ES 异常不静默为返回空,抛错 fail fast。
|
||||
|
||||
---
|
||||
|
||||
## 33. district.paper.percapita.query
|
||||
|
||||
查询站点人均用纸统计,覆盖老 `StatisticsReportController` 的五个接口。
|
||||
|
||||
@@ -805,7 +842,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 32. district.paper.device_average.query
|
||||
## 34. district.paper.device_average.query
|
||||
|
||||
查询站点设备日均用纸统计,覆盖老 `StatisticsReportController` 的五个硬件统计接口。
|
||||
|
||||
@@ -847,6 +884,8 @@
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 附录:在线查看工具清单
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# 电池故障列表 MCP 工具设计
|
||||
|
||||
需求:REQ-20260921-0006
|
||||
|
||||
## 目标
|
||||
|
||||
把旧系统 `/batteryFault/batteryFaultLogList` 封装为只读 MCP 工具 `battery.fault.list.query`,供 Agent 查询授权范围内的电池故障记录。旧接口注释掉了权限过滤,本工具补齐与出纸机列表一致的行级权限。
|
||||
|
||||
## 查询契约
|
||||
|
||||
- 数据源:m0 `battery_fault_log`,固定 `soft_delete=0`。
|
||||
- 筛选:站点、物业、代理商、SN 模糊匹配、设备类型、故障类型、恢复状态、故障日期范围。
|
||||
- 日期:`yyyy-MM-dd`;结束日期转为下一天 00:00 的排他上界,保证包含当天且不依赖毫秒精度。
|
||||
- 分页:默认 1/20,rows 上限 100,超限截断并返回 notice。
|
||||
- 排序:`fault_start_time DESC, id DESC`,避免同一故障时间下翻页漂移。
|
||||
- 查询次数:一次 count、一次分页 select;组织名称直接读取日志快照字段,无 N+1。
|
||||
|
||||
## 权限
|
||||
|
||||
`PermissionService.applyBatteryFaultLogRowFilter` 复用 `applyHardwareRowFilter` 的范围计算:总部不加过滤;代理范围为自己、直属下级、授权代管代理,并与授权代管物业取并集;销售非负责人仅按其可见物业过滤。显式传入的 agent/property/district 条件与权限条件叠加,越权时自然返回空列表。
|
||||
|
||||
tenant_service 服务身份按总部数据视角不加行过滤,但仍须通过 MCP 工具 scope 校验;缺失身份上下文继续 fail closed。
|
||||
|
||||
## 返回字段
|
||||
|
||||
返回设备、故障类型及状态(同时含 code/text)、原始峰值/标准值、故障发生/入列/恢复时间、安装位置,以及代理商/物业/站点 ID 和名称。时间统一格式化为 Asia/Shanghai `yyyy-MM-dd HH:mm:ss`,数值单位因旧代码未定义而不做推断或换算。
|
||||
|
||||
## 验证
|
||||
|
||||
- `BatteryFaultToolsTest`:参数校验、筛选 SQL、日期边界、分页截断、稳定排序、字段与文案映射、时区、Spring AI schema。
|
||||
- `PermissionServiceBatteryFaultRowFilterTest`:总部和代理+代管物业权限分支。
|
||||
- 全量 Maven 测试用于确认工具注册、权限和既有功能无回归。
|
||||
@@ -0,0 +1,78 @@
|
||||
# 设备电量消息记录 MCP 工具设计
|
||||
|
||||
需求:REQ-20260921(hardware.electricity.message.list.query)
|
||||
|
||||
## 背景
|
||||
|
||||
老系统 `BatteryAnalyseController.batteryAnalyseList`(xiaoqu-franchisee :63-150)消费 ES 索引
|
||||
`electricitymessage_yyyyMM`(按月分索引,索引名 = "electricitymessage_" + 开始时间月份 yyyyMM),
|
||||
文档结构 `ElectricAndSignAndNetworkLogEntity`:id、createTime、snCode、districtId、propertyId、
|
||||
agentId、messageType(0电量/1信号/2温度/3充电循环次数/4电压/5电流)、value(六类共用 Integer)。
|
||||
|
||||
老接口的语义与坑:
|
||||
|
||||
1. **只按开始时间的月份选索引**,跨月范围静默丢数据;前端限制只能查一天,后端没校验。
|
||||
2. **snCode 用 matchQuery**(BaseSearchSupportImpl2 type=0 非数值分支),经 standard analyzer
|
||||
分词后 AB-123 会被切成 ab/123,存在误配风险。
|
||||
3. **messageType 默认值 0**:不传参数时查询语义被默认值悄悄变成"只查电量",存在歧义。
|
||||
4. **接口完全无权限校验**(鉴权代码被注释掉)。
|
||||
5. scroll 拉全量后在内存里等间隔抽样(count>300 时 i%limit>0 continue),导出接口同样无分页。
|
||||
6. 查询异常被 catch 吞掉,返回空结果。
|
||||
|
||||
## 设计决策(用户拍板)
|
||||
|
||||
| 决策 | 说明 |
|
||||
|---|---|
|
||||
| 不做任何权限校验 | 对齐老系统;访问控制由 MCP ak/scope 层兜底 |
|
||||
| 只使用 esLocal 客户端 | 索引只建在本地集群,不走 esCloud;Gateway 只注入 esLocal bean |
|
||||
| 全量分页返回 | 不做老系统的内存抽样;from+size 分页,一次请求一次查询(track_total_hits 取 total) |
|
||||
| snCode 用 match_phrase | 精确短语匹配,修掉 standard analyzer 分词误配坑 |
|
||||
| 单日强校验 | startTime/endTime 必须为同一天(yyyy-MM-dd 部分相等),跨天直接抛 IllegalArgumentException;startTime 不得晚于 endTime;时间格式严格 `yyyy-MM-dd HH:mm:ss`(ResolverStyle.STRICT) |
|
||||
| 不传 messageType = 查全部类型 | 不加 term 过滤;与老系统默认值 0 的歧义语义不同,是刻意的显式设计 |
|
||||
| ES 异常 fail fast | 不复制老系统 catch 吞异常返回空的语义,抛 IllegalStateException 由 GuardedToolCallback 收敛 |
|
||||
|
||||
## 参数契约
|
||||
|
||||
- `snCode`:String,必填非空,match_phrase 精确短语匹配。
|
||||
- `startTime` / `endTime`:String,必填,严格 `yyyy-MM-dd HH:mm:ss`;两者必须为同一天,startTime ≤ endTime。
|
||||
- `messageType`:Integer,可选,0-5(0电量/1信号/2温度/3充电循环次数/4电压/5电流),越界拒绝;不传=全部类型。
|
||||
- `page`:Integer,默认 1(<1 归一为 1)。
|
||||
- `rows`:Integer,默认 500、上限 1000;>1000 截断为 1000 并在 notice 提示(与项目现有工具惯例一致)。
|
||||
|
||||
索引名:`electricitymessage_` + startTime 月份(yyyyMM),与老系统取法一致但跨天已被拒绝,
|
||||
因此不存在跨月静默丢数据问题。
|
||||
|
||||
## 查询构造
|
||||
|
||||
- bool filter:match_phrase(snCode);messageType 传了才 term(messageType);range(createTime) gte/lte 闭区间。
|
||||
- 排序:createTime ASC(与老系统一致)。
|
||||
- track_total_hits=true,total 取精确值。
|
||||
- 深分页防御:from+size 不得超 ES `max_result_window`(10000)。from ≥ 10000 时截断为窗口内最后可用完整页;
|
||||
from < 10000 但 from+size > 10000 时截断本页条数;两种情况都在 notice 说明。
|
||||
|
||||
## 返回契约
|
||||
|
||||
```
|
||||
snCode / index / total / page / rows / notice? / records[]
|
||||
record: createTime / messageType / messageTypeText / value
|
||||
```
|
||||
|
||||
- `createTime`:yyyy-MM-dd HH:mm:ss;ES 文档若存 epoch 毫秒数则按 Asia/Shanghai 归一格式化。
|
||||
- `messageTypeText`:服务端映射 0电量/1信号/2温度/3充电循环次数/4电压/5电流;未知/空为 null。
|
||||
- `value`:原始 Integer 原样返回,不加单位标注(六类语义各自的单位老系统未定义,不做推断)。
|
||||
- `rows` 返回截断后的生效值;多条 notice 用";"拼接。
|
||||
|
||||
## 代码结构
|
||||
|
||||
- `es/ElectricityMessageEsGateway`:接口,月索引名作为参数传入,保持可 mock;内嵌 Result/Record。
|
||||
- `es/ElectricityMessageEsGatewayImpl`:只注入 `@Qualifier("esLocalClient")`;ES 异常抛 IllegalStateException。
|
||||
- `mcp/tool/ElectricityMessageTools`:`@XqTool(category="hardware", kind="read")` +
|
||||
`@Tool(name="hardware.electricity.message.list.query")`;参数校验、索引名构造、分页与窗口截断、notice 拼接、文案映射。
|
||||
|
||||
## 验证
|
||||
|
||||
- `ElectricityMessageToolsTest`(mock Gateway):跨天/倒置/非法格式时间、空 snCode、非法 messageType 拒绝且零 Gateway 交互;
|
||||
默认分页与 rows 截断 notice;from+size 透传;max_result_window 整页/部分截断 notice;索引月份;返回映射含 messageTypeText;Spring AI schema。
|
||||
- `ElectricityMessageEsGatewayImplTest`(mock ElasticsearchClient,捕获请求构造 lambda 断言 SearchRequest):
|
||||
match_phrase/term 可选/range 闭区间/createTime ASC 排序/from+size/track_total_hits;命中映射含 epoch 毫秒 createTime 归一;ES 异常包装。
|
||||
- 全量 Maven 测试确认无回归。
|
||||
@@ -22,6 +22,8 @@ import java.util.Arrays;
|
||||
* 双集群 ES 客户端:cloud=云集群、local=本地化集群(老系统 Dictionary.LocalEsDate 分界)。
|
||||
* 低阶 RestClient 也注册为 bean(destroyMethod=close 随容器关闭);
|
||||
* 高阶 ElasticsearchClient 包装同一 RestClient,关闭 client 即关闭底层连接。
|
||||
* 注意:生产两个集群均为 ES 7.7.1,高阶客户端(7.17)的 vendor Content-Type 与
|
||||
* X-Elastic-Product 校验与 7.7.1 不兼容(406),业务查询一律注入低阶 RestClient bean。
|
||||
*/
|
||||
@Configuration
|
||||
public class EsClientConfig {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
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.battery_fault_log)。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@ToString
|
||||
@TableName("battery_fault_log")
|
||||
public class BatteryFaultLog implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long createId;
|
||||
private Date createTime;
|
||||
private Long modifyId;
|
||||
private Date modifyTime;
|
||||
private Integer softDelete;
|
||||
private String snCode;
|
||||
private Integer deviceType;
|
||||
private Integer faultType;
|
||||
private Integer faultState;
|
||||
private Date faultStartTime;
|
||||
private Date faultAddTime;
|
||||
private Date faultRecoveryTime;
|
||||
private String installPosition;
|
||||
private Long districtId;
|
||||
private String districtName;
|
||||
private Long propertyId;
|
||||
private String propertyName;
|
||||
private Long agentId;
|
||||
private String agentName;
|
||||
private Integer peakValue;
|
||||
private Integer standardValue;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package me.iiv.agent.es;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* electricitymessage_yyyyMM(设备电量/信号/温度等消息)ES 查询网关。
|
||||
* 只走本地 ES 集群(索引只建在本地集群),索引名由调用方按月构造传入,
|
||||
* 本接口只做单次查询,便于单测 mock。
|
||||
*/
|
||||
public interface ElectricityMessageEsGateway {
|
||||
|
||||
/**
|
||||
* 分页查询单台设备的电量/信号/温度/充电循环次数/电压/电流消息记录。
|
||||
*
|
||||
* @param index 月索引名,如 electricitymessage_202609(由调用方按 startTime 月份构造)
|
||||
* @param snCode 设备 SN,match_phrase 精确短语匹配
|
||||
* @param messageType 消息类型 0-5,null=不过滤(查全部类型)
|
||||
* @param startTime createTime 下界(含),yyyy-MM-dd HH:mm:ss
|
||||
* @param endTime createTime 上界(含),yyyy-MM-dd HH:mm:ss
|
||||
* @param from from+size 分页起始偏移
|
||||
* @param size 每页条数
|
||||
* @return 命中总数(track_total_hits)与当页记录,按 createTime ASC
|
||||
*/
|
||||
Result search(String index, String snCode, Integer messageType,
|
||||
String startTime, String endTime, int from, int size);
|
||||
|
||||
/** 单次查询结果。 */
|
||||
class Result {
|
||||
private final long total;
|
||||
private final List<Record> records;
|
||||
|
||||
public Result(long total, List<Record> records) {
|
||||
this.total = total;
|
||||
this.records = records;
|
||||
}
|
||||
|
||||
public long getTotal() {
|
||||
return total;
|
||||
}
|
||||
|
||||
public List<Record> getRecords() {
|
||||
return records;
|
||||
}
|
||||
}
|
||||
|
||||
/** 单条消息记录(createTime 已统一为 yyyy-MM-dd HH:mm:ss 文本,未标注单位语义)。 */
|
||||
class Record {
|
||||
private final String createTime;
|
||||
private final Integer messageType;
|
||||
private final Integer value;
|
||||
|
||||
public Record(String createTime, Integer messageType, Integer value) {
|
||||
this.createTime = createTime;
|
||||
this.messageType = messageType;
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public String getCreateTime() {
|
||||
return createTime;
|
||||
}
|
||||
|
||||
public Integer getMessageType() {
|
||||
return messageType;
|
||||
}
|
||||
|
||||
public Integer getValue() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* electricitymessage_yyyyMM ES 查询实现。
|
||||
* 降级原因:生产 ES 集群为 7.7.1,elasticsearch-java 7.17 高阶客户端(ElasticsearchClient)
|
||||
* 请求自带 vendor Content-Type(application/vnd.elasticsearch+json; compatible-with=7)被
|
||||
* 7.7.1 以 406 Not Acceptable 拒绝,且 7.17 客户端校验响应 X-Elastic-Product 头(7.14 才引入),
|
||||
* 7.7.1 没有,二次兼容也过不了;故改用低阶 RestClient(只发普通 application/json、无产品头校验)。
|
||||
* 只注入 esLocalRestClient(索引只在本地集群,不走 esCloud)。
|
||||
* 查询口径对齐老 BatteryAnalyseController.batteryAnalyseList 但修掉其坑:
|
||||
* snCode 用 match_phrase(老代码 matchQuery 经 standard analyzer 会把 AB-123 切成 ab/123 误配)、
|
||||
* messageType 不传不加 term(老代码默认值 0 会把"不传"歧义成"电量")、createTime 闭区间 range、
|
||||
* createTime ASC 排序、from+size 分页(老代码 scroll 拉全量后内存抽样)。
|
||||
* ES 异常不静默,抛 IllegalStateException(与老系统 catch 吞异常返回空不同,见设计文档)。
|
||||
*/
|
||||
@Component
|
||||
public class ElectricityMessageEsGatewayImpl implements ElectricityMessageEsGateway {
|
||||
|
||||
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 localRestClient;
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
public ElectricityMessageEsGatewayImpl(@Qualifier("esLocalRestClient") RestClient localRestClient) {
|
||||
this.localRestClient = localRestClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result search(String index, String snCode, Integer messageType,
|
||||
String startTime, String endTime, int from, int size) {
|
||||
ArrayNode filters = mapper.createArrayNode();
|
||||
// 老代码 snCode 字符串走 matchQuery(BaseSearchSupportImpl2 type=0 非数值分支),
|
||||
// standard analyzer 会把 AB-123 切成 ab/123 造成误配;改用 match_phrase 精确短语
|
||||
filters.addObject().putObject("match_phrase").putObject("snCode").put("query", snCode);
|
||||
if (messageType != null) {
|
||||
filters.addObject().putObject("term").putObject("messageType").put("value", messageType);
|
||||
}
|
||||
// createTime 在索引里是 date 类型(format strict_date_optional_time||epoch_millis),
|
||||
// 老系统经 DateUtils.parseDate 转 Date 后由 transport 序列化为 epoch 毫秒——
|
||||
// 直接把 yyyy-MM-dd HH:mm:ss 字符串发过去会被 400 拒绝(format 不接受空格分隔)
|
||||
ObjectNode range = filters.addObject().putObject("range").putObject("createTime");
|
||||
range.put("gte", toEpochMillis(startTime)).put("lte", toEpochMillis(endTime));
|
||||
|
||||
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("createTime").put("order", "asc");
|
||||
try {
|
||||
Request req = new Request("POST", "/" + index + "/_search");
|
||||
// setJsonEntity 自动带 Content-Type: application/json(无 vendor 头)
|
||||
req.setJsonEntity(mapper.writeValueAsString(body));
|
||||
Response resp = localRestClient.performRequest(req);
|
||||
JsonNode root = mapper.readTree(resp.getEntity().getContent());
|
||||
List<Record> records = new ArrayList<>();
|
||||
for (JsonNode hit : root.at("/hits/hits")) {
|
||||
JsonNode source = hit.get("_source");
|
||||
if (source == null) {
|
||||
continue;
|
||||
}
|
||||
records.add(new Record(createTimeText(source.get("createTime")),
|
||||
integerValue(source.get("messageType")),
|
||||
integerValue(source.get("value"))));
|
||||
}
|
||||
return new Result(root.at("/hits/total/value").asLong(), records);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
throw new IllegalStateException("ES 查询电量消息记录失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private long toEpochMillis(String timeText) {
|
||||
return TIME_FORMATTER.parse(timeText, Instant::from).toEpochMilli();
|
||||
}
|
||||
|
||||
/**
|
||||
* createTime 在 _source 里是 ISO 文本(如 2026-09-21T10:00:00.000+08:00)或 epoch 毫秒数;
|
||||
* 两种形态都归一为 Asia/Shanghai yyyy-MM-dd HH:mm:ss 文本输出(对齐老系统 Date 格式化口径)。
|
||||
*/
|
||||
private static String createTimeText(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;
|
||||
}
|
||||
|
||||
/**
|
||||
* value 在 _source 里可能是数字也可能是字符串(索引实际为 "28"/"100" 文本),
|
||||
* 统一解析为 Integer;非数值文本返回 null。
|
||||
*/
|
||||
private static Integer integerValue(JsonNode value) {
|
||||
if (value == null || value.isNull()) {
|
||||
return null;
|
||||
}
|
||||
if (value.isNumber()) {
|
||||
return value.intValue();
|
||||
}
|
||||
if (value.isTextual()) {
|
||||
try {
|
||||
return Integer.valueOf(value.asText().trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,24 @@
|
||||
package me.iiv.agent.es;
|
||||
|
||||
import co.elastic.clients.elasticsearch.ElasticsearchClient;
|
||||
import co.elastic.clients.elasticsearch._types.FieldValue;
|
||||
import co.elastic.clients.elasticsearch._types.query_dsl.Query;
|
||||
import co.elastic.clients.json.JsonData;
|
||||
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 org.springframework.util.StringUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* paperuselog ES 查询实现(elasticsearch-java 7.17 强类型 API)。
|
||||
* paperuselog ES 查询实现。
|
||||
* 降级原因:生产 ES 集群为 7.7.1,elasticsearch-java 7.17 高阶客户端(ElasticsearchClient)
|
||||
* 请求自带 vendor Content-Type(application/vnd.elasticsearch+json; compatible-with=7)被
|
||||
* 7.7.1 以 406 Not Acceptable 拒绝,且 7.17 客户端校验响应 X-Elastic-Product 头(7.14 才引入),
|
||||
* 7.7.1 没有,二次兼容也过不了;故改用低阶 RestClient(只发普通 application/json、无产品头校验)。
|
||||
* 查询口径对齐老 MonthlyStatisticsController.getMonthlyStatisticsMap:
|
||||
* term districtId/gender、range createTime [gte,lte](含边界)、deviceType 用 match(老代码字符串参数走 match),
|
||||
* positionMode 厕位=positionsId>0、厕墙=positionsId=0。
|
||||
@@ -25,31 +30,40 @@ public class PaperUseLogEsGatewayImpl implements PaperUseLogEsGateway {
|
||||
/** rfid terms 聚合去重上限,超出部分不计入 */
|
||||
private static final int RFID_TERMS_SIZE = 10000;
|
||||
|
||||
private final ElasticsearchClient cloudClient;
|
||||
private final ElasticsearchClient localClient;
|
||||
private final RestClient cloudRestClient;
|
||||
private final RestClient localRestClient;
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
public PaperUseLogEsGatewayImpl(@Qualifier("esCloudClient") ElasticsearchClient cloudClient,
|
||||
@Qualifier("esLocalClient") ElasticsearchClient localClient) {
|
||||
this.cloudClient = cloudClient;
|
||||
this.localClient = localClient;
|
||||
public PaperUseLogEsGatewayImpl(@Qualifier("esCloudRestClient") RestClient cloudRestClient,
|
||||
@Qualifier("esLocalRestClient") RestClient localRestClient) {
|
||||
this.cloudRestClient = cloudRestClient;
|
||||
this.localRestClient = localRestClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long distinctRfidCount(String index, boolean localCluster, Long districtId, Integer gender,
|
||||
Long fromMillis, Long toMillis, Integer deviceType, String positionMode) {
|
||||
List<Query> filters = baseFilters(districtId, gender, fromMillis, toMillis, deviceType, positionMode);
|
||||
ArrayNode filters = baseFilters(districtId, gender, fromMillis, toMillis, deviceType, positionMode);
|
||||
// 上纸(2)+撤纸(4) 合并后 rfid 去重(老 :302-342)
|
||||
filters.add(Query.of(q -> q.terms(t -> t.field("state")
|
||||
.terms(tt -> tt.value(List.of(FieldValue.of(2), FieldValue.of(4)))))));
|
||||
// 正确结构是 {"terms":{"state":[2,4]}};写成 {"terms":{"state":{"terms":[...]}}} 会被 ES 以 400 拒绝(2026-09-22 生产实测)
|
||||
ArrayNode stateTerms = filters.addObject().putObject("terms").putArray("state");
|
||||
stateTerms.add(2).add(4);
|
||||
try {
|
||||
return client(localCluster).search(s -> s
|
||||
.index(index)
|
||||
.size(0)
|
||||
.trackTotalHits(t -> t.enabled(true))
|
||||
.query(q -> q.bool(b -> b.filter(filters)))
|
||||
.aggregations("rfids", a -> a.terms(t -> t.field("rfid").size(RFID_TERMS_SIZE))),
|
||||
Void.class)
|
||||
.aggregations().get("rfids").sterms().buckets().array().size();
|
||||
ObjectNode body = mapper.createObjectNode();
|
||||
body.put("size", 0);
|
||||
body.put("track_total_hits", false);
|
||||
ObjectNode bool = body.putObject("query").putObject("bool");
|
||||
bool.set("filter", filters);
|
||||
// rfid 是 text 类型(mapping 含 ignore_above=256 的 keyword 子字段),terms 聚合必须用 rfid.keyword,
|
||||
// 直接聚合 text 字段报 "Text fields are not optimised for ... aggregations"(400,生产实测)
|
||||
body.putObject("aggs").putObject("rfids").putObject("terms")
|
||||
.put("field", "rfid.keyword").put("size", RFID_TERMS_SIZE);
|
||||
|
||||
Request req = new Request("POST", "/" + index + "/_search");
|
||||
req.setJsonEntity(mapper.writeValueAsString(body));
|
||||
Response resp = client(localCluster).performRequest(req);
|
||||
JsonNode root = mapper.readTree(resp.getEntity().getContent());
|
||||
return root.at("/aggregations/rfids/buckets").size();
|
||||
} catch (IOException | RuntimeException e) {
|
||||
throw new IllegalStateException("ES 查询换纸 rfid 去重失败: " + e.getMessage(), e);
|
||||
}
|
||||
@@ -58,40 +72,47 @@ public class PaperUseLogEsGatewayImpl implements PaperUseLogEsGateway {
|
||||
@Override
|
||||
public long countState2(String index, boolean localCluster, Long districtId, Integer gender,
|
||||
Long fromMillis, Long toMillis, Integer deviceType, String positionMode) {
|
||||
List<Query> filters = baseFilters(districtId, gender, fromMillis, toMillis, deviceType, positionMode);
|
||||
filters.add(Query.of(q -> q.term(t -> t.field("state").value(FieldValue.of(2)))));
|
||||
ArrayNode filters = baseFilters(districtId, gender, fromMillis, toMillis, deviceType, positionMode);
|
||||
filters.addObject().putObject("term").putObject("state").put("value", 2);
|
||||
try {
|
||||
return client(localCluster).count(c -> c.index(index)
|
||||
.query(q -> q.bool(b -> b.filter(filters)))).count();
|
||||
ObjectNode body = mapper.createObjectNode();
|
||||
ObjectNode bool = body.putObject("query").putObject("bool");
|
||||
bool.set("filter", filters);
|
||||
|
||||
Request req = new Request("POST", "/" + index + "/_count");
|
||||
req.setJsonEntity(mapper.writeValueAsString(body));
|
||||
Response resp = client(localCluster).performRequest(req);
|
||||
JsonNode root = mapper.readTree(resp.getEntity().getContent());
|
||||
return root.get("count").asLong();
|
||||
} catch (IOException | RuntimeException e) {
|
||||
throw new IllegalStateException("ES 查询换纸上纸记录数失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private List<Query> baseFilters(Long districtId, Integer gender, Long fromMillis, Long toMillis,
|
||||
Integer deviceType, String positionMode) {
|
||||
List<Query> filters = new ArrayList<>();
|
||||
filters.add(Query.of(q -> q.term(t -> t.field("districtId").value(FieldValue.of(districtId)))));
|
||||
filters.add(Query.of(q -> q.term(t -> t.field("gender").value(FieldValue.of(gender)))));
|
||||
private ArrayNode baseFilters(Long districtId, Integer gender, Long fromMillis, Long toMillis,
|
||||
Integer deviceType, String positionMode) {
|
||||
ArrayNode filters = mapper.createArrayNode();
|
||||
filters.addObject().putObject("term").putObject("districtId").put("value", districtId);
|
||||
filters.addObject().putObject("term").putObject("gender").put("value", gender);
|
||||
// createTime 为 epoch 毫秒;含边界(老 rangeQuery from/to 均 inclusive)
|
||||
filters.add(Query.of(q -> q.range(r -> r.field("createTime")
|
||||
.gte(JsonData.of(fromMillis)).lte(JsonData.of(toMillis)))));
|
||||
ObjectNode range = filters.addObject().putObject("range").putObject("createTime");
|
||||
range.put("gte", fromMillis).put("lte", toMillis);
|
||||
if (deviceType != null) {
|
||||
// 老代码 deviceType 为字符串参数走 matchQuery(BaseSearchSupportImpl2 type=0 非数值分支)
|
||||
filters.add(Query.of(q -> q.match(m -> m.field("deviceType")
|
||||
.query(FieldValue.of(String.valueOf(deviceType))))));
|
||||
filters.addObject().putObject("match").putObject("deviceType")
|
||||
.put("query", String.valueOf(deviceType));
|
||||
}
|
||||
if (StringUtils.hasText(positionMode)) {
|
||||
if ("position".equals(positionMode)) {
|
||||
filters.add(Query.of(q -> q.range(r -> r.field("positionsId").gt(JsonData.of(0)))));
|
||||
filters.addObject().putObject("range").putObject("positionsId").put("gt", 0);
|
||||
} else {
|
||||
filters.add(Query.of(q -> q.term(t -> t.field("positionsId").value(FieldValue.of(0)))));
|
||||
filters.addObject().putObject("term").putObject("positionsId").put("value", 0);
|
||||
}
|
||||
}
|
||||
return filters;
|
||||
}
|
||||
|
||||
private ElasticsearchClient client(boolean localCluster) {
|
||||
return localCluster ? localClient : cloudClient;
|
||||
private RestClient client(boolean localCluster) {
|
||||
return localCluster ? localRestClient : cloudRestClient;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package me.iiv.agent.mapper.m0;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import me.iiv.agent.entity.m0.BatteryFaultLog;
|
||||
|
||||
public interface BatteryFaultLogMapper extends BaseMapper<BatteryFaultLog> {
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package me.iiv.agent.mcp.tool;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import me.iiv.agent.entity.m0.BatteryFaultLog;
|
||||
import me.iiv.agent.entity.m0.User;
|
||||
import me.iiv.agent.mapper.m0.BatteryFaultLogMapper;
|
||||
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 org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** 电池故障列表查询工具,对应老系统 /batteryFault/batteryFaultLogList。 */
|
||||
@Component
|
||||
public class BatteryFaultTools implements ToolObject {
|
||||
|
||||
private static final int DEFAULT_ROWS = 20;
|
||||
private static final int MAX_ROWS = 100;
|
||||
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);
|
||||
|
||||
final BatteryFaultLogMapper mapper;
|
||||
final PermissionService permissionService;
|
||||
|
||||
public BatteryFaultTools(BatteryFaultLogMapper mapper, PermissionService permissionService) {
|
||||
this.mapper = mapper;
|
||||
this.permissionService = permissionService;
|
||||
}
|
||||
|
||||
@XqTool(category = "hardware", kind = "read")
|
||||
@Tool(name = "battery.fault.list.query", description = "分页查询当前身份可见的电池故障记录,对应老系统"
|
||||
+ " /batteryFault/batteryFaultLogList。支持站点、物业、代理商、设备 SN、设备类型、故障类型、"
|
||||
+ "恢复状态及故障日期范围筛选;page 默认 1,rows 默认 20、上限 100;"
|
||||
+ "固定按 fault_start_time DESC、id DESC 排序。")
|
||||
public Map<String, Object> batteryFaultListQuery(
|
||||
@ToolParam(required = false, description = "站点 id,正整数") Long districtId,
|
||||
@ToolParam(required = false, description = "物业 id,正整数") Long propertyId,
|
||||
@ToolParam(required = false, description = "代理商 id,正整数") Long agentId,
|
||||
@ToolParam(required = false, description = "设备 SN,模糊匹配") String snCode,
|
||||
@ToolParam(required = false, description = "设备类型:0=厕纸机/出纸机,1=擦手纸机") Integer deviceType,
|
||||
@ToolParam(required = false, description = "故障类型:0=温度异常,1=电压异常,2=电流异常,3=充电次数异常") Integer faultType,
|
||||
@ToolParam(required = false, description = "故障状态:0=未恢复,1=已恢复") Integer faultState,
|
||||
@ToolParam(required = false, description = "故障日期起始,yyyy-MM-dd,包含当天") String faultDateFrom,
|
||||
@ToolParam(required = false, description = "故障日期结束,yyyy-MM-dd,包含当天") String faultDateTo,
|
||||
@ToolParam(required = false, description = "页码,从 1 开始,默认 1") Integer page,
|
||||
@ToolParam(required = false, description = "每页条数,默认 20,上限 100") Integer rows) {
|
||||
validatePositive(districtId, "districtId");
|
||||
validatePositive(propertyId, "propertyId");
|
||||
validatePositive(agentId, "agentId");
|
||||
validateEnum(deviceType, "deviceType", 0, 1);
|
||||
validateEnum(faultType, "faultType", 0, 1, 2, 3);
|
||||
validateEnum(faultState, "faultState", 0, 1);
|
||||
LocalDate from = parseDate(faultDateFrom, "faultDateFrom");
|
||||
LocalDate to = parseDate(faultDateTo, "faultDateTo");
|
||||
if (from != null && to != null && from.isAfter(to)) {
|
||||
throw new IllegalArgumentException("faultDateFrom 不能晚于 faultDateTo");
|
||||
}
|
||||
|
||||
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<BatteryFaultLog> wrapper = new LambdaQueryWrapper<BatteryFaultLog>()
|
||||
.eq(BatteryFaultLog::getSoftDelete, 0)
|
||||
.eq(districtId != null, BatteryFaultLog::getDistrictId, districtId)
|
||||
.eq(propertyId != null, BatteryFaultLog::getPropertyId, propertyId)
|
||||
.eq(agentId != null, BatteryFaultLog::getAgentId, agentId)
|
||||
.eq(deviceType != null, BatteryFaultLog::getDeviceType, deviceType)
|
||||
.eq(faultType != null, BatteryFaultLog::getFaultType, faultType)
|
||||
.eq(faultState != null, BatteryFaultLog::getFaultState, faultState);
|
||||
if (StringUtils.hasText(snCode)) {
|
||||
wrapper.like(BatteryFaultLog::getSnCode, snCode.trim());
|
||||
}
|
||||
if (from != null) {
|
||||
wrapper.ge(BatteryFaultLog::getFaultStartTime,
|
||||
Date.from(from.atStartOfDay(BUSINESS_ZONE).toInstant()));
|
||||
}
|
||||
if (to != null) {
|
||||
wrapper.lt(BatteryFaultLog::getFaultStartTime,
|
||||
Date.from(to.plusDays(1).atStartOfDay(BUSINESS_ZONE).toInstant()));
|
||||
}
|
||||
|
||||
User user = AuthContextHolder.get().getUser();
|
||||
permissionService.applyBatteryFaultLogRowFilter(wrapper, user);
|
||||
Long total = mapper.selectCount(wrapper);
|
||||
wrapper.orderByDesc(BatteryFaultLog::getFaultStartTime)
|
||||
.orderByDesc(BatteryFaultLog::getId)
|
||||
.last("LIMIT " + (long) (pageNo - 1) * pageSize + "," + pageSize);
|
||||
List<Map<String, Object>> records = mapper.selectList(wrapper).stream()
|
||||
.map(this::record).collect(Collectors.toList());
|
||||
|
||||
Map<String, Object> 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;
|
||||
}
|
||||
|
||||
private Map<String, Object> record(BatteryFaultLog log) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("id", log.getId());
|
||||
row.put("snCode", log.getSnCode());
|
||||
row.put("deviceType", log.getDeviceType());
|
||||
row.put("deviceTypeText", text(log.getDeviceType(), "厕纸机", "擦手纸机"));
|
||||
row.put("faultType", log.getFaultType());
|
||||
row.put("faultTypeText", text(log.getFaultType(), "温度异常", "电压异常", "电流异常", "充电次数异常"));
|
||||
row.put("faultState", log.getFaultState());
|
||||
row.put("faultStateText", text(log.getFaultState(), "未恢复", "已恢复"));
|
||||
row.put("peakValue", log.getPeakValue());
|
||||
row.put("standardValue", log.getStandardValue());
|
||||
row.put("faultStartTime", format(log.getFaultStartTime()));
|
||||
row.put("faultAddTime", format(log.getFaultAddTime()));
|
||||
row.put("faultRecoveryTime", format(log.getFaultRecoveryTime()));
|
||||
row.put("installPosition", log.getInstallPosition());
|
||||
row.put("agentId", log.getAgentId());
|
||||
row.put("agentName", log.getAgentName());
|
||||
row.put("propertyId", log.getPropertyId());
|
||||
row.put("propertyName", log.getPropertyName());
|
||||
row.put("districtId", log.getDistrictId());
|
||||
row.put("districtName", log.getDistrictName());
|
||||
return row;
|
||||
}
|
||||
|
||||
private static LocalDate parseDate(String value, String name) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(value, DateTimeFormatter.ISO_LOCAL_DATE);
|
||||
} catch (DateTimeParseException ex) {
|
||||
throw new IllegalArgumentException(name + " 必须为 yyyy-MM-dd 格式");
|
||||
}
|
||||
}
|
||||
|
||||
private static String format(Date value) {
|
||||
return value == null ? null : TIME_FORMATTER.format(value.toInstant());
|
||||
}
|
||||
|
||||
private static String text(Integer value, String... labels) {
|
||||
return value != null && value >= 0 && value < labels.length ? labels[value] : null;
|
||||
}
|
||||
|
||||
private static void validatePositive(Long value, String name) {
|
||||
if (value != null && value <= 0) {
|
||||
throw new IllegalArgumentException(name + " 必须为正整数");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateEnum(Integer value, String name, Integer... allowed) {
|
||||
if (value != null && !Arrays.asList(allowed).contains(value)) {
|
||||
throw new IllegalArgumentException(name + " 不在允许范围内");
|
||||
}
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package me.iiv.agent.mcp.tool;
|
||||
|
||||
import me.iiv.agent.es.ElectricityMessageEsGateway;
|
||||
import me.iiv.agent.mcp.ToolObject;
|
||||
import me.iiv.agent.mcp.XqTool;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeFormatterBuilder;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.time.format.ResolverStyle;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 设备电量/信号/温度等消息记录查询工具(ES electricitymessage_yyyyMM 月索引),
|
||||
* 对应老系统 /batteryAnalyse/batteryAnalyseList 的数据查询语义但修掉其坑。
|
||||
* 按用户拍板:不做任何权限校验(对齐老系统)、只走 esLocal 本地集群、全量分页返回(不做抽样)。
|
||||
*/
|
||||
@Component
|
||||
public class ElectricityMessageTools implements ToolObject {
|
||||
|
||||
private static final int DEFAULT_ROWS = 500;
|
||||
private static final int MAX_ROWS = 1000;
|
||||
/** ES from+size 深分页上限 max_result_window */
|
||||
private static final int MAX_RESULT_WINDOW = 10000;
|
||||
private static final String INDEX_PREFIX = "electricitymessage_";
|
||||
private static final DateTimeFormatter STRICT_TIME = new DateTimeFormatterBuilder()
|
||||
.appendPattern("uuuu-MM-dd HH:mm:ss").toFormatter().withResolverStyle(ResolverStyle.STRICT);
|
||||
private static final DateTimeFormatter INDEX_MONTH = DateTimeFormatter.ofPattern("yyyyMM");
|
||||
|
||||
final ElectricityMessageEsGateway gateway;
|
||||
|
||||
public ElectricityMessageTools(ElectricityMessageEsGateway gateway) {
|
||||
this.gateway = gateway;
|
||||
}
|
||||
|
||||
@XqTool(category = "hardware", kind = "read")
|
||||
@Tool(name = "hardware.electricity.message.list.query", description = "分页查询设备电量/信号/温度/充电循环次数/电压/电流消息记录"
|
||||
+ "(ES electricitymessage_yyyyMM 月索引,只走本地集群,无权限过滤,对齐老系统"
|
||||
+ " /batteryAnalyse/batteryAnalyseList)。snCode 必填;startTime/endTime 必填且必须为同一天"
|
||||
+ "(只支持单日查询,跨天直接拒绝);messageType 可选,0=电量、1=信号、2=温度、3=充电循环次数、"
|
||||
+ "4=电压、5=电流,不传=查全部类型;按 createTime ASC 排序,page 默认 1、rows 默认 500、上限 1000。")
|
||||
public Map<String, Object> hardwareElectricityMessageListQuery(
|
||||
@ToolParam(description = "设备 SN,必填,非空,match_phrase 精确短语匹配") String snCode,
|
||||
@ToolParam(description = "开始时间,yyyy-MM-dd HH:mm:ss,必填,与 endTime 必须为同一天") String startTime,
|
||||
@ToolParam(description = "结束时间,yyyy-MM-dd HH:mm:ss,必填,不早于 startTime") String endTime,
|
||||
@ToolParam(required = false, description = "消息类型:0=电量,1=信号,2=温度,3=充电循环次数,4=电压,5=电流;不传=查全部类型") Integer messageType,
|
||||
@ToolParam(required = false, description = "页码,从 1 开始,默认 1") Integer page,
|
||||
@ToolParam(required = false, description = "每页条数,默认 500,上限 1000,超出截断为 1000") Integer rows) {
|
||||
if (!StringUtils.hasText(snCode)) {
|
||||
throw new IllegalArgumentException("snCode 必填,非空");
|
||||
}
|
||||
LocalDateTime start = parseTime(startTime, "startTime");
|
||||
LocalDateTime end = parseTime(endTime, "endTime");
|
||||
// 老系统只按开始时间的月份选索引,跨月范围静默丢数据;这里显式拒绝跨天查询
|
||||
if (!start.toLocalDate().equals(end.toLocalDate())) {
|
||||
throw new IllegalArgumentException("只支持单日查询:startTime 与 endTime 必须为同一天(yyyy-MM-dd)");
|
||||
}
|
||||
if (start.isAfter(end)) {
|
||||
throw new IllegalArgumentException("startTime 不能晚于 endTime");
|
||||
}
|
||||
if (messageType != null && (messageType < 0 || messageType > 5)) {
|
||||
throw new IllegalArgumentException("messageType 仅支持 0-5");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
String index = INDEX_PREFIX + INDEX_MONTH.format(start);
|
||||
ElectricityMessageEsGateway.Result search = gateway.search(index, snCode.trim(), messageType,
|
||||
startTime.trim(), endTime.trim(), (int) fromLong, pageSize);
|
||||
|
||||
List<Map<String, Object>> records = search.getRecords().stream()
|
||||
.map(this::record)
|
||||
.collect(Collectors.toList());
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("snCode", snCode.trim());
|
||||
result.put("index", 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;
|
||||
}
|
||||
|
||||
private Map<String, Object> record(ElectricityMessageEsGateway.Record message) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("createTime", message.getCreateTime());
|
||||
row.put("messageType", message.getMessageType());
|
||||
row.put("messageTypeText", messageTypeText(message.getMessageType()));
|
||||
row.put("value", message.getValue());
|
||||
return row;
|
||||
}
|
||||
|
||||
private static String notice(boolean truncatedRows, String windowNotice) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
if (truncatedRows) {
|
||||
builder.append("rows 超过上限 1000,已截断为 1000");
|
||||
}
|
||||
if (windowNotice != null) {
|
||||
if (builder.length() > 0) {
|
||||
builder.append(";");
|
||||
}
|
||||
builder.append(windowNotice);
|
||||
}
|
||||
return builder.length() == 0 ? null : builder.toString();
|
||||
}
|
||||
|
||||
private static String messageTypeText(Integer messageType) {
|
||||
if (messageType == null || messageType < 0 || messageType > 5) {
|
||||
return null;
|
||||
}
|
||||
String[] labels = {"电量", "信号", "温度", "充电循环次数", "电压", "电流"};
|
||||
return labels[messageType];
|
||||
}
|
||||
|
||||
private static LocalDateTime parseTime(String value, String name) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
throw new IllegalArgumentException(name + " 必填,格式 yyyy-MM-dd HH:mm:ss");
|
||||
}
|
||||
try {
|
||||
return LocalDateTime.parse(value.trim(), STRICT_TIME);
|
||||
} catch (DateTimeParseException ex) {
|
||||
throw new IllegalArgumentException(name + " 格式非法,要求 yyyy-MM-dd HH:mm:ss: " + value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import me.iiv.agent.entity.m0.Agent;
|
||||
import me.iiv.agent.entity.m0.AgentManageDistrictAuth;
|
||||
import me.iiv.agent.entity.m0.Authority;
|
||||
import me.iiv.agent.entity.m0.BatteryFaultLog;
|
||||
import me.iiv.agent.entity.m0.DistrictDistrictStrategy;
|
||||
import me.iiv.agent.entity.m0.HardwareView;
|
||||
import me.iiv.agent.entity.m0.Properyt;
|
||||
@@ -833,6 +834,24 @@ public class PermissionService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 电池故障日志行权限:与出纸机列表复用同一代理/物业可见范围。 */
|
||||
public void applyBatteryFaultLogRowFilter(LambdaQueryWrapper<BatteryFaultLog> wrapper, User user) {
|
||||
RowScope scope = resolveRowScope(user);
|
||||
if (scope.headquarters) {
|
||||
return;
|
||||
}
|
||||
if (scope.salesPropertyIds != null) {
|
||||
wrapper.in(BatteryFaultLog::getPropertyId, scope.salesPropertyIds);
|
||||
return;
|
||||
}
|
||||
if (!scope.managedPropertyIds.isEmpty()) {
|
||||
wrapper.and(w -> w.in(BatteryFaultLog::getAgentId, scope.ids)
|
||||
.or().in(BatteryFaultLog::getPropertyId, scope.managedPropertyIds));
|
||||
} else {
|
||||
wrapper.in(BatteryFaultLog::getAgentId, scope.ids);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 合同(m2 contract_new)行权限:与站点/出纸机同一 getByIsAgent 变体
|
||||
* (propertyFlag=true),agent 维度落到 contract_agent_id(合同所属公司,
|
||||
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
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 ElectricityMessageEsGatewayImplTest {
|
||||
|
||||
/** 2026-09-21 14:00:00 Asia/Shanghai 的 epoch 毫秒(= 2026-09-21T06:00:00Z) */
|
||||
private static final long EPOCH_MILLIS = 1789970400000L;
|
||||
|
||||
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\":\"electricitymessage_202609\","
|
||||
+ "\"_source\":{\"createTime\":\"2026-09-21T10:00:00.000+08:00\",\"messageType\":0,\"value\":\"88\"}},"
|
||||
+ "{\"_index\":\"electricitymessage_202609\","
|
||||
+ "\"_source\":{\"createTime\":" + EPOCH_MILLIS + ",\"messageType\":4,\"value\":3700}}"
|
||||
+ "]}}";
|
||||
|
||||
@Mock
|
||||
RestClient localRestClient;
|
||||
|
||||
ElectricityMessageEsGatewayImpl gateway;
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
gateway = new ElectricityMessageEsGatewayImpl(localRestClient);
|
||||
}
|
||||
|
||||
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 responseJson, Integer messageType) throws Exception {
|
||||
Response esResponse = esResponse(responseJson);
|
||||
when(localRestClient.performRequest(any(Request.class))).thenReturn(esResponse);
|
||||
gateway.search("electricitymessage_202609", "AB-123", messageType,
|
||||
"2026-09-21 00:00:00", "2026-09-21 23:59:59", 0, 500);
|
||||
ArgumentCaptor<Request> captor = ArgumentCaptor.forClass(Request.class);
|
||||
verify(localRestClient).performRequest(captor.capture());
|
||||
Request captured = captor.getValue();
|
||||
assertEquals("POST", captured.getMethod());
|
||||
assertEquals("/electricitymessage_202609/_search", captured.getEndpoint());
|
||||
return mapper.readTree(EntityUtils.toString(captured.getEntity()));
|
||||
}
|
||||
|
||||
private static boolean anyFilter(JsonNode filters, java.util.function.Predicate<JsonNode> predicate) {
|
||||
for (JsonNode filter : filters) {
|
||||
if (predicate.test(filter)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildsMatchPhraseOptionalTermRangeSortAndPagination() throws Exception {
|
||||
JsonNode body = captureRequestBody(RESPONSE_JSON, 0);
|
||||
|
||||
assertEquals(0, body.get("from").asInt());
|
||||
assertEquals(500, body.get("size").asInt());
|
||||
assertTrue(body.get("track_total_hits").asBoolean());
|
||||
assertEquals("asc", body.get("sort").get(0).get("createTime").get("order").asText());
|
||||
|
||||
JsonNode filters = body.at("/query/bool/filter");
|
||||
// snCode 必须 match_phrase(老系统 matchQuery 分词误配的修复)
|
||||
assertTrue(anyFilter(filters, f -> f.has("match_phrase")
|
||||
&& f.get("match_phrase").get("snCode").get("query").asText().equals("AB-123")));
|
||||
// messageType 传了才加 term
|
||||
assertTrue(anyFilter(filters, f -> f.has("term")
|
||||
&& f.get("term").get("messageType").get("value").asInt() == 0));
|
||||
// createTime 闭区间 range,date 类型字段必须发 epoch 毫秒(空格分隔字符串会被 ES 400 拒绝)
|
||||
assertTrue(anyFilter(filters, f -> f.has("range")
|
||||
&& f.get("range").get("createTime").get("gte").asLong() == 1789920000000L
|
||||
&& f.get("range").get("createTime").get("lte").asLong() == 1790006399000L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void omitsMessageTypeTermWhenNull() throws Exception {
|
||||
JsonNode body = captureRequestBody(RESPONSE_JSON, null);
|
||||
for (JsonNode filter : body.at("/query/bool/filter")) {
|
||||
assertTrue(!filter.has("term") || filter.get("term").findValue("messageType") == null);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsHitsAndEpochMillisCreateTime() throws Exception {
|
||||
Response esResponse = esResponse(RESPONSE_JSON);
|
||||
when(localRestClient.performRequest(any(Request.class))).thenReturn(esResponse);
|
||||
|
||||
ElectricityMessageEsGateway.Result result = gateway.search("electricitymessage_202609",
|
||||
"AB-123", null, "2026-09-21 00:00:00", "2026-09-21 23:59:59", 0, 500);
|
||||
|
||||
assertEquals(2, result.getTotal());
|
||||
assertEquals(2, result.getRecords().size());
|
||||
ElectricityMessageEsGateway.Record first = result.getRecords().get(0);
|
||||
assertEquals("2026-09-21 10:00:00", first.getCreateTime());
|
||||
assertEquals(Integer.valueOf(0), first.getMessageType());
|
||||
assertEquals(Integer.valueOf(88), first.getValue());
|
||||
// epoch 毫秒形态归一为 Asia/Shanghai yyyy-MM-dd HH:mm:ss
|
||||
assertEquals("2026-09-21 14:00:00", result.getRecords().get(1).getCreateTime());
|
||||
assertEquals(Integer.valueOf(4), result.getRecords().get(1).getMessageType());
|
||||
assertEquals(Integer.valueOf(3700), result.getRecords().get(1).getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsNullAndMissingFields() throws Exception {
|
||||
String response = "{\"hits\":{\"total\":{\"value\":1,\"relation\":\"eq\"},"
|
||||
+ "\"hits\":[{\"_source\":{\"createTime\":null}}]}}";
|
||||
Response esResponse = esResponse(response);
|
||||
when(localRestClient.performRequest(any(Request.class))).thenReturn(esResponse);
|
||||
|
||||
ElectricityMessageEsGateway.Record record = gateway.search("electricitymessage_202609",
|
||||
"AB-123", null, "2026-09-21 00:00:00", "2026-09-21 23:59:59", 0, 500)
|
||||
.getRecords().get(0);
|
||||
|
||||
assertNull(record.getCreateTime());
|
||||
assertNull(record.getMessageType());
|
||||
assertNull(record.getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrapsEsFailureInIllegalState() throws Exception {
|
||||
when(localRestClient.performRequest(any(Request.class)))
|
||||
.thenThrow(new IOException("connection reset"));
|
||||
|
||||
IllegalStateException exception = assertThrows(IllegalStateException.class,
|
||||
() -> gateway.search("electricitymessage_202609", "AB-123", null,
|
||||
"2026-09-21 00:00:00", "2026-09-21 23:59:59", 0, 500));
|
||||
assertTrue(exception.getMessage().startsWith("ES 查询电量消息记录失败"));
|
||||
}
|
||||
}
|
||||
@@ -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.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 PaperUseLogEsGatewayImplTest {
|
||||
|
||||
@Mock
|
||||
RestClient cloudRestClient;
|
||||
|
||||
@Mock
|
||||
RestClient localRestClient;
|
||||
|
||||
PaperUseLogEsGatewayImpl gateway;
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
gateway = new PaperUseLogEsGatewayImpl(cloudRestClient, localRestClient);
|
||||
}
|
||||
|
||||
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 responseJson, boolean countOnly) throws Exception {
|
||||
Response response = esResponse(responseJson);
|
||||
when(localRestClient.performRequest(any(Request.class))).thenReturn(response);
|
||||
if (countOnly) {
|
||||
gateway.countState2("paperuselog", true, 2578L, 0,
|
||||
1789392000000L, 1789478399000L, null, null);
|
||||
} else {
|
||||
gateway.distinctRfidCount("paperuselog", true, 2578L, 0,
|
||||
1789392000000L, 1789478399000L, null, null);
|
||||
}
|
||||
ArgumentCaptor<Request> captor = ArgumentCaptor.forClass(Request.class);
|
||||
verify(localRestClient).performRequest(captor.capture());
|
||||
Request captured = captor.getValue();
|
||||
assertEquals("POST", captured.getMethod());
|
||||
assertEquals(countOnly ? "/paperuselog/_count" : "/paperuselog/_search", captured.getEndpoint());
|
||||
return mapper.readTree(EntityUtils.toString(captured.getEntity()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void distinctRfidBuildsFlatStateTermsArray() throws Exception {
|
||||
String response = "{\"aggregations\":{\"rfids\":{\"buckets\":[{\"key\":\"a\"},{\"key\":\"b\"}]}}}";
|
||||
JsonNode body = captureRequestBody(response, false);
|
||||
|
||||
assertEquals(0, body.get("size").asInt());
|
||||
assertTrue(!body.get("track_total_hits").asBoolean());
|
||||
// 回归:必须是扁平数组 {"terms":{"state":[2,4]}};
|
||||
// 嵌套 {"terms":{"state":{"terms":[...]}}} 会被 ES 7.7.1 以 400 "terms_lookup unknown field [terms]" 拒绝(2026-09-22 生产实测)
|
||||
JsonNode stateTerms = body.at("/query/bool/filter").findValue("state");
|
||||
assertTrue(stateTerms != null && stateTerms.isArray());
|
||||
assertEquals(2, stateTerms.size());
|
||||
assertEquals(2, stateTerms.get(0).asInt());
|
||||
assertEquals(4, stateTerms.get(1).asInt());
|
||||
// rfid 是 text 类型,terms 聚合必须走 rfid.keyword(直接聚合 text 报 400)
|
||||
assertEquals("rfid.keyword", body.at("/aggs/rfids/terms/field").asText());
|
||||
assertEquals(10000, body.at("/aggs/rfids/terms/size").asInt());
|
||||
// 基础过滤:districtId/gender term + createTime 闭区间 range(epoch 毫秒)
|
||||
assertTrue(body.at("/query/bool/filter").toString().contains("\"districtId\":{\"value\":2578}"));
|
||||
assertTrue(body.at("/query/bool/filter").toString().contains("\"gender\":{\"value\":0}"));
|
||||
assertEquals(1789392000000L, body.at("/query/bool/filter").findValue("gte").asLong());
|
||||
assertEquals(1789478399000L, body.at("/query/bool/filter").findValue("lte").asLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void distinctRfidMapsBucketCount() throws Exception {
|
||||
String response = "{\"aggregations\":{\"rfids\":{\"buckets\":[{\"key\":\"a\"},{\"key\":\"b\"},{\"key\":\"c\"}]}}}";
|
||||
Response esResponse = esResponse(response);
|
||||
when(localRestClient.performRequest(any(Request.class))).thenReturn(esResponse);
|
||||
|
||||
long count = gateway.distinctRfidCount("paperuselog", true, 2578L, 0,
|
||||
1789392000000L, 1789478399000L, null, null);
|
||||
|
||||
assertEquals(3L, count);
|
||||
}
|
||||
|
||||
@Test
|
||||
void countState2BuildsTermFilterAndMapsCount() throws Exception {
|
||||
JsonNode body = captureRequestBody("{\"count\":42}", true);
|
||||
|
||||
assertTrue(body.at("/query/bool/filter").toString().contains("\"state\":{\"value\":2}"));
|
||||
Response countResponse = esResponse("{\"count\":42}");
|
||||
when(localRestClient.performRequest(any(Request.class))).thenReturn(countResponse);
|
||||
long count = gateway.countState2("paperuselog", true, 2578L, 0,
|
||||
1789392000000L, 1789478399000L, null, null);
|
||||
assertEquals(42L, count);
|
||||
}
|
||||
|
||||
@Test
|
||||
void routesToCloudClientWhenNotLocalCluster() throws Exception {
|
||||
Response esResponse = esResponse("{\"aggregations\":{\"rfids\":{\"buckets\":[]}}}");
|
||||
when(cloudRestClient.performRequest(any(Request.class))).thenReturn(esResponse);
|
||||
|
||||
gateway.distinctRfidCount("paperuselog", false, 2578L, 0,
|
||||
1789392000000L, 1789478399000L, null, null);
|
||||
|
||||
verify(cloudRestClient).performRequest(any(Request.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrapsEsFailureInIllegalState() throws Exception {
|
||||
when(localRestClient.performRequest(any(Request.class)))
|
||||
.thenThrow(new IOException("connection reset"));
|
||||
|
||||
IllegalStateException exception = assertThrows(IllegalStateException.class,
|
||||
() -> gateway.distinctRfidCount("paperuselog", true, 2578L, 0,
|
||||
1789392000000L, 1789478399000L, null, null));
|
||||
assertTrue(exception.getMessage().startsWith("ES 查询换纸 rfid 去重失败"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
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.BatteryFaultLog;
|
||||
import me.iiv.agent.entity.m0.User;
|
||||
import me.iiv.agent.mapper.m0.BatteryFaultLogMapper;
|
||||
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 java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.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.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class BatteryFaultToolsTest {
|
||||
|
||||
@Mock
|
||||
BatteryFaultLogMapper mapper;
|
||||
@Mock
|
||||
PermissionService permissionService;
|
||||
|
||||
BatteryFaultTools tools;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
BatteryFaultLog.class);
|
||||
tools = new BatteryFaultTools(mapper, permissionService);
|
||||
User user = new User();
|
||||
user.setId(7L);
|
||||
AuthContextHolder.set(new AuthContext(null, user));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
AuthContextHolder.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void listBuildsFiltersMapsFieldsAndTruncatesRows() {
|
||||
BatteryFaultLog log = new BatteryFaultLog();
|
||||
log.setId(9L);
|
||||
log.setSnCode("BAT001");
|
||||
log.setDeviceType(0);
|
||||
log.setFaultType(3);
|
||||
log.setFaultState(1);
|
||||
log.setPeakValue(3500);
|
||||
log.setStandardValue(3700);
|
||||
log.setFaultStartTime(Date.from(Instant.parse("2026-09-21T02:15:00Z")));
|
||||
log.setFaultAddTime(Date.from(Instant.parse("2026-09-21T02:20:00Z")));
|
||||
log.setAgentId(1L);
|
||||
log.setAgentName("代理商");
|
||||
log.setPropertyId(2L);
|
||||
log.setPropertyName("物业");
|
||||
log.setDistrictId(3L);
|
||||
log.setDistrictName("站点");
|
||||
when(mapper.selectCount(any())).thenReturn(1L);
|
||||
when(mapper.selectList(any())).thenReturn(List.of(log));
|
||||
|
||||
Map<String, Object> result = tools.batteryFaultListQuery(
|
||||
3L, 2L, 1L, "BAT", 0, 3, 1,
|
||||
"2026-09-20", "2026-09-21", 1, 500);
|
||||
|
||||
assertEquals(100, result.get("rows"));
|
||||
assertEquals("rows 超过上限 100,已截断为 100", result.get("notice"));
|
||||
Map<String, Object> record = ((List<Map<String, Object>>) result.get("records")).get(0);
|
||||
assertEquals("厕纸机", record.get("deviceTypeText"));
|
||||
assertEquals("充电次数异常", record.get("faultTypeText"));
|
||||
assertEquals("已恢复", record.get("faultStateText"));
|
||||
assertEquals("2026-09-21 10:15:00", record.get("faultStartTime"));
|
||||
assertEquals(3500, record.get("peakValue"));
|
||||
|
||||
ArgumentCaptor<LambdaQueryWrapper<BatteryFaultLog>> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
verify(mapper).selectList(captor.capture());
|
||||
String sql = captor.getValue().getSqlSegment();
|
||||
assertTrue(sql.contains("soft_delete ="));
|
||||
assertTrue(sql.contains("district_id ="));
|
||||
assertTrue(sql.contains("property_id ="));
|
||||
assertTrue(sql.contains("agent_id ="));
|
||||
assertTrue(sql.contains("sn_code LIKE"));
|
||||
assertTrue(sql.contains("fault_start_time >="));
|
||||
assertTrue(sql.contains("fault_start_time <"));
|
||||
assertTrue(sql.contains("ORDER BY fault_start_time DESC,id DESC"));
|
||||
verify(permissionService).applyBatteryFaultLogRowFilter(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidIdsEnumsAndDatesBeforeQuerying() {
|
||||
assertThrows(IllegalArgumentException.class, () -> tools.batteryFaultListQuery(
|
||||
0L, null, null, null, null, null, null, null, null, 1, 20));
|
||||
assertThrows(IllegalArgumentException.class, () -> tools.batteryFaultListQuery(
|
||||
null, null, null, null, 2, null, null, null, null, 1, 20));
|
||||
assertThrows(IllegalArgumentException.class, () -> tools.batteryFaultListQuery(
|
||||
null, null, null, null, null, 4, null, null, null, 1, 20));
|
||||
assertThrows(IllegalArgumentException.class, () -> tools.batteryFaultListQuery(
|
||||
null, null, null, null, null, null, null, "2026-09-22", "2026-09-21", 1, 20));
|
||||
assertThrows(IllegalArgumentException.class, () -> tools.batteryFaultListQuery(
|
||||
null, null, null, null, null, null, null, "2026/09/21", null, 1, 20));
|
||||
verifyNoInteractions(mapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void springAiPublishesReviewedSchema() {
|
||||
var callbacks = org.springframework.ai.tool.method.MethodToolCallbackProvider.builder()
|
||||
.toolObjects(tools).build().getToolCallbacks();
|
||||
Map<String, String> schemas = Arrays.stream(callbacks).collect(Collectors.toMap(
|
||||
callback -> callback.getToolDefinition().name(),
|
||||
callback -> callback.getToolDefinition().inputSchema()));
|
||||
|
||||
assertEquals(1, schemas.size());
|
||||
assertTrue(schemas.containsKey("battery.fault.list.query"));
|
||||
assertTrue(schemas.get("battery.fault.list.query").contains("faultDateFrom"));
|
||||
}
|
||||
}
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
package me.iiv.agent.mcp.tool;
|
||||
|
||||
import me.iiv.agent.es.ElectricityMessageEsGateway;
|
||||
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.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
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.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ElectricityMessageToolsTest {
|
||||
|
||||
@Mock
|
||||
ElectricityMessageEsGateway gateway;
|
||||
|
||||
ElectricityMessageTools tools;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
tools = new ElectricityMessageTools(gateway);
|
||||
}
|
||||
|
||||
private void stubEmpty() {
|
||||
when(gateway.search(anyString(), anyString(), isNull(), anyString(), anyString(), anyInt(), anyInt()))
|
||||
.thenReturn(new ElectricityMessageEsGateway.Result(0, List.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void queriesByStartMonthIndexAndPassesThroughParams() {
|
||||
when(gateway.search(anyString(), anyString(), isNull(), anyString(), anyString(), anyInt(), anyInt()))
|
||||
.thenReturn(new ElectricityMessageEsGateway.Result(2, List.of(
|
||||
new ElectricityMessageEsGateway.Record("2026-09-21 10:00:00", 0, 88),
|
||||
new ElectricityMessageEsGateway.Record("2026-09-21 10:05:00", 4, 3700))));
|
||||
|
||||
Map<String, Object> result = tools.hardwareElectricityMessageListQuery(
|
||||
" AB-123 ", "2026-09-21 00:00:00", "2026-09-21 23:59:59", null, null, null);
|
||||
|
||||
assertEquals("AB-123", result.get("snCode"));
|
||||
assertEquals("electricitymessage_202609", result.get("index"));
|
||||
assertEquals(2L, result.get("total"));
|
||||
assertEquals(1, result.get("page"));
|
||||
assertEquals(500, result.get("rows"));
|
||||
assertFalse(result.containsKey("notice"));
|
||||
|
||||
List<Map<String, Object>> records = (List<Map<String, Object>>) result.get("records");
|
||||
assertEquals(2, records.size());
|
||||
Map<String, Object> first = records.get(0);
|
||||
assertEquals("2026-09-21 10:00:00", first.get("createTime"));
|
||||
assertEquals(0, first.get("messageType"));
|
||||
assertEquals("电量", first.get("messageTypeText"));
|
||||
assertEquals(88, first.get("value"));
|
||||
Map<String, Object> second = records.get(1);
|
||||
assertEquals(4, second.get("messageType"));
|
||||
assertEquals("电压", second.get("messageTypeText"));
|
||||
assertEquals(3700, second.get("value"));
|
||||
|
||||
// 不传 messageType → null 透传(不加 term,查全部类型)
|
||||
verify(gateway).search(eq("electricitymessage_202609"), eq("AB-123"), isNull(),
|
||||
eq("2026-09-21 00:00:00"), eq("2026-09-21 23:59:59"), eq(0), eq(500));
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsAllMessageTypeTexts() {
|
||||
when(gateway.search(anyString(), anyString(), isNull(), anyString(), anyString(), anyInt(), anyInt()))
|
||||
.thenReturn(new ElectricityMessageEsGateway.Result(6, List.of(
|
||||
record(0), record(1), record(2), record(3), record(4), record(5))));
|
||||
|
||||
Map<String, Object> result = tools.hardwareElectricityMessageListQuery(
|
||||
"AB-123", "2026-09-21 00:00:00", "2026-09-21 23:59:59", null, 1, 100);
|
||||
|
||||
List<Map<String, Object>> records = (List<Map<String, Object>>) result.get("records");
|
||||
assertEquals(List.of("电量", "信号", "温度", "充电循环次数", "电压", "电流"),
|
||||
records.stream().map(r -> r.get("messageTypeText")).collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
private static ElectricityMessageEsGateway.Record record(int messageType) {
|
||||
return new ElectricityMessageEsGateway.Record("2026-09-21 10:00:00", messageType, 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsBlankSnCodeCrossDayInvertedTimeAndBadMessageType() {
|
||||
assertThrows(IllegalArgumentException.class, () -> tools.hardwareElectricityMessageListQuery(
|
||||
" ", "2026-09-21 00:00:00", "2026-09-21 23:59:59", null, 1, 500));
|
||||
assertThrows(IllegalArgumentException.class, () -> tools.hardwareElectricityMessageListQuery(
|
||||
"AB-123", null, "2026-09-21 23:59:59", null, 1, 500));
|
||||
assertThrows(IllegalArgumentException.class, () -> tools.hardwareElectricityMessageListQuery(
|
||||
"AB-123", "2026-09-21 00:00:00", null, null, 1, 500));
|
||||
// 跨天:老系统静默丢数据,这里显式拒绝
|
||||
assertThrows(IllegalArgumentException.class, () -> tools.hardwareElectricityMessageListQuery(
|
||||
"AB-123", "2026-09-21 00:00:00", "2026-09-22 00:00:00", null, 1, 500));
|
||||
assertThrows(IllegalArgumentException.class, () -> tools.hardwareElectricityMessageListQuery(
|
||||
"AB-123", "2026-09-21 23:59:59", "2026-09-21 00:00:00", null, 1, 500));
|
||||
assertThrows(IllegalArgumentException.class, () -> tools.hardwareElectricityMessageListQuery(
|
||||
"AB-123", "2026-09-21 0:00:00", "2026-09-21 23:59:59", null, 1, 500));
|
||||
assertThrows(IllegalArgumentException.class, () -> tools.hardwareElectricityMessageListQuery(
|
||||
"AB-123", "2026/09/21 00:00:00", "2026-09-21 23:59:59", null, 1, 500));
|
||||
assertThrows(IllegalArgumentException.class, () -> tools.hardwareElectricityMessageListQuery(
|
||||
"AB-123", "2026-09-21 00:00:00", "2026-09-21 23:59:59", -1, 1, 500));
|
||||
assertThrows(IllegalArgumentException.class, () -> tools.hardwareElectricityMessageListQuery(
|
||||
"AB-123", "2026-09-21 00:00:00", "2026-09-21 23:59:59", 6, 1, 500));
|
||||
verifyNoInteractions(gateway);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCrossDayWithMessage() {
|
||||
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
|
||||
() -> tools.hardwareElectricityMessageListQuery(
|
||||
"AB-123", "2026-09-21 00:00:00", "2026-09-22 00:00:00", null, 1, 500));
|
||||
assertEquals("只支持单日查询:startTime 与 endTime 必须为同一天(yyyy-MM-dd)",
|
||||
exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void truncatesRowsOverMaxWithNotice() {
|
||||
stubEmpty();
|
||||
|
||||
Map<String, Object> result = tools.hardwareElectricityMessageListQuery(
|
||||
"AB-123", "2026-09-21 00:00:00", "2026-09-21 23:59:59", null, 1, 1500);
|
||||
|
||||
assertEquals(1000, result.get("rows"));
|
||||
assertEquals("rows 超过上限 1000,已截断为 1000", result.get("notice"));
|
||||
verify(gateway).search(anyString(), anyString(), isNull(), anyString(), anyString(), eq(0), eq(1000));
|
||||
}
|
||||
|
||||
@Test
|
||||
void paginatesFromAndSize() {
|
||||
when(gateway.search(anyString(), anyString(), eq(3), anyString(), anyString(), anyInt(), anyInt()))
|
||||
.thenReturn(new ElectricityMessageEsGateway.Result(0, List.of()));
|
||||
|
||||
tools.hardwareElectricityMessageListQuery(
|
||||
"AB-123", "2026-09-21 00:00:00", "2026-09-21 23:59:59", 3, 2, 100);
|
||||
|
||||
verify(gateway).search(anyString(), anyString(), eq(3), anyString(), anyString(), eq(100), eq(100));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToDefaultsForInvalidPageAndRows() {
|
||||
stubEmpty();
|
||||
|
||||
Map<String, Object> result = tools.hardwareElectricityMessageListQuery(
|
||||
"AB-123", "2026-09-21 00:00:00", "2026-09-21 23:59:59", null, 0, -1);
|
||||
|
||||
assertEquals(1, result.get("page"));
|
||||
assertEquals(500, result.get("rows"));
|
||||
assertFalse(result.containsKey("notice"));
|
||||
verify(gateway).search(anyString(), anyString(), isNull(), anyString(), anyString(), eq(0), eq(500));
|
||||
}
|
||||
|
||||
@Test
|
||||
void truncatesWindowWhenFromExceedsMaxResultWindow() {
|
||||
stubEmpty();
|
||||
|
||||
// from = 20*500 = 10000 ≥ 10000 → 截断为最后可用窗口 9500
|
||||
Map<String, Object> result = tools.hardwareElectricityMessageListQuery(
|
||||
"AB-123", "2026-09-21 00:00:00", "2026-09-21 23:59:59", null, 21, 500);
|
||||
|
||||
assertEquals(21, result.get("page"));
|
||||
assertEquals(500, result.get("rows"));
|
||||
assertEquals("查询起始位置超过 ES max_result_window(10000),已截断为第 20 页起的最后可用窗口",
|
||||
result.get("notice"));
|
||||
verify(gateway).search(anyString(), anyString(), isNull(), anyString(), anyString(), eq(9500), eq(500));
|
||||
}
|
||||
|
||||
@Test
|
||||
void truncatesPartialPageWithinMaxResultWindow() {
|
||||
stubEmpty();
|
||||
|
||||
// from = 16*600 = 9600 < 10000,但 9600+600 > 10000 → 本页截断为 400 条
|
||||
Map<String, Object> result = tools.hardwareElectricityMessageListQuery(
|
||||
"AB-123", "2026-09-21 00:00:00", "2026-09-21 23:59:59", null, 17, 600);
|
||||
|
||||
assertEquals(400, result.get("rows"));
|
||||
assertEquals("本页超出 ES max_result_window(10000),已截断为 400 条", result.get("notice"));
|
||||
verify(gateway).search(anyString(), anyString(), isNull(), anyString(), anyString(), eq(9600), eq(400));
|
||||
}
|
||||
|
||||
@Test
|
||||
void combinesRowAndWindowNotices() {
|
||||
stubEmpty();
|
||||
|
||||
// rows=1200 先截断为 1000,page=11 → from=10000 ≥ 10000 再截断为最后可用窗口 9000
|
||||
Map<String, Object> result = tools.hardwareElectricityMessageListQuery(
|
||||
"AB-123", "2026-09-21 00:00:00", "2026-09-21 23:59:59", null, 11, 1200);
|
||||
|
||||
assertEquals("rows 超过上限 1000,已截断为 1000;查询起始位置超过 ES max_result_window(10000),已截断为第 10 页起的最后可用窗口",
|
||||
result.get("notice"));
|
||||
verify(gateway).search(anyString(), anyString(), isNull(), anyString(), anyString(), eq(9000), eq(1000));
|
||||
}
|
||||
|
||||
@Test
|
||||
void indexFollowsStartTimeMonth() {
|
||||
stubEmpty();
|
||||
|
||||
tools.hardwareElectricityMessageListQuery(
|
||||
"AB-123", "2026-01-31 23:00:00", "2026-01-31 23:59:59", null, 1, 500);
|
||||
|
||||
ArgumentCaptor<String> indexCaptor = ArgumentCaptor.forClass(String.class);
|
||||
verify(gateway).search(indexCaptor.capture(), anyString(), isNull(), anyString(), anyString(),
|
||||
anyInt(), anyInt());
|
||||
assertEquals("electricitymessage_202601", indexCaptor.getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void gatewayFailurePropagatesAsIllegalState() {
|
||||
when(gateway.search(anyString(), anyString(), isNull(), anyString(), anyString(), anyInt(), anyInt()))
|
||||
.thenThrow(new IllegalStateException("ES 查询电量消息记录失败: boom"));
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> tools.hardwareElectricityMessageListQuery(
|
||||
"AB-123", "2026-09-21 00:00:00", "2026-09-21 23:59:59", null, 1, 500));
|
||||
}
|
||||
|
||||
@Test
|
||||
void springAiPublishesToolSchema() {
|
||||
var callbacks = org.springframework.ai.tool.method.MethodToolCallbackProvider.builder()
|
||||
.toolObjects(tools).build().getToolCallbacks();
|
||||
Map<String, String> schemas = Arrays.stream(callbacks).collect(Collectors.toMap(
|
||||
callback -> callback.getToolDefinition().name(),
|
||||
callback -> callback.getToolDefinition().inputSchema()));
|
||||
|
||||
assertEquals(1, schemas.size());
|
||||
assertTrue(schemas.containsKey("hardware.electricity.message.list.query"));
|
||||
assertTrue(schemas.get("hardware.electricity.message.list.query").contains("startTime"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void keepsNullMessageTypeRecordFieldsAsIs() {
|
||||
when(gateway.search(anyString(), anyString(), isNull(), anyString(), anyString(), anyInt(), anyInt()))
|
||||
.thenReturn(new ElectricityMessageEsGateway.Result(1, List.of(
|
||||
new ElectricityMessageEsGateway.Record(null, null, null))));
|
||||
|
||||
Map<String, Object> result = tools.hardwareElectricityMessageListQuery(
|
||||
"AB-123", "2026-09-21 00:00:00", "2026-09-21 23:59:59", null, 1, 500);
|
||||
|
||||
Map<String, Object> record = ((List<Map<String, Object>>) result.get("records")).get(0);
|
||||
assertNull(record.get("createTime"));
|
||||
assertNull(record.get("messageType"));
|
||||
assertNull(record.get("messageTypeText"));
|
||||
assertNull(record.get("value"));
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package me.iiv.agent.permission;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import me.iiv.agent.entity.m0.Agent;
|
||||
import me.iiv.agent.entity.m0.AgentManageDistrictAuth;
|
||||
import me.iiv.agent.entity.m0.Authority;
|
||||
import me.iiv.agent.entity.m0.BatteryFaultLog;
|
||||
import me.iiv.agent.entity.m0.Properyt;
|
||||
import me.iiv.agent.entity.m0.User;
|
||||
import me.iiv.agent.entity.m2.WarehouseAgentAuth;
|
||||
import me.iiv.agent.mapper.m0.AgentManageDistrictAuthMapper;
|
||||
import me.iiv.agent.mapper.m0.AgentMapper;
|
||||
import me.iiv.agent.mapper.m0.AuthorityMapper;
|
||||
import me.iiv.agent.mapper.m0.ProperytMapper;
|
||||
import me.iiv.agent.mapper.m2.WarehouseAgentAuthMapper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PermissionServiceBatteryFaultRowFilterTest {
|
||||
|
||||
@Mock AuthorityMapper authorityMapper;
|
||||
@Mock AgentMapper agentMapper;
|
||||
@Mock WarehouseAgentAuthMapper warehouseAgentAuthMapper;
|
||||
@Mock AgentManageDistrictAuthMapper agentManageDistrictAuthMapper;
|
||||
@Mock ProperytMapper properytMapper;
|
||||
@Mock StringRedisTemplate redisTemplate;
|
||||
|
||||
PermissionService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
BatteryFaultLog.class);
|
||||
service = new PermissionService(authorityMapper, agentMapper, warehouseAgentAuthMapper,
|
||||
agentManageDistrictAuthMapper, properytMapper, redisTemplate);
|
||||
}
|
||||
|
||||
@Test
|
||||
void headquartersAddsNoBoundary() {
|
||||
when(authorityMapper.selectById(1L)).thenReturn(authority(0));
|
||||
LambdaQueryWrapper<BatteryFaultLog> wrapper = new LambdaQueryWrapper<>();
|
||||
|
||||
service.applyBatteryFaultLogRowFilter(wrapper, user(1L));
|
||||
|
||||
assertTrue(wrapper.getSqlSegment().isBlank());
|
||||
}
|
||||
|
||||
@Test
|
||||
void agentUsesAgentAndManagedPropertyScope() {
|
||||
when(authorityMapper.selectById(1L)).thenReturn(authority(1));
|
||||
Agent subordinate = new Agent();
|
||||
subordinate.setId(2L);
|
||||
when(agentMapper.selectList(any())).thenReturn(List.of(subordinate));
|
||||
when(warehouseAgentAuthMapper.selectList(any())).thenReturn(List.of());
|
||||
AgentManageDistrictAuth auth = new AgentManageDistrictAuth();
|
||||
auth.setPropertyId(44L);
|
||||
when(agentManageDistrictAuthMapper.selectList(any())).thenReturn(List.of(auth));
|
||||
LambdaQueryWrapper<BatteryFaultLog> wrapper = new LambdaQueryWrapper<>();
|
||||
|
||||
service.applyBatteryFaultLogRowFilter(wrapper, user(1L));
|
||||
|
||||
String sql = wrapper.getSqlSegment();
|
||||
assertTrue(sql.contains("agent_id IN"));
|
||||
assertTrue(sql.contains("property_id IN"));
|
||||
assertTrue(sql.contains("OR"));
|
||||
assertFalse(wrapper.getParamNameValuePairs().isEmpty());
|
||||
}
|
||||
|
||||
private static Authority authority(int isAgent) {
|
||||
Authority authority = new Authority();
|
||||
authority.setId(1L);
|
||||
authority.setIsagent(isAgent);
|
||||
authority.setSoftDelete(0);
|
||||
return authority;
|
||||
}
|
||||
|
||||
private static User user(Long agentId) {
|
||||
User user = new User();
|
||||
user.setId(7L);
|
||||
user.setAuthorityId(1L);
|
||||
user.setAgentId(agentId);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
+14
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import me.iiv.agent.entity.m0.Agent;
|
||||
import me.iiv.agent.entity.m0.BatteryFaultLog;
|
||||
import me.iiv.agent.entity.m0.HardwareView;
|
||||
import me.iiv.agent.entity.m0.Properyt;
|
||||
import me.iiv.agent.entity.svc.AgentApiKey;
|
||||
@@ -59,6 +60,8 @@ class PermissionServiceServicePrincipalTest {
|
||||
Properyt.class);
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
Agent.class);
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
BatteryFaultLog.class);
|
||||
permissionService = new PermissionService(authorityMapper, agentMapper,
|
||||
warehouseAgentAuthMapper, agentManageDistrictAuthMapper, properytMapper, redisTemplate);
|
||||
}
|
||||
@@ -86,6 +89,17 @@ class PermissionServiceServicePrincipalTest {
|
||||
verifyNoInteractions(authorityMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void servicePrincipalBatteryFaultFilterAddsNoBoundary() {
|
||||
setServiceContext();
|
||||
|
||||
LambdaQueryWrapper<BatteryFaultLog> wrapper = new LambdaQueryWrapper<>();
|
||||
permissionService.applyBatteryFaultLogRowFilter(wrapper, null);
|
||||
|
||||
assertTrue(wrapper.getSqlSegment().isBlank(), "tenant_service 按总部视角全量可见");
|
||||
verifyNoInteractions(authorityMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void servicePrincipalVisibleAgentIdsReturnsNull() {
|
||||
setServiceContext();
|
||||
|
||||
Reference in New Issue
Block a user