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:
qiudl
2026-09-22 11:30:56 +02:00
20 changed files with 1664 additions and 43 deletions
+32
View File
@@ -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 " 前缀,不要再拼。
+2
View File
@@ -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;
}
}
}
@@ -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 + " 不在允许范围内");
}
}
}
@@ -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(合同所属公司,
@@ -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"));
}
}
@@ -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"));
}
}
@@ -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;
}
}
@@ -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();