diff --git a/xiaoqu-agent-service/README.md b/xiaoqu-agent-service/README.md index ee65fb1..b007516 100644 --- a/xiaoqu-agent-service/README.md +++ b/xiaoqu-agent-service/README.md @@ -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` 字段提示。 diff --git a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md index 62ce5d1..f177e0e 100644 --- a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md +++ b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md @@ -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 diff --git a/xiaoqu-agent-service/doc/electricity-message-mcp-tool-design.md b/xiaoqu-agent-service/doc/electricity-message-mcp-tool-design.md new file mode 100644 index 0000000..82115b9 --- /dev/null +++ b/xiaoqu-agent-service/doc/electricity-message-mcp-tool-design.md @@ -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 测试确认无回归。 diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/ElectricityMessageEsGateway.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/ElectricityMessageEsGateway.java new file mode 100644 index 0000000..04e5ef9 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/ElectricityMessageEsGateway.java @@ -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 records; + + public Result(long total, List records) { + this.total = total; + this.records = records; + } + + public long getTotal() { + return total; + } + + public List 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; + } + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/ElectricityMessageEsGatewayImpl.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/ElectricityMessageEsGatewayImpl.java new file mode 100644 index 0000000..f33b940 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/ElectricityMessageEsGatewayImpl.java @@ -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 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 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 records = new ArrayList<>(); + for (Hit 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; + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/ElectricityMessageTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/ElectricityMessageTools.java new file mode 100644 index 0000000..570b484 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/ElectricityMessageTools.java @@ -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 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> records = search.getRecords().stream() + .map(this::record) + .collect(Collectors.toList()); + Map 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 record(ElectricityMessageEsGateway.Record message) { + Map 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); + } + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/es/ElectricityMessageEsGatewayImplTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/es/ElectricityMessageEsGatewayImplTest.java new file mode 100644 index 0000000..40f39b6 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/es/ElectricityMessageEsGatewayImplTest.java @@ -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 responseWith(String... sources) { + List> hits = java.util.Arrays.stream(sources) + .map(json -> Hit.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 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>> 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 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 查询电量消息记录失败")); + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/ElectricityMessageToolsTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/ElectricityMessageToolsTest.java new file mode 100644 index 0000000..4a23956 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/ElectricityMessageToolsTest.java @@ -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 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> records = (List>) result.get("records"); + assertEquals(2, records.size()); + Map 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 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 result = tools.hardwareElectricityMessageListQuery( + "AB-123", "2026-09-21 00:00:00", "2026-09-21 23:59:59", null, 1, 100); + + List> records = (List>) 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 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 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 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 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 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 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 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 result = tools.hardwareElectricityMessageListQuery( + "AB-123", "2026-09-21 00:00:00", "2026-09-21 23:59:59", null, 1, 500); + + Map record = ((List>) result.get("records")).get(0); + assertNull(record.get("createTime")); + assertNull(record.get("messageType")); + assertNull(record.get("messageTypeText")); + assertNull(record.get("value")); + } +}