feat(hardware): 新增设备电量消息记录查询工具 (REQ-20260921-0007)

This commit is contained in:
my_local_kimi_dev2
2026-09-21 17:59:20 +08:00
parent 3e82e76135
commit b3b5877e26
8 changed files with 844 additions and 0 deletions
+1
View File
@@ -93,6 +93,7 @@ xiaoqu-agent-service/
| `pickup.order.detail.query` | workorder | read | 取货工单详情;返回物流、取货物品、SN 调整、附件、流程及关联单据 |
| `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 |
| `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` 字段提示。
@@ -797,6 +797,28 @@
---
## 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。
---
## 附录:在线查看工具清单
```bash
@@ -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 测试确认无回归。
@@ -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,107 @@
package me.iiv.agent.es;
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch._types.FieldValue;
import co.elastic.clients.elasticsearch._types.SortOrder;
import co.elastic.clients.elasticsearch._types.query_dsl.Query;
import co.elastic.clients.elasticsearch.core.SearchResponse;
import co.elastic.clients.elasticsearch.core.search.Hit;
import co.elastic.clients.json.JsonData;
import jakarta.json.JsonNumber;
import jakarta.json.JsonObject;
import jakarta.json.JsonString;
import jakarta.json.JsonValue;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
/**
* electricitymessage_yyyyMM ES 查询实现(elasticsearch-java 7.17 强类型 API),
* 只注入 esLocal 客户端(索引只在本地集群,不走 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 ElasticsearchClient localClient;
public ElectricityMessageEsGatewayImpl(@Qualifier("esLocalClient") ElasticsearchClient localClient) {
this.localClient = localClient;
}
@Override
public Result search(String index, String snCode, Integer messageType,
String startTime, String endTime, int from, int size) {
List<Query> filters = new ArrayList<>();
// 老代码 snCode 字符串走 matchQuery(BaseSearchSupportImpl2 type=0 非数值分支),
// standard analyzer 会把 AB-123 切成 ab/123 造成误配;改用 match_phrase 精确短语
filters.add(Query.of(q -> q.matchPhrase(m -> m.field("snCode").query(snCode))));
if (messageType != null) {
filters.add(Query.of(q -> q.term(t -> t.field("messageType")
.value(FieldValue.of(messageType.longValue())))));
}
// 老代码 rangeQuery from(start,true).to(end,true),闭区间
filters.add(Query.of(q -> q.range(r -> r.field("createTime")
.gte(JsonData.of(startTime)).lte(JsonData.of(endTime)))));
try {
SearchResponse<JsonData> response = localClient.search(s -> s
.index(index)
.from(from)
.size(size)
.trackTotalHits(t -> t.enabled(true))
.query(q -> q.bool(b -> b.filter(filters)))
.sort(so -> so.field(f -> f.field("createTime").order(SortOrder.Asc))),
JsonData.class);
List<Record> records = new ArrayList<>();
for (Hit<JsonData> hit : response.hits().hits()) {
JsonData source = hit.source();
if (source == null) {
continue;
}
JsonObject obj = source.toJson().asJsonObject();
records.add(new Record(createTimeText(obj.get("createTime")),
integerValue(obj.get("messageType")),
integerValue(obj.get("value"))));
}
return new Result(response.hits().total().value(), records);
} catch (IOException | RuntimeException e) {
throw new IllegalStateException("ES 查询电量消息记录失败: " + e.getMessage(), e);
}
}
/**
* createTime 在索引里可能是 yyyy-MM-dd HH:mm:ss 文本(老 range 直接传字符串可证 ES 接受该格式),
* 也可能是 epoch 毫秒数;两种形态都归一为 yyyy-MM-dd HH:mm:ss 文本输出。
*/
private static String createTimeText(JsonValue value) {
if (value == null || value.getValueType() == JsonValue.ValueType.NULL) {
return null;
}
if (value instanceof JsonString) {
return ((JsonString) value).getString();
}
if (value instanceof JsonNumber) {
return TIME_FORMATTER.format(Instant.ofEpochMilli(((JsonNumber) value).longValue()));
}
return null;
}
private static Integer integerValue(JsonValue value) {
return value != null && value.getValueType() == JsonValue.ValueType.NUMBER
? ((JsonNumber) value).intValue() : null;
}
}
@@ -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);
}
}
}
@@ -0,0 +1,155 @@
package me.iiv.agent.es;
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch.core.SearchRequest;
import co.elastic.clients.elasticsearch.core.SearchResponse;
import co.elastic.clients.elasticsearch.core.search.Hit;
import co.elastic.clients.elasticsearch.core.search.TotalHitsRelation;
import co.elastic.clients.json.JsonData;
import co.elastic.clients.util.ObjectBuilder;
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.List;
import java.util.function.Function;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ElectricityMessageEsGatewayImplTest {
@Mock
ElasticsearchClient localClient;
ElectricityMessageEsGatewayImpl gateway;
@BeforeEach
void setUp() {
gateway = new ElectricityMessageEsGatewayImpl(localClient);
}
private SearchResponse<JsonData> responseWith(String... sources) {
List<Hit<JsonData>> hits = java.util.Arrays.stream(sources)
.map(json -> Hit.<JsonData>of(hit -> hit
.index("electricitymessage_202609")
.source(JsonData.fromJson(json))))
.toList();
return SearchResponse.of(r -> r
.took(1)
.timedOut(false)
.shards(s -> s.total(1).successful(1).skipped(0).failed(0))
.hits(h -> h.total(t -> t.value(2).relation(TotalHitsRelation.Eq))
.hits(hits)));
}
@SuppressWarnings("unchecked")
private SearchRequest captureRequest(SearchResponse<JsonData> response, Integer messageType) throws Exception {
when(localClient.search(any(Function.class), eq(JsonData.class))).thenReturn(response);
gateway.search("electricitymessage_202609", "AB-123", messageType,
"2026-09-21 00:00:00", "2026-09-21 23:59:59", 0, 500);
ArgumentCaptor<Function<SearchRequest.Builder, ObjectBuilder<SearchRequest>>> captor =
ArgumentCaptor.forClass(Function.class);
verify(localClient).search(captor.capture(), eq(JsonData.class));
return captor.getValue().apply(new SearchRequest.Builder()).build();
}
@Test
@SuppressWarnings("unchecked")
void buildsMatchPhraseOptionalTermRangeSortAndPagination() throws Exception {
SearchRequest request = captureRequest(responseWith(
"{\"createTime\":\"2026-09-21 10:00:00\",\"messageType\":0,\"value\":88}"), 0);
assertEquals(List.of("electricitymessage_202609"), request.index());
assertEquals(Integer.valueOf(0), request.from());
assertEquals(Integer.valueOf(500), request.size());
assertTrue(request.trackTotalHits().enabled());
assertEquals(1, request.sort().size());
assertEquals("createTime", request.sort().get(0).field().field());
assertEquals(co.elastic.clients.elasticsearch._types.SortOrder.Asc,
request.sort().get(0).field().order());
List<co.elastic.clients.elasticsearch._types.query_dsl.Query> filters =
request.query().bool().filter();
// snCode 必须 match_phrase(老系统 matchQuery 分词误配的修复)
assertTrue(filters.stream().anyMatch(q -> q.isMatchPhrase()
&& q.matchPhrase().field().equals("snCode")
&& q.matchPhrase().query().equals("AB-123")));
// messageType 传了才加 term
assertTrue(filters.stream().anyMatch(q -> q.isTerm()
&& q.term().field().equals("messageType")
&& q.term().value().longValue() == 0));
// createTime 闭区间 range
assertTrue(filters.stream().anyMatch(q -> q.isRange()
&& q.range().field().equals("createTime")
&& q.range().gte().to(String.class).equals("2026-09-21 00:00:00")
&& q.range().lte().to(String.class).equals("2026-09-21 23:59:59")));
}
@Test
@SuppressWarnings("unchecked")
void omitsMessageTypeTermWhenNull() throws Exception {
SearchRequest request = captureRequest(responseWith(
"{\"createTime\":\"2026-09-21 10:00:00\",\"messageType\":1,\"value\":-60}"), null);
assertTrue(request.query().bool().filter().stream().noneMatch(q -> q.isTerm()));
}
@Test
@SuppressWarnings("unchecked")
void mapsHitsAndEpochMillisCreateTime() throws Exception {
long epochMillis = java.time.Instant.parse("2026-09-21T06:00:00Z").toEpochMilli();
when(localClient.search(any(Function.class), eq(JsonData.class))).thenReturn(responseWith(
"{\"createTime\":\"2026-09-21 10:00:00\",\"messageType\":0,\"value\":88}",
"{\"createTime\":" + epochMillis + ",\"messageType\":4,\"value\":3700}"));
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());
}
@Test
@SuppressWarnings("unchecked")
void mapsNullAndMissingFields() throws Exception {
when(localClient.search(any(Function.class), eq(JsonData.class))).thenReturn(responseWith(
"{\"createTime\":null}"));
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
@SuppressWarnings("unchecked")
void wrapsEsFailureInIllegalState() throws Exception {
when(localClient.search(any(Function.class), eq(JsonData.class)))
.thenThrow(new java.io.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,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"));
}
}