From 315f9bafff5fdad0670cccef965f22756d71d127 Mon Sep 17 00:00:00 2001 From: qiudl Date: Mon, 21 Sep 2026 10:43:30 +0200 Subject: [PATCH 01/13] =?UTF-8?q?feat(hardware):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E7=94=B5=E6=B1=A0=E6=95=85=E9=9A=9C=E5=88=97=E8=A1=A8=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E5=B7=A5=E5=85=B7=20(REQ-20260921-0006)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- xiaoqu-agent-service/README.md | 1 + .../doc/agent-mcp-tools-reference.md | 15 ++ .../doc/battery-fault-mcp-tool-design.md | 32 ++++ .../iiv/agent/entity/m0/BatteryFaultLog.java | 45 +++++ .../mapper/m0/BatteryFaultLogMapper.java | 7 + .../iiv/agent/mcp/tool/BatteryFaultTools.java | 172 ++++++++++++++++++ .../agent/permission/PermissionService.java | 19 ++ .../agent/mcp/tool/BatteryFaultToolsTest.java | 137 ++++++++++++++ ...ssionServiceBatteryFaultRowFilterTest.java | 99 ++++++++++ ...PermissionServiceServicePrincipalTest.java | 14 ++ 10 files changed, 541 insertions(+) create mode 100644 xiaoqu-agent-service/doc/battery-fault-mcp-tool-design.md create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/BatteryFaultLog.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/BatteryFaultLogMapper.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/BatteryFaultTools.java create mode 100644 xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/BatteryFaultToolsTest.java create mode 100644 xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceBatteryFaultRowFilterTest.java diff --git a/xiaoqu-agent-service/README.md b/xiaoqu-agent-service/README.md index da22bd6..ee65fb1 100644 --- a/xiaoqu-agent-service/README.md +++ b/xiaoqu-agent-service/README.md @@ -92,6 +92,7 @@ xiaoqu-agent-service/ | `pickup.order.list.query` | workorder | read | 取货工单分页查询;申请/取货/完成日期独立过滤,物流按页批量富化 | | `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 | 分页统一规范: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 8733420..62ce5d1 100644 --- a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md +++ b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md @@ -782,6 +782,21 @@ --- +## 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`。 + +--- + ## 附录:在线查看工具清单 ```bash diff --git a/xiaoqu-agent-service/doc/battery-fault-mcp-tool-design.md b/xiaoqu-agent-service/doc/battery-fault-mcp-tool-design.md new file mode 100644 index 0000000..9dc8a55 --- /dev/null +++ b/xiaoqu-agent-service/doc/battery-fault-mcp-tool-design.md @@ -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 测试用于确认工具注册、权限和既有功能无回归。 diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/BatteryFaultLog.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/BatteryFaultLog.java new file mode 100644 index 0000000..ad2ffa8 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/BatteryFaultLog.java @@ -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; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/BatteryFaultLogMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/BatteryFaultLogMapper.java new file mode 100644 index 0000000..30150e1 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/BatteryFaultLogMapper.java @@ -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 { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/BatteryFaultTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/BatteryFaultTools.java new file mode 100644 index 0000000..93a6325 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/BatteryFaultTools.java @@ -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 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 wrapper = new LambdaQueryWrapper() + .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> records = mapper.selectList(wrapper).stream() + .map(this::record).collect(Collectors.toList()); + + Map result = new LinkedHashMap<>(); + result.put("total", total); + result.put("page", pageNo); + result.put("rows", pageSize); + if (truncated) { + result.put("notice", "rows 超过上限 100,已截断为 100"); + } + result.put("records", records); + return result; + } + + private Map record(BatteryFaultLog log) { + Map 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 + " 不在允许范围内"); + } + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java index af8c039..6401103 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java @@ -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 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(合同所属公司, diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/BatteryFaultToolsTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/BatteryFaultToolsTest.java new file mode 100644 index 0000000..7ea9f4d --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/BatteryFaultToolsTest.java @@ -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 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 record = ((List>) 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> 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 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")); + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceBatteryFaultRowFilterTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceBatteryFaultRowFilterTest.java new file mode 100644 index 0000000..68404af --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceBatteryFaultRowFilterTest.java @@ -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 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 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; + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceServicePrincipalTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceServicePrincipalTest.java index 3c6fdc4..7068250 100644 --- a/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceServicePrincipalTest.java +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceServicePrincipalTest.java @@ -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 wrapper = new LambdaQueryWrapper<>(); + permissionService.applyBatteryFaultLogRowFilter(wrapper, null); + + assertTrue(wrapper.getSqlSegment().isBlank(), "tenant_service 按总部视角全量可见"); + verifyNoInteractions(authorityMapper); + } + @Test void servicePrincipalVisibleAgentIdsReturnsNull() { setServiceContext(); From b3b5877e2651cb0c27eae06c662bb9bd0bf8d6f9 Mon Sep 17 00:00:00 2001 From: my_local_kimi_dev2 Date: Mon, 21 Sep 2026 17:59:20 +0800 Subject: [PATCH 02/13] =?UTF-8?q?feat(hardware):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E7=94=B5=E9=87=8F=E6=B6=88=E6=81=AF=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E6=9F=A5=E8=AF=A2=E5=B7=A5=E5=85=B7=20(REQ-20260921-0?= =?UTF-8?q?007)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- xiaoqu-agent-service/README.md | 1 + .../doc/agent-mcp-tools-reference.md | 22 ++ .../electricity-message-mcp-tool-design.md | 78 ++++++ .../agent/es/ElectricityMessageEsGateway.java | 70 +++++ .../es/ElectricityMessageEsGatewayImpl.java | 107 +++++++ .../mcp/tool/ElectricityMessageTools.java | 150 ++++++++++ .../ElectricityMessageEsGatewayImplTest.java | 155 +++++++++++ .../mcp/tool/ElectricityMessageToolsTest.java | 261 ++++++++++++++++++ 8 files changed, 844 insertions(+) create mode 100644 xiaoqu-agent-service/doc/electricity-message-mcp-tool-design.md create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/es/ElectricityMessageEsGateway.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/es/ElectricityMessageEsGatewayImpl.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/ElectricityMessageTools.java create mode 100644 xiaoqu-agent-service/src/test/java/me/iiv/agent/es/ElectricityMessageEsGatewayImplTest.java create mode 100644 xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/ElectricityMessageToolsTest.java 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")); + } +} From 6e7a5da19dd85f83300d4bbc886c344fb9e9db06 Mon Sep 17 00:00:00 2001 From: my_local_kimi_dev2 Date: Mon, 21 Sep 2026 18:40:10 +0800 Subject: [PATCH 03/13] docs(agent-service): add AGENTS.md with ai-proj project id 224 and known MCP contract limits --- xiaoqu-agent-service/AGENTS.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 xiaoqu-agent-service/AGENTS.md diff --git a/xiaoqu-agent-service/AGENTS.md b/xiaoqu-agent-service/AGENTS.md new file mode 100644 index 0000000..9312fa2 --- /dev/null +++ b/xiaoqu-agent-service/AGENTS.md @@ -0,0 +1,20 @@ +# xiaoqu-agent-service 项目指引 + +## ai-proj 关联信息 + +- **ai-proj 项目 ID:224**(项目名 xiaoqu_agent_service)。本仓所有需求/任务创建后应挂到该项目,关联任务前确保任务也在项目 224。 +- 近期需求示例:REQ-20260921-0007(id 5778,设备电量消息查询工具,released)→ 任务 22306。 + +## ai-proj MCP 已知限制与真实契约 + +- MCP `update_requirement` 不支持改 project_id/owner;改归属必须走 REST 两步: + 1. `POST /api/v1/requirements/{id}/reassignment/preflight`,body=`{operation_id:"", 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}` +- 任务跨项目用 `move_task`,再 `link_tasks_to_requirement`(role=implementation)。 +- 本机无 ai-proj CLI,远程同步需在有 CLI 的机器执行 `ai-proj sync requirement --id --direction to_remote`。 + +## 生产部署要点 + +- `./deploy/deploy.sh remote-deploy`(SSH 别名 xiaoqu-prd,目录 /data/xiaoqu-agent-service)。 +- 新工具上线后必须幂等绑 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 " 前缀,不要再拼。 From 10d618dac2dfc5e466475e562e0543912afc3181 Mon Sep 17 00:00:00 2001 From: my_local_kimi_dev2 Date: Tue, 22 Sep 2026 09:11:58 +0800 Subject: [PATCH 04/13] =?UTF-8?q?docs:=20AGENTS.md=20=E8=A1=A5=E5=85=85=20?= =?UTF-8?q?reassignment=20confirm=20=E4=B8=80=E6=AC=A1=E6=80=A7=20token=20?= =?UTF-8?q?=E6=B3=A8=E6=84=8F=E9=A1=B9=E4=B8=8E=20mcp.json=20=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- xiaoqu-agent-service/AGENTS.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/xiaoqu-agent-service/AGENTS.md b/xiaoqu-agent-service/AGENTS.md index 9312fa2..2474a8a 100644 --- a/xiaoqu-agent-service/AGENTS.md +++ b/xiaoqu-agent-service/AGENTS.md @@ -3,13 +3,15 @@ ## ai-proj 关联信息 - **ai-proj 项目 ID:224**(项目名 xiaoqu_agent_service)。本仓所有需求/任务创建后应挂到该项目,关联任务前确保任务也在项目 224。 -- 近期需求示例:REQ-20260921-0007(id 5778,设备电量消息查询工具,released)→ 任务 22306。 +- 近期需求示例: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:"", 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}` + 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 --direction to_remote`。 From 6be51692176b27cc6ef7e323773e1676a47e0b86 Mon Sep 17 00:00:00 2001 From: my_local_kimi_dev2 Date: Tue, 22 Sep 2026 11:21:46 +0800 Subject: [PATCH 05/13] =?UTF-8?q?fix(es):=20ES=20=E9=9B=86=E7=BE=A4=207.7.?= =?UTF-8?q?1=20=E4=B8=8E=E9=AB=98=E9=98=B6=E5=AE=A2=E6=88=B7=E7=AB=AF=207.?= =?UTF-8?q?17=20=E4=B8=8D=E5=85=BC=E5=AE=B9=EF=BC=8C=E7=94=B5=E9=87=8F?= =?UTF-8?q?=E6=B6=88=E6=81=AF/=E6=8D=A2=E7=BA=B8=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E9=99=8D=E7=BA=A7=E4=BD=8E=E9=98=B6=20RestCl?= =?UTF-8?q?ient?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生产两个 ES 集群均为 7.7.1:elasticsearch-java 7.17 的 vendor Content-Type (application/vnd.elasticsearch+json; compatible-with=7) 被 406 拒绝, 且其 X-Elastic-Product 响应头校验(7.14 引入)在 7.7.1 上无法通过。 ElectricityMessageEsGatewayImpl 与 PaperUseLogEsGatewayImpl 改用低阶 RestClient 直连(application/json、无产品头校验),查询语义不变。 修复REQ-20260921-0007上线后 tools/call 406 冒烟失败。 --- .../me/iiv/agent/config/EsClientConfig.java | 2 + .../es/ElectricityMessageEsGatewayImpl.java | 94 ++++++------ .../agent/es/PaperUseLogEsGatewayImpl.java | 100 +++++++----- .../ElectricityMessageEsGatewayImplTest.java | 144 +++++++++--------- 4 files changed, 184 insertions(+), 156 deletions(-) diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/config/EsClientConfig.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/config/EsClientConfig.java index 23b53c6..c061fcb 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/config/EsClientConfig.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/config/EsClientConfig.java @@ -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 { 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 index f33b940..130848b 100644 --- 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 @@ -1,16 +1,12 @@ 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 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; @@ -22,8 +18,12 @@ import java.util.ArrayList; import java.util.List; /** - * electricitymessage_yyyyMM ES 查询实现(elasticsearch-java 7.17 强类型 API), - * 只注入 esLocal 客户端(索引只在本地集群,不走 esCloud)。 + * 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、 @@ -37,47 +37,50 @@ public class ElectricityMessageEsGatewayImpl implements ElectricityMessageEsGate private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(BUSINESS_ZONE); - private final ElasticsearchClient localClient; + private final RestClient localRestClient; + private final ObjectMapper mapper = new ObjectMapper(); - public ElectricityMessageEsGatewayImpl(@Qualifier("esLocalClient") ElasticsearchClient localClient) { - this.localClient = localClient; + 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) { - List filters = new ArrayList<>(); + ArrayNode filters = mapper.createArrayNode(); // 老代码 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)))); + filters.addObject().putObject("match_phrase").putObject("snCode").put("query", snCode); if (messageType != null) { - filters.add(Query.of(q -> q.term(t -> t.field("messageType") - .value(FieldValue.of(messageType.longValue()))))); + filters.addObject().putObject("term").putObject("messageType").put("value", messageType); } // 老代码 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))))); + ObjectNode range = filters.addObject().putObject("range").putObject("createTime"); + range.put("gte", startTime).put("lte", 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 { - 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); + 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 records = new ArrayList<>(); - for (Hit hit : response.hits().hits()) { - JsonData source = hit.source(); + for (JsonNode hit : root.at("/hits/hits")) { + JsonNode source = hit.get("_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")))); + records.add(new Record(createTimeText(source.get("createTime")), + integerValue(source.get("messageType")), + integerValue(source.get("value")))); } - return new Result(response.hits().total().value(), records); + return new Result(root.at("/hits/total/value").asLong(), records); } catch (IOException | RuntimeException e) { throw new IllegalStateException("ES 查询电量消息记录失败: " + e.getMessage(), e); } @@ -87,21 +90,20 @@ public class ElectricityMessageEsGatewayImpl implements ElectricityMessageEsGate * 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) { + private static String createTimeText(JsonNode value) { + if (value == null || value.isNull()) { return null; } - if (value instanceof JsonString) { - return ((JsonString) value).getString(); + if (value.isTextual()) { + return value.asText(); } - if (value instanceof JsonNumber) { - return TIME_FORMATTER.format(Instant.ofEpochMilli(((JsonNumber) value).longValue())); + if (value.isNumber()) { + return TIME_FORMATTER.format(Instant.ofEpochMilli(value.longValue())); } return null; } - private static Integer integerValue(JsonValue value) { - return value != null && value.getValueType() == JsonValue.ValueType.NUMBER - ? ((JsonNumber) value).intValue() : null; + private static Integer integerValue(JsonNode value) { + return value != null && value.isNumber() ? value.intValue() : null; } } diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsGatewayImpl.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsGatewayImpl.java index 32c4edc..a1b182c 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsGatewayImpl.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsGatewayImpl.java @@ -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,37 @@ 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 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))))))); + ArrayNode stateTerms = filters.addObject().putObject("terms").putObject("state").putArray("terms"); + 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); + body.putObject("aggs").putObject("rfids").putObject("terms") + .put("field", "rfid").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 +69,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 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 baseFilters(Long districtId, Integer gender, Long fromMillis, Long toMillis, - Integer deviceType, String positionMode) { - List 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; } } 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 index 40f39b6..0aa2ea1 100644 --- 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 @@ -1,12 +1,13 @@ 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 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; @@ -14,103 +15,107 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.util.List; -import java.util.function.Function; +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.ArgumentMatchers.eq; 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-21 10:00:00\",\"messageType\":0,\"value\":88}}," + + "{\"_index\":\"electricitymessage_202609\"," + + "\"_source\":{\"createTime\":" + EPOCH_MILLIS + ",\"messageType\":4,\"value\":3700}}" + + "]}}"; + @Mock - ElasticsearchClient localClient; + RestClient localRestClient; ElectricityMessageEsGatewayImpl gateway; + private final ObjectMapper mapper = new ObjectMapper(); + @BeforeEach void setUp() { - gateway = new ElectricityMessageEsGatewayImpl(localClient); + gateway = new ElectricityMessageEsGatewayImpl(localRestClient); } - 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))); + 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; } - @SuppressWarnings("unchecked") - private SearchRequest captureRequest(SearchResponse response, Integer messageType) throws Exception { - when(localClient.search(any(Function.class), eq(JsonData.class))).thenReturn(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>> captor = - ArgumentCaptor.forClass(Function.class); - verify(localClient).search(captor.capture(), eq(JsonData.class)); - return captor.getValue().apply(new SearchRequest.Builder()).build(); + ArgumentCaptor 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 predicate) { + for (JsonNode filter : filters) { + if (predicate.test(filter)) { + return true; + } + } + return false; } @Test - @SuppressWarnings("unchecked") void buildsMatchPhraseOptionalTermRangeSortAndPagination() throws Exception { - SearchRequest request = captureRequest(responseWith( - "{\"createTime\":\"2026-09-21 10:00:00\",\"messageType\":0,\"value\":88}"), 0); + JsonNode body = captureRequestBody(RESPONSE_JSON, 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()); + 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()); - List filters = - request.query().bool().filter(); + JsonNode filters = body.at("/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"))); + assertTrue(anyFilter(filters, f -> f.has("match_phrase") + && f.get("match_phrase").get("snCode").get("query").asText().equals("AB-123"))); // messageType 传了才加 term - assertTrue(filters.stream().anyMatch(q -> q.isTerm() - && q.term().field().equals("messageType") - && q.term().value().longValue() == 0)); + assertTrue(anyFilter(filters, f -> f.has("term") + && f.get("term").get("messageType").get("value").asInt() == 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"))); + assertTrue(anyFilter(filters, f -> f.has("range") + && f.get("range").get("createTime").get("gte").asText().equals("2026-09-21 00:00:00") + && f.get("range").get("createTime").get("lte").asText().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())); + 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 - @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}")); + 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); @@ -124,13 +129,15 @@ class ElectricityMessageEsGatewayImplTest { // 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 - @SuppressWarnings("unchecked") void mapsNullAndMissingFields() throws Exception { - when(localClient.search(any(Function.class), eq(JsonData.class))).thenReturn(responseWith( - "{\"createTime\":null}")); + 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) @@ -142,10 +149,9 @@ class ElectricityMessageEsGatewayImplTest { } @Test - @SuppressWarnings("unchecked") void wrapsEsFailureInIllegalState() throws Exception { - when(localClient.search(any(Function.class), eq(JsonData.class))) - .thenThrow(new java.io.IOException("connection reset")); + when(localRestClient.performRequest(any(Request.class))) + .thenThrow(new IOException("connection reset")); IllegalStateException exception = assertThrows(IllegalStateException.class, () -> gateway.search("electricitymessage_202609", "AB-123", null, From 7613499dfbaca48cd273934a6e1f3c3d1c206b85 Mon Sep 17 00:00:00 2001 From: my_local_kimi_dev2 Date: Tue, 22 Sep 2026 11:24:22 +0800 Subject: [PATCH 06/13] =?UTF-8?q?docs(agent-service):=20AGENTS.md=20?= =?UTF-8?q?=E8=A1=A5=E5=85=85=E7=94=9F=E4=BA=A7=20ES=207.7.1=20=E4=B8=8E?= =?UTF-8?q?=E9=AB=98=E9=98=B6=E5=AE=A2=E6=88=B7=E7=AB=AF=E4=B8=8D=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E7=9A=84=E5=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- xiaoqu-agent-service/AGENTS.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/xiaoqu-agent-service/AGENTS.md b/xiaoqu-agent-service/AGENTS.md index 2474a8a..b2898e8 100644 --- a/xiaoqu-agent-service/AGENTS.md +++ b/xiaoqu-agent-service/AGENTS.md @@ -15,6 +15,11 @@ - 任务跨项目用 `move_task`,再 `link_tasks_to_requirement`(role=implementation)。 - 本机无 ai-proj CLI,远程同步需在有 CLI 的机器执行 `ai-proj sync requirement --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。 + ## 生产部署要点 - `./deploy/deploy.sh remote-deploy`(SSH 别名 xiaoqu-prd,目录 /data/xiaoqu-agent-service)。 From d87a8752a19dc9a87a171f099118035668fa9ea3 Mon Sep 17 00:00:00 2001 From: my_local_kimi_dev2 Date: Tue, 22 Sep 2026 11:30:46 +0800 Subject: [PATCH 07/13] =?UTF-8?q?fix(es):=20=E7=94=B5=E9=87=8F=E6=B6=88?= =?UTF-8?q?=E6=81=AF=20createTime=20range=20=E6=94=B9=E5=8F=91=20epoch=20?= =?UTF-8?q?=E6=AF=AB=E7=A7=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit electricitymessage 索引 createTime 是 date 类型 (format strict_date_optional_time||epoch_millis),yyyy-MM-dd HH:mm:ss 字符串会被 400 拒绝;对齐老系统 DateUtils.parseDate 转 Date 后由 transport 序列化为 epoch 毫秒的口径。 --- .../agent/es/ElectricityMessageEsGatewayImpl.java | 14 ++++++++++---- .../es/ElectricityMessageEsGatewayImplTest.java | 6 +++--- 2 files changed, 13 insertions(+), 7 deletions(-) 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 index 130848b..94280a5 100644 --- 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 @@ -54,9 +54,11 @@ public class ElectricityMessageEsGatewayImpl implements ElectricityMessageEsGate if (messageType != null) { filters.addObject().putObject("term").putObject("messageType").put("value", messageType); } - // 老代码 rangeQuery from(start,true).to(end,true),闭区间 + // 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", startTime).put("lte", endTime); + range.put("gte", toEpochMillis(startTime)).put("lte", toEpochMillis(endTime)); ObjectNode body = mapper.createObjectNode(); body.put("from", from).put("size", size); @@ -86,9 +88,13 @@ public class ElectricityMessageEsGatewayImpl implements ElectricityMessageEsGate } } + private long toEpochMillis(String timeText) { + return TIME_FORMATTER.parse(timeText, Instant::from).toEpochMilli(); + } + /** - * createTime 在索引里可能是 yyyy-MM-dd HH:mm:ss 文本(老 range 直接传字符串可证 ES 接受该格式), - * 也可能是 epoch 毫秒数;两种形态都归一为 yyyy-MM-dd HH:mm:ss 文本输出。 + * createTime 在索引里是 date 类型,_source 里可能是 ISO 文本也可能是 epoch 毫秒数; + * 两种形态都归一为 Asia/Shanghai yyyy-MM-dd HH:mm:ss 文本输出。 */ private static String createTimeText(JsonNode value) { if (value == null || value.isNull()) { 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 index 0aa2ea1..ec530a0 100644 --- 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 @@ -98,10 +98,10 @@ class ElectricityMessageEsGatewayImplTest { // messageType 传了才加 term assertTrue(anyFilter(filters, f -> f.has("term") && f.get("term").get("messageType").get("value").asInt() == 0)); - // createTime 闭区间 range + // createTime 闭区间 range,date 类型字段必须发 epoch 毫秒(空格分隔字符串会被 ES 400 拒绝) assertTrue(anyFilter(filters, f -> f.has("range") - && f.get("range").get("createTime").get("gte").asText().equals("2026-09-21 00:00:00") - && f.get("range").get("createTime").get("lte").asText().equals("2026-09-21 23:59:59"))); + && f.get("range").get("createTime").get("gte").asLong() == 1789920000000L + && f.get("range").get("createTime").get("lte").asLong() == 1790006399000L)); } @Test From 267bc59a8f080043a1258cb8454dfeefce7c8a14 Mon Sep 17 00:00:00 2001 From: my_local_kimi_dev2 Date: Tue, 22 Sep 2026 11:36:54 +0800 Subject: [PATCH 08/13] =?UTF-8?q?fix(es):=20=E7=94=B5=E9=87=8F=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E5=BD=92=E4=B8=80=E5=8C=96=E2=80=94=E2=80=94value=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=AD=97=E7=AC=A6=E4=B8=B2=E5=BD=A2=E6=80=81?= =?UTF-8?q?=E3=80=81createTime=20ISO=20=E6=96=87=E6=9C=AC=E8=BD=AC?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生产 _source 实测:value 索引为文本("28"/"100"),原 isNumber 判空 导致 value 恒 null;createTime 为 ISO 文本(2026-09-21T10:00:00.000+08:00), 原样输出与老系统 yyyy-MM-dd HH:mm:ss 口径不一致,统一归一。 --- .../es/ElectricityMessageEsGatewayImpl.java | 39 ++++++++++++++++--- .../ElectricityMessageEsGatewayImplTest.java | 2 +- 2 files changed, 34 insertions(+), 7 deletions(-) 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 index 94280a5..4309b07 100644 --- 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 @@ -12,8 +12,10 @@ 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; @@ -93,23 +95,48 @@ public class ElectricityMessageEsGatewayImpl implements ElectricityMessageEsGate } /** - * createTime 在索引里是 date 类型,_source 里可能是 ISO 文本也可能是 epoch 毫秒数; - * 两种形态都归一为 Asia/Shanghai yyyy-MM-dd HH:mm:ss 文本输出。 + * 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.isTextual()) { - return value.asText(); - } 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) { - return value != null && value.isNumber() ? value.intValue() : null; + 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; } } 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 index ec530a0..f0173c7 100644 --- 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 @@ -37,7 +37,7 @@ class ElectricityMessageEsGatewayImplTest { + "\"hits\":{\"total\":{\"value\":2,\"relation\":\"eq\"}," + "\"hits\":[" + "{\"_index\":\"electricitymessage_202609\"," - + "\"_source\":{\"createTime\":\"2026-09-21 10:00:00\",\"messageType\":0,\"value\":88}}," + + "\"_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}}" + "]}}"; From dc43920d5c17b31a24ead75e0d9833ec10ce503a Mon Sep 17 00:00:00 2001 From: my_local_kimi_dev2 Date: Tue, 22 Sep 2026 11:39:46 +0800 Subject: [PATCH 09/13] =?UTF-8?q?docs(agent-service):=20AGENTS.md=20?= =?UTF-8?q?=E8=A1=A5=E5=85=85=20electricitymessage=20=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E5=BD=A2=E6=80=81=E4=B8=8E=20curl=20=E7=9B=B4=E8=BF=9E?= =?UTF-8?q?=E5=86=92=E7=83=9F=E5=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- xiaoqu-agent-service/AGENTS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/xiaoqu-agent-service/AGENTS.md b/xiaoqu-agent-service/AGENTS.md index b2898e8..5c5e2c4 100644 --- a/xiaoqu-agent-service/AGENTS.md +++ b/xiaoqu-agent-service/AGENTS.md @@ -19,6 +19,9 @@ - 生产两个 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 字段。 +- 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 直连即可。 ## 生产部署要点 From 58d01e9a47a72f6b52d651b562af2acf4af68cc0 Mon Sep 17 00:00:00 2001 From: my_local_kimi_dev2 Date: Tue, 22 Sep 2026 13:47:41 +0800 Subject: [PATCH 10/13] =?UTF-8?q?fix(es):=20paperuselog=20rfid=20=E5=8E=BB?= =?UTF-8?q?=E9=87=8D=E8=81=9A=E5=90=88=E6=94=B9=20rfid.keyword=E2=80=94?= =?UTF-8?q?=E2=80=94rfid=20=E4=B8=BA=20text=20=E7=B1=BB=E5=9E=8B=EF=BC=8Ct?= =?UTF-8?q?erms=20=E8=81=9A=E5=90=88=20text=20=E5=AD=97=E6=AE=B5=E8=A2=AB?= =?UTF-8?q?=207.7.1=20=E4=BB=A5=20400=20=E6=8B=92=E7=BB=9D=EF=BC=88?= =?UTF-8?q?=E7=94=9F=E4=BA=A7=E5=AE=9E=E6=B5=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/me/iiv/agent/es/PaperUseLogEsGatewayImpl.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsGatewayImpl.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsGatewayImpl.java index a1b182c..5c69d6f 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsGatewayImpl.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsGatewayImpl.java @@ -53,8 +53,10 @@ public class PaperUseLogEsGatewayImpl implements PaperUseLogEsGateway { 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").put("size", RFID_TERMS_SIZE); + .put("field", "rfid.keyword").put("size", RFID_TERMS_SIZE); Request req = new Request("POST", "/" + index + "/_search"); req.setJsonEntity(mapper.writeValueAsString(body)); From c7b6f84db43f3624889d35d37619fe63ca37907c Mon Sep 17 00:00:00 2001 From: my_local_kimi_dev2 Date: Tue, 22 Sep 2026 13:48:17 +0800 Subject: [PATCH 11/13] =?UTF-8?q?docs(agent-service):=20AGENTS.md=20?= =?UTF-8?q?=E8=A1=A5=20paperuselog=20rfid=20=E4=B8=BA=20text=20=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E9=A1=BB=E7=94=A8=20rfid.keyword=20=E8=81=9A=E5=90=88?= =?UTF-8?q?=E7=9A=84=E5=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- xiaoqu-agent-service/AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/xiaoqu-agent-service/AGENTS.md b/xiaoqu-agent-service/AGENTS.md index 5c5e2c4..0cf946e 100644 --- a/xiaoqu-agent-service/AGENTS.md +++ b/xiaoqu-agent-service/AGENTS.md @@ -20,6 +20,7 @@ - 生产两个 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 直连即可。 From a61040e2bce669a33657e1c53749dd3ea39c981e Mon Sep 17 00:00:00 2001 From: my_local_kimi_dev2 Date: Tue, 22 Sep 2026 14:38:44 +0800 Subject: [PATCH 12/13] =?UTF-8?q?fix(es):=20paperuselog=20state=20terms=20?= =?UTF-8?q?=E8=BF=87=E6=BB=A4=E7=BB=93=E6=9E=84=E4=BF=AE=E6=AD=A3=E2=80=94?= =?UTF-8?q?=E2=80=94{"terms":{"state":[2,4]}}=20=E8=AF=AF=E5=86=99?= =?UTF-8?q?=E6=88=90=E5=B5=8C=E5=A5=97=20terms=20=E5=AF=B9=E8=B1=A1?= =?UTF-8?q?=E8=A2=AB=20ES=207.7.1=20=E4=BB=A5=20400=20=E6=8B=92=E7=BB=9D?= =?UTF-8?q?=EF=BC=88=E7=94=9F=E4=BA=A7=E5=AE=9E=E6=B5=8B=EF=BC=89=EF=BC=9B?= =?UTF-8?q?=E8=A1=A5=20Gateway=20impl=20=E5=B1=82=E5=8D=95=E6=B5=8B?= =?UTF-8?q?=E9=94=81=E8=AF=B7=E6=B1=82=E4=BD=93=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent/es/PaperUseLogEsGatewayImpl.java | 3 +- .../es/PaperUseLogEsGatewayImplTest.java | 138 ++++++++++++++++++ 2 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 xiaoqu-agent-service/src/test/java/me/iiv/agent/es/PaperUseLogEsGatewayImplTest.java diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsGatewayImpl.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsGatewayImpl.java index 5c69d6f..51ee598 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsGatewayImpl.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsGatewayImpl.java @@ -45,7 +45,8 @@ public class PaperUseLogEsGatewayImpl implements PaperUseLogEsGateway { Long fromMillis, Long toMillis, Integer deviceType, String positionMode) { ArrayNode filters = baseFilters(districtId, gender, fromMillis, toMillis, deviceType, positionMode); // 上纸(2)+撤纸(4) 合并后 rfid 去重(老 :302-342) - ArrayNode stateTerms = filters.addObject().putObject("terms").putObject("state").putArray("terms"); + // 正确结构是 {"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 { ObjectNode body = mapper.createObjectNode(); diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/es/PaperUseLogEsGatewayImplTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/es/PaperUseLogEsGatewayImplTest.java new file mode 100644 index 0000000..184b60d --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/es/PaperUseLogEsGatewayImplTest.java @@ -0,0 +1,138 @@ +package me.iiv.agent.es; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; +import org.apache.http.util.EntityUtils; +import org.elasticsearch.client.Request; +import org.elasticsearch.client.Response; +import org.elasticsearch.client.RestClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.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 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 去重失败")); + } +} From 6d76cfd70ecd6b15b48fe26c31de26d7a258fad5 Mon Sep 17 00:00:00 2001 From: my_local_kimi_dev2 Date: Tue, 22 Sep 2026 15:03:25 +0800 Subject: [PATCH 13/13] =?UTF-8?q?docs:=20=E9=83=A8=E7=BD=B2=E5=89=8D?= =?UTF-8?q?=E5=85=88=E6=8E=A8=20main=20=E7=9A=84=E6=95=99=E8=AE=AD?= =?UTF-8?q?=EF=BC=88=E6=9C=AC=E5=9C=B0=E9=A2=86=E5=85=88=20origin=20?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=E7=94=9F=E4=BA=A7=E8=B7=91=E6=97=A7=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- xiaoqu-agent-service/AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/xiaoqu-agent-service/AGENTS.md b/xiaoqu-agent-service/AGENTS.md index 0cf946e..82de46f 100644 --- a/xiaoqu-agent-service/AGENTS.md +++ b/xiaoqu-agent-service/AGENTS.md @@ -27,5 +27,6 @@ ## 生产部署要点 - `./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 " 前缀,不要再拼。