From 8d8e71e45cbb5abc29a26ef0f56e5cc414605b3e Mon Sep 17 00:00:00 2001 From: my_local_kimi_dev2 Date: Sun, 20 Sep 2026 18:48:30 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20MCP=20=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=20district.paper.statistics.query=EF=BC=88=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=94=A8=E7=BA=B8=E7=BB=9F=E8=AE=A1=EF=BC=89=E5=B9=B6?= =?UTF-8?q?=E5=BC=95=E5=85=A5=20ES=207.17=20=E5=AE=A2=E6=88=B7=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 复刻老 MonthlyStatisticsController.getMonthlyStatisticsMap:m0 monthly_statistics 按 gender/按日聚合(用纸长度米、人次、台日),district_strategy.rfid_use_flag 分口径统计 ES paperuselog 换纸卷数 - ES 双集群(云/本地,Dictionary.LocalEsDate 分界)+ 冷热索引(90 天),terms 聚合统计 rfid 去重数替代老 scroll 拉全量 - 引入 co.elastic.clients:elasticsearch-java 7.17.29(显式钉版本,覆盖 Boot BOM 8.x),EsClientConfig 双集群客户端 - 字典缺失/ES 异常 fail fast 抛 IllegalStateException(老系统静默为 0);district_strategy 缺失按未使用 rfid 口径(老系统 NPE) - 新增单测 16 个(工具 13 + planner 3),全套 303 个单测通过 --- xiaoqu-agent-service/README.md | 1 + .../doc/agent-mcp-tools-reference.md | 28 ++ .../doc/district-paper-statistics-design.md | 161 ++++++++ xiaoqu-agent-service/pom.xml | 8 + .../me/iiv/agent/config/EsClientConfig.java | 77 ++++ .../iiv/agent/entity/m0/DistrictStrategy.java | 36 ++ .../agent/entity/m0/MonthlyStatistics.java | 81 ++++ .../me/iiv/agent/es/PaperUseLogEsGateway.java | 25 ++ .../agent/es/PaperUseLogEsGatewayImpl.java | 97 +++++ .../me/iiv/agent/es/PaperUseLogEsPlanner.java | 36 ++ .../mapper/m0/DistrictStrategyMapper.java | 7 + .../mapper/m0/MonthlyStatisticsMapper.java | 7 + .../tool/DistrictPaperStatisticsTools.java | 311 +++++++++++++++ .../src/main/resources/application-dev.yml | 8 + .../src/main/resources/application-prod.yml | 12 + .../src/main/resources/application.yml | 13 + .../agent/es/PaperUseLogEsPlannerTest.java | 51 +++ .../DistrictPaperStatisticsToolsTest.java | 376 ++++++++++++++++++ 18 files changed, 1335 insertions(+) create mode 100644 xiaoqu-agent-service/doc/district-paper-statistics-design.md create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/config/EsClientConfig.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/DistrictStrategy.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/MonthlyStatistics.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsGateway.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsGatewayImpl.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsPlanner.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/DistrictStrategyMapper.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/MonthlyStatisticsMapper.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/DistrictPaperStatisticsTools.java create mode 100644 xiaoqu-agent-service/src/test/java/me/iiv/agent/es/PaperUseLogEsPlannerTest.java create mode 100644 xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/DistrictPaperStatisticsToolsTest.java diff --git a/xiaoqu-agent-service/README.md b/xiaoqu-agent-service/README.md index b9e117b..e19244e 100644 --- a/xiaoqu-agent-service/README.md +++ b/xiaoqu-agent-service/README.md @@ -91,6 +91,7 @@ xiaoqu-agent-service/ | `install.order.detail.query` | workorder | read | 装机工单详情;返回明细、作业人员、地址、商城订单、流程、配发 SN、出入库关联及凭证 | | `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 | 分页统一规范: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 b0a9cb9..78e1886 100644 --- a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md +++ b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md @@ -744,6 +744,34 @@ --- +## 30. district.paper.statistics.query + +客户用纸统计,1:1 复刻老系统 `POST /MonthlyStatistics/getMonthlyStatisticsMap`(MonthlyStatisticsController), +分主体统计(DB)与换纸卷数(ES)两部分。 + +- 分类:hardware | 类型:读 +- 主体数据来源:m0 `monthly_statistics`(XXL-Job 预聚合的站点日汇总,每站点每厕位/厕每天一行), + `statistics_time` 范围 [startDate 00:00:00, endDate 23:59:59],可选 `buildingId/floor/toiletId/deviceType` 精确过滤。 +- 入参:`districtId`(必填正整数)、`startDate`/`endDate`(必填 yyyy-MM-dd,startDate≤endDate≤今天)、 + `positionMode`(position=仅厕位/wall=仅厕墙,只作用于 ES 换纸卷数口径)、`deviceType`(0/1)、`buildingId`、`floor`、`toiletId`。 +- 聚合维度 gender(0男1女2母婴3残卫4第三5其他99未知): + `paperLengthM`=Σpaper_length/100(米,3 位小数)、`useCount`=Σuse_count(厕墙行恒 0 直接求和)、 + `deviceDayCount`=按日 distinct 厕位/厕数之和(老系统 perPositionsCount,输出改名)、 + `perUseLengthM`=paperLengthM/useCount(useCount=0 为 null);另输出按日明细(date/gender/paperLengthM/useCount/deviceDayCount)。 +- 换纸卷数 `rfidCount`(ES):读 `district_strategy.rfid_use_flag`(行 id=站点 id)—— + =1 时 state∈{2上纸,4撤纸} 合并后 rfid 去重数(terms 聚合 size 10000 统计 bucket 数,超出部分不计); + ≠1 或记录缺失时取 state=2 精确 count(不含撤纸、不去重)。仅对 DB 结果中出现过的 gender 逐组查询。 +- ES 口径:索引按 startDate 距今 90 天分冷热(`paperuselog` / `paperuselog_all`,老 isHotTime 硬编码); + 集群按 Redis `Dictionary.LocalEsDate`(yyyy-MM-dd)分界——分界日 > endDate 走本地集群, + startDate < 分界日 < endDate 拒绝查询;`Dictionary.StatisticsDays` 限制查询天数上限;字典缺失 fail fast 抛 `IllegalStateException`。 + endDate=今天时 ES 上界取今天 00:00(当天数据未跑完),否则 23:59:59(含边界)。 +- 行级权限:站点可见性沿用 `district.detail.get` 模式(id + soft_delete=0 + applyDistrictRowFilter), + 查不到统一抛 `站点不存在或无权访问`。 +- 与老系统的差异(详见 `district-paper-statistics-design.md`):ES 异常不静默为 0 而是抛错; + rfid 去重用 terms 聚合替代 scroll 拉全量;district_strategy 缺失/空 rfid_use_flag 按"未使用 rfid"口径而非 NPE。 + +--- + ## 附录:在线查看工具清单 ```bash diff --git a/xiaoqu-agent-service/doc/district-paper-statistics-design.md b/xiaoqu-agent-service/doc/district-paper-statistics-design.md new file mode 100644 index 0000000..dd5d366 --- /dev/null +++ b/xiaoqu-agent-service/doc/district-paper-statistics-design.md @@ -0,0 +1,161 @@ +# district.paper.statistics.query 设计文档(客户用纸统计 MCP 工具) + +> 状态:已实现(dev2/paper-statistics-mcp) +> 工具:`district.paper.statistics.query`(@XqTool category=hardware, kind=read) + +## 1. 背景 + +管理端/Agent 需要按站点查询客户用纸统计:各性别分组的用纸长度、使用人次、台日合计、换纸卷数及按日明细。 +老系统接口为 `POST /MonthlyStatistics/getMonthlyStatisticsMap`(xiaoqu-franchisee +`MonthlyStatisticsController.java:106-355`)。本工具 1:1 复刻其统计口径,作为 MCP 只读工具暴露。 + +## 2. 老系统口径复刻 + +### 2.1 主体统计(DB 部分) + +- 表:m0 库 `monthly_statistics`(每站点每厕位/厕每天一行,XXL-Job 预聚合;实体照老 + `beans/b0/MonthlyStatistics.java`)。 +- 过滤:`soft_delete=0`、`district_id` 精确、`statistics_time ∈ [startDate 00:00:00, endDate 23:59:59]` + (实现为左闭右开 `[start, end+1)` 避免秒精度丢行);可选 `building_id/floor/toilet_id/device_type` 精确匹配。 +- 聚合维度 `gender`(gender 为 null 的行跳过,老 :178-180): + - `paperLengthM` = Σpaper_length / 100(paper_length 单位厘米,输出米,3 位小数); + - `useCount` = Σuse_count(老代码对厕位/厕墙、runl_mode 有多个分支,但厕墙行 use_count 恒 0, + 等价于直接求和——老 :188-203 分支差异不影响结果); + - `deviceDayCount` = 按日 distinct 厕位/厕数之和(老 :190-192、:249:positionSet 存 + `p_{positionId}`(厕位行)与 `t_{toiletId}`(厕墙行),每天取 set size 再累加)。 + 老出参字段名 perPositionsCount 注释有误,本工具改名 deviceDayCount); + - `perUseLengthM` = paperLengthM / useCount(useCount=0 时为 null)。 +- 按日明细:date(yyyy-MM-dd)+ 上述指标按 (date, gender) 输出。 +- `toiletGenders`:老系统查 Toilet 表取 gender 集合(老 :147-155);本工具直接取 DB 结果中 distinct gender + (本服务未引入 Toilet 实体,且换纸卷数本来就只对 DB 结果中出现过的 gender 查询,两者口径天然一致)。 + +### 2.2 换纸卷数 rfidCount(ES 部分,本次引入 ES 客户端) + +- 索引:`paperuselog`(热)/ `paperuselog_all`(冷)。startDate 距今 <90 天走热索引, + 否则冷索引(老 `DateToConverter.isHotTime`,:324-336,90 天硬编码)。 +- 集群:Redis db5 `Dictionary` hash 的 `LocalEsDate`(yyyy-MM-dd,云/本地 ES 数据分界日): + - `localEsDate > endDate` → 本地集群; + - `startDate < localEsDate < endDate`(与老代码同为严格小于比较)→ 拒绝: + `查询范围跨越本地化数据分界日 {localEsDate}`; + - 其他 → 云集群。 +- 天数上限:Redis `Dictionary.StatisticsDays`(Integer);查询天数 = endDate-startDate+1, + 超限抛 `查询时间段最大{N}天`。 +- 时间边界(epoch 毫秒,Asia/Shanghai):start = startDate 00:00:00; + endDate=今天 → end = 今天 00:00:00(排除当天,当天数据未跑完,老 :260-268), + 否则 end = endDate 23:59:59(含边界;实现为 `(endDate+1)00:00:00 - 1ms`)。 +- 查询条件(对齐老 ElasticsearchPublic 语义:数值 term、字符串 match,BaseSearchSupportImpl2 :688-716): + - term `districtId`、term `gender`(每组 gender 各查一次,与老 :272 一致); + - range `createTime` [gte, lte] 含边界; + - `deviceType` 非空时 match `deviceType`(老代码该参数为字符串走 matchQuery); + - `positionMode`:position → range `positionsId > 0`;wall → term `positionsId = 0`;不传不加。 +- 口径分支:读 m0 库 `district_strategy`(行 id=站点 id,与老 `districtStrategyService.getById` 一致) + 的 `rfid_use_flag`: + - =1(使用 rfid):state ∈ {2上纸, 4撤纸} 合并后 rfid 去重数。实现用 terms 聚合 + (field `rfid`, size 10000)统计 bucket 数,**不做 scroll 拉全量**; + ⚠️ 去重上限 10000,单站点单 gender 查询区间内不同 rfid 超过 10000 时超出部分不计 + (老系统 scroll 全量无此上限,这是本实现的有意取舍:省内存、降耗时,正常站点量级远低于此)。 + - ≠1 或记录不存在 / rfid_use_flag 为 null:state=2 记录数(精确 count,不含撤纸、不去重)。 + +## 3. 与本仓库既有约定的关系 + +- 工具类 `mcp/tool/DistrictPaperStatisticsTools.java`:@Component + ToolObject,构造器注入; + `@XqTool(category="hardware", kind="read")` + `@Tool` + `@ToolParam`;返回 LinkedHashMap 白名单拼装; + 参数错误抛 IllegalArgumentException,权限/字典/ES 错误抛 IllegalStateException。 +- 站点可见性:district_district_strategy eq id + soft_delete=0 后套 + `PermissionService.applyDistrictRowFilter`,查不到抛 `站点不存在或无权访问`(同 district.detail.get 模式)。 +- 字典读取:`redisTemplate.opsForHash().get("Dictionary", field)`,与 PermissionService 同一 redis(db5); + 缺失 fail fast。 + +## 4. ES 客户端接入(本次新增) + +- 依赖:`co.elastic.clients:elasticsearch-java:7.17.29`(pom.xml **显式钉版本**——Spring Boot 3.5.16 BOM + 管理的是 elasticsearch 8.x,老集群为 7.x 必须用 7.17 客户端)。传递引入 + `org.elasticsearch.client:elasticsearch-rest-client`(低阶)与 jakarta json。 +- 配置:`app.es.cloud.{hosts,port,username,password}` / `app.es.local.{...}`(application.yml 公共默认值, + application-dev.yml 明文 dev 值:cloud=192.168.1.152:9200、local=127.0.0.1:9200 无账号; + application-prod.yml 全部 `${SVC_ES_CLOUD_*}` / `${SVC_ES_LOCAL_*}` 环境变量占位,hosts 支持逗号多节点)。 +- `config/EsClientConfig.java`:低阶 `org.elasticsearch.client.RestClient`(HttpHost 多节点、 + BasicCredentialsProvider、connect 1000ms / socket 60000ms)注册为 bean(destroyMethod=close), + 上套 `ElasticsearchClient`(JacksonJsonpMapper,classpath 已有 jackson)两个 bean: + `esCloudClient` / `esLocalClient`。 +- `es/PaperUseLogEsGateway` 接口(便于单测 mock)+ `PaperUseLogEsGatewayImpl`: + `distinctRfidCount(...)` / `countState2(...)`,签名收 `index, localCluster, districtId, gender, + fromMillis, toMillis, deviceType, positionMode`;集群与索引选择不在实现内。 +- `es/PaperUseLogEsPlanner`:索引/集群/跨界判断纯函数(收 LocalDate),独立单测。 + +## 5. 与老系统的差异点(有意为之) + +| 点 | 老系统 | 本工具 | 理由 | +|---|---|---|---| +| ES 异常 | try/catch 打印堆栈,计数静默为 0(:299-301、:320-322) | 抛 IllegalStateException | 静默为 0 会产出"换纸 0 卷"的误导性统计;fail fast 让调用方显式感知 | +| rfid 去重实现 | count+分页拉全量文档,内存 Set 去重 | terms 聚合 size 10000 数 bucket | 不拉全量、低内存低耗时;上限 10000 对正常站点足够,超限部分不计(见 §2.2) | +| district_strategy 不存在 / rfid_use_flag=null | NPE(:324-325) | 按"未使用 rfid"口径走 state=2 count | 该表与站点 1:1,缺失视为未启用 rfid;避免 500 | +| Dictionary 字典缺失 | NPE 被外层 catch 吞掉,继续执行 | 抛 IllegalStateException | fail fast,避免权限/口径配置异常导致错误数据范围 | +| use_count 聚合 | 厕位/厕墙/runl_mode 多分支 | 直接求和 | 厕墙行恒 0,分支差异不影响结果,简化口径 | +| DB 时间过滤 | between 含两端(秒精度) | 左闭右开 [start, end+1) | 等价且避免 23:59:59 之后毫秒级数据丢行 | + +## 6. 行级权限说明 + +站点维度:`applyDistrictRowFilter`(总部全量;代理=自己+直属下级+代管授权;销售非负责人按创建范围), +查不到与无权限统一抛 `站点不存在或无权访问`,防止枚举越权站点。 +ES 侧不带用户维度过滤:换纸记录跟随站点口径,站点可见即数据可见(与老系统一致)。 + +## 7. 配置说明 + +| 配置 | dev | prod | +|---|---|---| +| app.es.cloud.hosts / port | 192.168.1.152 / 9200 | `${SVC_ES_CLOUD_HOSTS}` / `${SVC_ES_CLOUD_PORT:9200}` | +| app.es.cloud.username / password | 空(无认证) | `${SVC_ES_CLOUD_USERNAME:}` / `${SVC_ES_CLOUD_PASSWORD:}` | +| app.es.local.hosts / port | 127.0.0.1 / 9200 | `${SVC_ES_LOCAL_HOSTS}` / `${SVC_ES_LOCAL_PORT:9200}` | +| app.es.local.username / password | 空 | `${SVC_ES_LOCAL_USERNAME:}` / `${SVC_ES_LOCAL_PASSWORD:}` | + +username 为空则不设置认证(BasicCredentialsProvider 不挂载)。 + +## 8. 出参示例 + +```json +{ + "districtId": 10086, + "districtName": "示例站点", + "dateRange": {"start": "2026-09-01", "end": "2026-09-07"}, + "toiletGenders": [ + {"gender": 0, "genderText": "男"}, + {"gender": 1, "genderText": "女"} + ], + "summaryByGender": [ + { + "gender": 0, + "genderText": "男", + "paperLengthM": 1234.567, + "useCount": 890, + "perUseLengthM": 1.387, + "deviceDayCount": 42, + "rfidCount": 15 + }, + { + "gender": 1, + "genderText": "女", + "paperLengthM": 980.5, + "useCount": 0, + "perUseLengthM": null, + "deviceDayCount": 35, + "rfidCount": 11 + } + ], + "daily": [ + {"date": "2026-09-01", "gender": 0, "paperLengthM": 176.4, "useCount": 120, "deviceDayCount": 6} + ] +} +``` + +所有长度字段单位均为米(字段名带 M 后缀);`useCount=0` 时 `perUseLengthM=null`。 + +## 9. 单测 + +- `DistrictPaperStatisticsToolsTest`(12 个):参数校验(日期格式/大小关系/今天上限/positionMode/deviceType)、 + 站点不可见拒绝、字典缺失 fail fast、StatisticsDays 超限、LocalEsDate 跨界拒绝、 + cm→m 换算与 gender/按日聚合与台日口径、rfid_use_flag=1/=1/记录缺失三口径分支、 + 冷热索引选择、ES 结束时间边界(今天 00:00 vs 过去日 23:59:59.999)、positionMode/deviceType 透传。 +- `PaperUseLogEsPlannerTest`(3 个):90 天冷热索引边界、localEsDate 与 endDate 相等走云集群、 + 跨界拒绝的严格小于口径。 +- ES gateway 在工具测试中全部 mock,不依赖真实 ES。 diff --git a/xiaoqu-agent-service/pom.xml b/xiaoqu-agent-service/pom.xml index 3aed19a..e3f2bfc 100644 --- a/xiaoqu-agent-service/pom.xml +++ b/xiaoqu-agent-service/pom.xml @@ -79,6 +79,14 @@ spring-boot-starter-data-redis + + + co.elastic.clients + elasticsearch-java + 7.17.29 + + org.projectlombok lombok 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 new file mode 100644 index 0000000..23b53c6 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/config/EsClientConfig.java @@ -0,0 +1,77 @@ +package me.iiv.agent.config; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.json.jackson.JacksonJsonpMapper; +import co.elastic.clients.transport.rest_client.RestClientTransport; +import org.apache.http.HttpHost; +import org.apache.http.auth.AuthScope; +import org.apache.http.auth.UsernamePasswordCredentials; +import org.apache.http.impl.client.BasicCredentialsProvider; +import org.apache.http.impl.nio.client.HttpAsyncClientBuilder; +import org.elasticsearch.client.RestClient; +import org.elasticsearch.client.RestClientBuilder; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.StringUtils; + +import java.util.Arrays; + +/** + * 双集群 ES 客户端:cloud=云集群、local=本地化集群(老系统 Dictionary.LocalEsDate 分界)。 + * 低阶 RestClient 也注册为 bean(destroyMethod=close 随容器关闭); + * 高阶 ElasticsearchClient 包装同一 RestClient,关闭 client 即关闭底层连接。 + */ +@Configuration +public class EsClientConfig { + + @Bean(name = "esCloudRestClient", destroyMethod = "close") + public RestClient esCloudRestClient(@Value("${app.es.cloud.hosts}") String hosts, + @Value("${app.es.cloud.port:9200}") int port, + @Value("${app.es.cloud.username:}") String username, + @Value("${app.es.cloud.password:}") String password) { + return buildRestClient("cloud", hosts, port, username, password); + } + + @Bean(name = "esCloudClient") + public ElasticsearchClient esCloudClient(@Qualifier("esCloudRestClient") RestClient restClient) { + return new ElasticsearchClient(new RestClientTransport(restClient, new JacksonJsonpMapper())); + } + + @Bean(name = "esLocalRestClient", destroyMethod = "close") + public RestClient esLocalRestClient(@Value("${app.es.local.hosts}") String hosts, + @Value("${app.es.local.port:9200}") int port, + @Value("${app.es.local.username:}") String username, + @Value("${app.es.local.password:}") String password) { + return buildRestClient("local", hosts, port, username, password); + } + + @Bean(name = "esLocalClient") + public ElasticsearchClient esLocalClient(@Qualifier("esLocalRestClient") RestClient restClient) { + return new ElasticsearchClient(new RestClientTransport(restClient, new JacksonJsonpMapper())); + } + + private static RestClient buildRestClient(String cluster, String hosts, int port, + String username, String password) { + HttpHost[] httpHosts = Arrays.stream(hosts.split(",")) + .map(String::trim) + .filter(StringUtils::hasText) + .map(host -> new HttpHost(host, port)) + .toArray(HttpHost[]::new); + if (httpHosts.length == 0) { + throw new IllegalStateException("app.es." + cluster + ".hosts 未配置有效节点"); + } + RestClientBuilder builder = RestClient.builder(httpHosts) + .setRequestConfigCallback(config -> + config.setConnectTimeout(1000).setSocketTimeout(60000)); + if (StringUtils.hasText(username)) { + BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider(); + credentialsProvider.setCredentials(AuthScope.ANY, + new UsernamePasswordCredentials(username, password)); + builder.setHttpClientConfigCallback((HttpAsyncClientBuilder httpClientBuilder) -> + httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider)); + } + return builder.build(); + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/DistrictStrategy.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/DistrictStrategy.java new file mode 100644 index 0000000..35d3081 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/DistrictStrategy.java @@ -0,0 +1,36 @@ +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; + +/** + * district_strategy 站点策略(m0 库 xiaoqu_comples_d;老系统 beans/b0 走 dataSource0, + * 行 id=站点 id,与 district_district_strategy 一一对应)。 + * 本服务只取 rfid_use_flag 判断换纸卷数口径。 + */ +@Getter +@Setter +@ToString +@TableName("district_strategy") +public class DistrictStrategy implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** 站点id */ + private Long districtId; + + /** rfid使用标记(0:未使用;1:使用) */ + private Integer rfidUseFlag; + + /** 数据状态 */ + private Integer softDelete; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/MonthlyStatistics.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/MonthlyStatistics.java new file mode 100644 index 0000000..5d188ac --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/MonthlyStatistics.java @@ -0,0 +1,81 @@ +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; + +/** + * monthly_statistics 站点用纸日统计(m0 库,每站点每厕位/厕每天一行,XXL-Job 预聚合)。 + * 字段照搬老项目 xiaoqu-publicbeanandmap beans/b0/MonthlyStatistics.java。 + */ +@Getter +@Setter +@ToString +@TableName("monthly_statistics") +public class MonthlyStatistics implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** 统计时间(当天 0 点) */ + private Date statisticsTime; + + /** 数据状态 */ + private Integer softDelete; + + /** 代理商 */ + private Long agentId; + + /** 所属物业 */ + private Long propertyId; + + /** 所属区域/站点 */ + private Long districtId; + + /** 楼id */ + private Long buildingId; + + /** 楼层 */ + private String floor; + + /** 厕所id */ + private Long toiletId; + + /** 位置id(>0=厕位,=0=厕墙) */ + private Long positionId; + + /** 位置信息 */ + private String position; + + /** 性别(0男1女2母婴3残卫4第三5其他,无厕所时99) */ + private Integer gender; + + /** sn码 */ + private String snCode; + + /** 用纸长度,单位厘米 */ + private Integer paperLength; + + /** 有效使用人数(仅厕位模式有值,厕墙行恒 0) */ + private Integer useCount; + + /** 总人次 */ + private Integer totalCount; + + /** 出纸人次 */ + private Integer paperCount; + + /** 运行模式(0厕位模式) */ + private Integer runlMode; + + /** 设备类型(0出纸机1擦手纸机) */ + private Integer deviceType; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsGateway.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsGateway.java new file mode 100644 index 0000000..9155491 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsGateway.java @@ -0,0 +1,25 @@ +package me.iiv.agent.es; + +/** + * paperuselog(换纸记录)ES 查询网关。集群选择(云/本地)与索引选择(热/冷) + * 由调用方通过参数传入,本接口只做单次查询,便于单测 mock。 + */ +public interface PaperUseLogEsGateway { + + /** + * rfid 去重数:state ∈ {2上纸, 4撤纸} 合并后 rfid 去重(terms 聚合统计 bucket 数, + * size 上限 10000,超出部分不计——见设计文档)。 + * + * @param index paperuselog / paperuselog_all + * @param localCluster true=本地集群,false=云集群 + */ + long distinctRfidCount(String index, boolean localCluster, Long districtId, Integer gender, + Long fromMillis, Long toMillis, Integer deviceType, String positionMode); + + /** + * state=2(上纸)记录数(精确 count,不含 state=4、不去重)。 + * 参数含义同 {@link #distinctRfidCount}。 + */ + long countState2(String index, boolean localCluster, Long districtId, Integer gender, + Long fromMillis, Long toMillis, Integer deviceType, String positionMode); +} 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 new file mode 100644 index 0000000..32c4edc --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsGatewayImpl.java @@ -0,0 +1,97 @@ +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 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)。 + * 查询口径对齐老 MonthlyStatisticsController.getMonthlyStatisticsMap: + * term districtId/gender、range createTime [gte,lte](含边界)、deviceType 用 match(老代码字符串参数走 match), + * positionMode 厕位=positionsId>0、厕墙=positionsId=0。 + * ES 异常不静默,抛 IllegalStateException(与老系统静默为 0 不同,见设计文档)。 + */ +@Component +public class PaperUseLogEsGatewayImpl implements PaperUseLogEsGateway { + + /** rfid terms 聚合去重上限,超出部分不计入 */ + private static final int RFID_TERMS_SIZE = 10000; + + private final ElasticsearchClient cloudClient; + private final ElasticsearchClient localClient; + + public PaperUseLogEsGatewayImpl(@Qualifier("esCloudClient") ElasticsearchClient cloudClient, + @Qualifier("esLocalClient") ElasticsearchClient localClient) { + this.cloudClient = cloudClient; + this.localClient = localClient; + } + + @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); + // 上纸(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))))))); + 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(); + } catch (IOException | RuntimeException e) { + throw new IllegalStateException("ES 查询换纸 rfid 去重失败: " + e.getMessage(), e); + } + } + + @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))))); + try { + return client(localCluster).count(c -> c.index(index) + .query(q -> q.bool(b -> b.filter(filters)))).count(); + } 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))))); + // 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))))); + 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)))))); + } + if (StringUtils.hasText(positionMode)) { + if ("position".equals(positionMode)) { + filters.add(Query.of(q -> q.range(r -> r.field("positionsId").gt(JsonData.of(0))))); + } else { + filters.add(Query.of(q -> q.term(t -> t.field("positionsId").value(FieldValue.of(0))))); + } + } + return filters; + } + + private ElasticsearchClient client(boolean localCluster) { + return localCluster ? localClient : cloudClient; + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsPlanner.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsPlanner.java new file mode 100644 index 0000000..e3a5725 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsPlanner.java @@ -0,0 +1,36 @@ +package me.iiv.agent.es; + +import java.time.LocalDate; + +/** + * paperuselog 查询的索引/集群选择纯函数(老 DateToConverter.isHotTime / MonthlyStatisticsController + * 的 LocalEsDate 判断),抽成静态方法便于单测。常量与比较口径 1:1 对齐老系统。 + */ +public final class PaperUseLogEsPlanner { + + public static final String HOT_INDEX = "paperuselog"; + public static final String COLD_INDEX = "paperuselog_all"; + + /** 老 isHotTime 硬编码 90 天:startDate 距今 <90 天走热索引,否则冷索引 */ + private static final int HOT_DAYS = 90; + + private PaperUseLogEsPlanner() { + } + + /** startDate >= 今天-90天 → 热索引 paperuselog,否则 paperuselog_all */ + public static String resolveIndex(LocalDate startDate, LocalDate today) { + return startDate.isBefore(today.minusDays(HOT_DAYS)) ? COLD_INDEX : HOT_INDEX; + } + + /** localEsDate > endDate → 整个区间在本地集群 */ + public static boolean useLocalCluster(LocalDate localEsDate, LocalDate endDate) { + return localEsDate.isAfter(endDate); + } + + /** startDate < localEsDate < endDate(与老代码同口径的严格小于)→ 跨界拒绝 */ + public static void checkNotCrossingBoundary(LocalDate startDate, LocalDate endDate, LocalDate localEsDate) { + if (localEsDate.isBefore(endDate) && localEsDate.isAfter(startDate)) { + throw new IllegalArgumentException("查询范围跨越本地化数据分界日 " + localEsDate); + } + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/DistrictStrategyMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/DistrictStrategyMapper.java new file mode 100644 index 0000000..99923c4 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/DistrictStrategyMapper.java @@ -0,0 +1,7 @@ +package me.iiv.agent.mapper.m0; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m0.DistrictStrategy; + +public interface DistrictStrategyMapper extends BaseMapper { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/MonthlyStatisticsMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/MonthlyStatisticsMapper.java new file mode 100644 index 0000000..0d8173d --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/MonthlyStatisticsMapper.java @@ -0,0 +1,7 @@ +package me.iiv.agent.mapper.m0; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m0.MonthlyStatistics; + +public interface MonthlyStatisticsMapper extends BaseMapper { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/DistrictPaperStatisticsTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/DistrictPaperStatisticsTools.java new file mode 100644 index 0000000..b960b14 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/DistrictPaperStatisticsTools.java @@ -0,0 +1,311 @@ +package me.iiv.agent.mcp.tool; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import me.iiv.agent.entity.m0.DistrictDistrictStrategy; +import me.iiv.agent.entity.m0.DistrictStrategy; +import me.iiv.agent.entity.m0.MonthlyStatistics; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.es.PaperUseLogEsGateway; +import me.iiv.agent.es.PaperUseLogEsPlanner; +import me.iiv.agent.mapper.m0.DistrictDistrictStrategyMapper; +import me.iiv.agent.mapper.m0.DistrictStrategyMapper; +import me.iiv.agent.mapper.m0.MonthlyStatisticsMapper; +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.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +/** + * 客户用纸统计(老 MonthlyStatisticsController.getMonthlyStatisticsMap 复刻): + * DB 部分聚合 m0 monthly_statistics(用纸长度/人次/台日),ES 部分(paperuselog) + * 按站点策略 rfid_use_flag 分口径统计换纸卷数。 + */ +@Component +public class DistrictPaperStatisticsTools implements ToolObject { + + private static final String DICTIONARY_KEY = "Dictionary"; + private static final String DICT_LOCAL_ES_DATE = "LocalEsDate"; + private static final String DICT_STATISTICS_DAYS = "StatisticsDays"; + private static final ZoneId SHANGHAI = ZoneId.of("Asia/Shanghai"); + private static final DateTimeFormatter DAY_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + final DistrictDistrictStrategyMapper districtMapper; + final MonthlyStatisticsMapper monthlyStatisticsMapper; + final DistrictStrategyMapper districtStrategyMapper; + final PermissionService permissionService; + final StringRedisTemplate redisTemplate; + final PaperUseLogEsGateway paperUseLogEsGateway; + + public DistrictPaperStatisticsTools(DistrictDistrictStrategyMapper districtMapper, + MonthlyStatisticsMapper monthlyStatisticsMapper, + DistrictStrategyMapper districtStrategyMapper, + PermissionService permissionService, + StringRedisTemplate redisTemplate, + PaperUseLogEsGateway paperUseLogEsGateway) { + this.districtMapper = districtMapper; + this.monthlyStatisticsMapper = monthlyStatisticsMapper; + this.districtStrategyMapper = districtStrategyMapper; + this.permissionService = permissionService; + this.redisTemplate = redisTemplate; + this.paperUseLogEsGateway = paperUseLogEsGateway; + } + + @XqTool(category = "hardware", kind = "read") + @Tool(name = "district.paper.statistics.query", description = "客户用纸统计:按站点和日期范围统计各性别分组的用纸长度(米)、" + + "使用人次、台日合计与换纸卷数,并给出按日明细。主体数据来自 m0 monthly_statistics 日汇总表;" + + "换纸卷数来自 ES paperuselog(热索引近 90 天,更早走冷索引 paperuselog_all;" + + "按 Redis LocalEsDate 分界选择云/本地集群,跨界查询会被拒绝)。" + + "dateRange 最大天数受 Redis StatisticsDays 限制。所有长度字段单位均为米。") + public Map districtPaperStatisticsQuery( + @ToolParam(description = "站点 id,正整数") Long districtId, + @ToolParam(description = "开始日期,yyyy-MM-dd,含当天") String startDate, + @ToolParam(description = "结束日期,yyyy-MM-dd,含当天,不得晚于今天") String endDate, + @ToolParam(required = false, description = "厕位/厕墙过滤:position=仅厕位、wall=仅厕墙,缺省不过滤") String positionMode, + @ToolParam(required = false, description = "设备类型:0=出纸机、1=擦手纸机,缺省不过滤") Integer deviceType, + @ToolParam(required = false, description = "楼 id,正整数,精确匹配") Long buildingId, + @ToolParam(required = false, description = "楼层,精确匹配") String floor, + @ToolParam(required = false, description = "厕所 id,正整数,精确匹配") Long toiletId) { + + if (districtId == null) { + throw new IllegalArgumentException("districtId 必填"); + } + validatePositive(districtId, "districtId"); + validatePositive(buildingId, "buildingId"); + validatePositive(toiletId, "toiletId"); + LocalDate start = parseDay(startDate, "startDate"); + LocalDate end = parseDay(endDate, "endDate"); + LocalDate today = LocalDate.now(); + if (start.isAfter(end)) { + throw new IllegalArgumentException("startDate 不能晚于 endDate"); + } + if (end.isAfter(today)) { + throw new IllegalArgumentException("endDate 不得晚于今天"); + } + if (StringUtils.hasText(positionMode) && !"position".equals(positionMode) && !"wall".equals(positionMode)) { + throw new IllegalArgumentException("positionMode 仅支持 position、wall"); + } + if (deviceType != null && deviceType != 0 && deviceType != 1) { + throw new IllegalArgumentException("deviceType 仅支持 0、1"); + } + + User user = AuthContextHolder.get().getUser(); + LambdaQueryWrapper districtWrapper = new LambdaQueryWrapper() + .eq(DistrictDistrictStrategy::getSoftDelete, 0) + .eq(DistrictDistrictStrategy::getId, districtId); + permissionService.applyDistrictRowFilter(districtWrapper, user); + DistrictDistrictStrategy district = districtMapper.selectOne(districtWrapper); + if (district == null) { + throw new IllegalStateException("站点不存在或无权访问"); + } + + // 老 :121-143:LocalEsDate 集群分界 + StatisticsDays 天数上限;字典缺失 fail fast(老系统 NPE 被吞) + LocalDate localEsDate = parseDay(dictionaryValue(DICT_LOCAL_ES_DATE), DICT_LOCAL_ES_DATE); + int maxDays = Integer.parseInt(dictionaryValue(DICT_STATISTICS_DAYS).trim()); + int queryDays = (int) ChronoUnit.DAYS.between(start, end) + 1; + if (queryDays > maxDays) { + throw new IllegalArgumentException("查询时间段最大" + maxDays + "天"); + } + PaperUseLogEsPlanner.checkNotCrossingBoundary(start, end, localEsDate); + String index = PaperUseLogEsPlanner.resolveIndex(start, today); + boolean localCluster = PaperUseLogEsPlanner.useLocalCluster(localEsDate, end); + + List rows = queryMonthlyStatistics(districtId, start, end, + buildingId, floor, toiletId, deviceType); + Map byGender = aggregate(rows); + + DistrictStrategy strategy = districtStrategyMapper.selectOne(new LambdaQueryWrapper() + .eq(DistrictStrategy::getSoftDelete, 0) + .eq(DistrictStrategy::getId, districtId)); + // 老 :324-325 对不存在/空 rfidUseFlag 会 NPE;这里按"未使用 rfid"口径走 state=2 count + boolean rfidUsed = strategy != null && strategy.getRfidUseFlag() != null + && strategy.getRfidUseFlag() == 1; + + long fromMillis = start.atStartOfDay(SHANGHAI).toInstant().toEpochMilli(); + // 老 :260-268:endDate=今天 → 上界取今天 00:00(当天数据未跑完),否则 23:59:59(含边界) + LocalDate endExclusive = end.equals(today) ? end : end.plusDays(1); + long toMillis = endExclusive.atStartOfDay(SHANGHAI).toInstant().toEpochMilli() - 1; + + List> summaryByGender = new ArrayList<>(); + List> daily = new ArrayList<>(); + List> toiletGenders = new ArrayList<>(); + for (Map.Entry entry : new TreeMap<>(byGender).entrySet()) { + Integer gender = entry.getKey(); + GenderAgg agg = entry.getValue(); + long rfidCount = rfidUsed + ? paperUseLogEsGateway.distinctRfidCount(index, localCluster, districtId, gender, + fromMillis, toMillis, deviceType, positionMode) + : paperUseLogEsGateway.countState2(index, localCluster, districtId, gender, + fromMillis, toMillis, deviceType, positionMode); + + Map item = new LinkedHashMap<>(); + item.put("gender", gender); + item.put("genderText", genderText(gender)); + item.put("paperLengthM", round3(agg.paperCm / 100.0)); + item.put("useCount", agg.useCount); + item.put("perUseLengthM", agg.useCount > 0 ? round3(agg.paperCm / 100.0 / agg.useCount) : null); + item.put("deviceDayCount", agg.deviceDayCount()); + item.put("rfidCount", rfidCount); + summaryByGender.add(item); + + for (Map.Entry dayEntry : agg.daily.entrySet()) { + DailyAgg dayAgg = dayEntry.getValue(); + Map dayItem = new LinkedHashMap<>(); + dayItem.put("date", dayEntry.getKey()); + dayItem.put("gender", gender); + dayItem.put("paperLengthM", round3(dayAgg.paperCm / 100.0)); + dayItem.put("useCount", dayAgg.useCount); + dayItem.put("deviceDayCount", (long) dayAgg.positionKeys.size()); + daily.add(dayItem); + } + + Map genderItem = new LinkedHashMap<>(); + genderItem.put("gender", gender); + genderItem.put("genderText", genderText(gender)); + toiletGenders.add(genderItem); + } + + Map dateRange = new LinkedHashMap<>(); + dateRange.put("start", start.toString()); + dateRange.put("end", end.toString()); + + Map result = new LinkedHashMap<>(); + result.put("districtId", districtId); + result.put("districtName", district.getDistrictName()); + result.put("dateRange", dateRange); + result.put("toiletGenders", toiletGenders); + result.put("summaryByGender", summaryByGender); + result.put("daily", daily); + return result; + } + + private List queryMonthlyStatistics(Long districtId, LocalDate start, LocalDate end, + Long buildingId, String floor, Long toiletId, + Integer deviceType) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(MonthlyStatistics::getSoftDelete, 0) + .eq(MonthlyStatistics::getDistrictId, districtId) + .ge(MonthlyStatistics::getStatisticsTime, Date.from(start.atStartOfDay(SHANGHAI).toInstant())) + // [start 00:00:00, end 23:59:59],用左闭右开避免秒精度丢行 + .lt(MonthlyStatistics::getStatisticsTime, Date.from(end.plusDays(1).atStartOfDay(SHANGHAI).toInstant())); + if (buildingId != null) { + wrapper.eq(MonthlyStatistics::getBuildingId, buildingId); + } + if (StringUtils.hasText(floor)) { + wrapper.eq(MonthlyStatistics::getFloor, floor); + } + if (toiletId != null) { + wrapper.eq(MonthlyStatistics::getToiletId, toiletId); + } + if (deviceType != null) { + wrapper.eq(MonthlyStatistics::getDeviceType, deviceType); + } + return monthlyStatisticsMapper.selectList(wrapper); + } + + /** 老 :176-249 聚合:gender 分组 + 按日分组;台日=每天 distinct 厕位/厕数之和(positionSet) */ + private Map aggregate(List rows) { + Map byGender = new LinkedHashMap<>(); + for (MonthlyStatistics row : rows) { + if (row.getGender() == null) { + continue; + } + GenderAgg agg = byGender.computeIfAbsent(row.getGender(), gender -> new GenderAgg()); + int paperLength = row.getPaperLength() == null ? 0 : row.getPaperLength(); + int useCount = row.getUseCount() == null ? 0 : row.getUseCount(); + agg.paperCm += paperLength; + agg.useCount += useCount; + String day = DAY_FORMAT.format(row.getStatisticsTime().toInstant().atZone(SHANGHAI)); + DailyAgg dailyAgg = agg.daily.computeIfAbsent(day, key -> new DailyAgg()); + dailyAgg.paperCm += paperLength; + dailyAgg.useCount += useCount; + // 厕位行按 positionId、厕墙行按 toiletId 计台(老 "p_"/"t_" positionSet) + if (row.getPositionId() != null && row.getPositionId() > 0) { + dailyAgg.positionKeys.add("p_" + row.getPositionId()); + } else { + dailyAgg.positionKeys.add("t_" + row.getToiletId()); + } + } + return byGender; + } + + private String dictionaryValue(String field) { + Object value = redisTemplate.opsForHash().get(DICTIONARY_KEY, field); + if (value == null) { + throw new IllegalStateException("Redis Dictionary 缺少 " + field + " 字段,拒绝查询"); + } + return value.toString(); + } + + private static LocalDate parseDay(String value, String name) { + if (!StringUtils.hasText(value)) { + throw new IllegalArgumentException(name + " 必填,格式 yyyy-MM-dd"); + } + try { + return LocalDate.parse(value.trim()); + } catch (java.time.format.DateTimeParseException e) { + throw new IllegalArgumentException(name + " 格式非法,要求 yyyy-MM-dd: " + value); + } + } + + private static void validatePositive(Long value, String name) { + if (value != null && value < 1) { + throw new IllegalArgumentException(name + " 必须为正整数"); + } + } + + private static double round3(double value) { + return BigDecimal.valueOf(value).setScale(3, RoundingMode.HALF_UP).doubleValue(); + } + + private static String genderText(Integer gender) { + if (gender == null) { + return "未知"; + } + switch (gender) { + case 0: return "男"; + case 1: return "女"; + case 2: return "母婴"; + case 3: return "残卫"; + case 4: return "第三"; + case 5: return "其他"; + case 99: return "未知"; + default: return "未知"; + } + } + + private static final class GenderAgg { + long paperCm; + long useCount; + final Map daily = new TreeMap<>(); + + long deviceDayCount() { + return daily.values().stream().mapToLong(day -> day.positionKeys.size()).sum(); + } + } + + private static final class DailyAgg { + long paperCm; + long useCount; + final Set positionKeys = new LinkedHashSet<>(); + } +} diff --git a/xiaoqu-agent-service/src/main/resources/application-dev.yml b/xiaoqu-agent-service/src/main/resources/application-dev.yml index 168b17c..4c05bdc 100644 --- a/xiaoqu-agent-service/src/main/resources/application-dev.yml +++ b/xiaoqu-agent-service/src/main/resources/application-dev.yml @@ -36,6 +36,14 @@ app: jdbc-url: jdbc:mysql://192.168.1.181:3306/xiaoqu_agent_svc_d?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: kaixinjiuhao + # ES 双集群(老 dev 值):cloud=云集群、local=本地化集群;无账号 + es: + cloud: + hosts: 192.168.1.152 + port: 9200 + local: + hosts: 127.0.0.1 + port: 9200 spring: data: diff --git a/xiaoqu-agent-service/src/main/resources/application-prod.yml b/xiaoqu-agent-service/src/main/resources/application-prod.yml index 98fe102..bc71d8e 100644 --- a/xiaoqu-agent-service/src/main/resources/application-prod.yml +++ b/xiaoqu-agent-service/src/main/resources/application-prod.yml @@ -27,6 +27,18 @@ app: jdbc-url: ${SVC_DB_SVC_URL} username: ${SVC_DB_SVC_USER} password: ${SVC_DB_SVC_PASSWORD} + # ES 双集群:cloud=云集群、local=本地化集群(hosts 支持逗号多节点) + es: + cloud: + hosts: ${SVC_ES_CLOUD_HOSTS} + port: ${SVC_ES_CLOUD_PORT:9200} + username: ${SVC_ES_CLOUD_USERNAME:} + password: ${SVC_ES_CLOUD_PASSWORD:} + local: + hosts: ${SVC_ES_LOCAL_HOSTS} + port: ${SVC_ES_LOCAL_PORT:9200} + username: ${SVC_ES_LOCAL_USERNAME:} + password: ${SVC_ES_LOCAL_PASSWORD:} spring: data: diff --git a/xiaoqu-agent-service/src/main/resources/application.yml b/xiaoqu-agent-service/src/main/resources/application.yml index cdd0471..91a2b6e 100644 --- a/xiaoqu-agent-service/src/main/resources/application.yml +++ b/xiaoqu-agent-service/src/main/resources/application.yml @@ -32,6 +32,19 @@ app: rate-limit: query-per-minute: ${QUERY_RATE_LIMIT_PER_MINUTE:600} sync-per-minute: ${SYNC_RATE_LIMIT_PER_MINUTE:120} + # 双集群 ES(paperuselog 换纸记录):cloud=云集群、local=本地化集群; + # hosts 支持逗号多节点,username 为空 = 不设认证;各环境在 application-{env}.yml 覆盖 + es: + cloud: + hosts: ${SVC_ES_CLOUD_HOSTS:} + port: ${SVC_ES_CLOUD_PORT:9200} + username: ${SVC_ES_CLOUD_USERNAME:} + password: ${SVC_ES_CLOUD_PASSWORD:} + local: + hosts: ${SVC_ES_LOCAL_HOSTS:} + port: ${SVC_ES_LOCAL_PORT:9200} + username: ${SVC_ES_LOCAL_USERNAME:} + password: ${SVC_ES_LOCAL_PASSWORD:} spring: application: diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/es/PaperUseLogEsPlannerTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/es/PaperUseLogEsPlannerTest.java new file mode 100644 index 0000000..0c0de64 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/es/PaperUseLogEsPlannerTest.java @@ -0,0 +1,51 @@ +package me.iiv.agent.es; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PaperUseLogEsPlannerTest { + + private static final LocalDate TODAY = LocalDate.of(2026, 9, 20); + + @Test + void hotIndexWhenStartWithin90Days() { + assertEquals("paperuselog", PaperUseLogEsPlanner.resolveIndex(TODAY.minusDays(90), TODAY)); + assertEquals("paperuselog", PaperUseLogEsPlanner.resolveIndex(TODAY.minusDays(6), TODAY)); + assertEquals("paperuselog_all", PaperUseLogEsPlanner.resolveIndex(TODAY.minusDays(91), TODAY)); + assertEquals("paperuselog_all", PaperUseLogEsPlanner.resolveIndex(TODAY.minusDays(365), TODAY)); + } + + @Test + void localClusterOnlyWhenLocalEsDateAfterEndDate() { + assertTrue(PaperUseLogEsPlanner.useLocalCluster(LocalDate.of(2026, 9, 21), TODAY)); + // 与老代码一致:localEsDate == endDate 走云集群 + assertFalse(PaperUseLogEsPlanner.useLocalCluster(TODAY, TODAY)); + assertFalse(PaperUseLogEsPlanner.useLocalCluster(TODAY.minusDays(30), TODAY)); + } + + @Test + void crossingBoundaryRejectedOnlyWhenStrictlyInside() { + LocalDate start = TODAY.minusDays(6); + LocalDate boundary = TODAY.minusDays(3); + LocalDate end = TODAY; + + // start < localEsDate < end → 拒绝 + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> PaperUseLogEsPlanner.checkNotCrossingBoundary(start, end, boundary)); + assertEquals("查询范围跨越本地化数据分界日 " + boundary, exception.getMessage()); + + // localEsDate == startDate:老口径不拒绝(localEsDate > startDay 为 false) + PaperUseLogEsPlanner.checkNotCrossingBoundary(boundary, end, boundary); + // localEsDate == endDate:不拒绝且走云集群 + PaperUseLogEsPlanner.checkNotCrossingBoundary(start, boundary, boundary); + // 整段在分界日之前/之后 + PaperUseLogEsPlanner.checkNotCrossingBoundary(start, end, TODAY.plusDays(1)); + PaperUseLogEsPlanner.checkNotCrossingBoundary(start, end, start.minusDays(1)); + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/DistrictPaperStatisticsToolsTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/DistrictPaperStatisticsToolsTest.java new file mode 100644 index 0000000..80be52f --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/DistrictPaperStatisticsToolsTest.java @@ -0,0 +1,376 @@ +package me.iiv.agent.mcp.tool; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import me.iiv.agent.entity.m0.DistrictDistrictStrategy; +import me.iiv.agent.entity.m0.DistrictStrategy; +import me.iiv.agent.entity.m0.MonthlyStatistics; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.es.PaperUseLogEsGateway; +import me.iiv.agent.mapper.m0.DistrictDistrictStrategyMapper; +import me.iiv.agent.mapper.m0.DistrictStrategyMapper; +import me.iiv.agent.mapper.m0.MonthlyStatisticsMapper; +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 org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Date; +import java.util.List; +import java.util.Map; + +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.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DistrictPaperStatisticsToolsTest { + + private static final ZoneId SH = ZoneId.of("Asia/Shanghai"); + + @Mock + DistrictDistrictStrategyMapper districtMapper; + @Mock + MonthlyStatisticsMapper monthlyStatisticsMapper; + @Mock + DistrictStrategyMapper districtStrategyMapper; + @Mock + PermissionService permissionService; + @Mock + StringRedisTemplate redisTemplate; + @Mock + HashOperations hashOperations; + @Mock + PaperUseLogEsGateway esGateway; + + DistrictPaperStatisticsTools tools; + LocalDate today; + String start; + String end; + + @BeforeEach + void setUp() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), + DistrictDistrictStrategy.class); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), + MonthlyStatistics.class); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), + DistrictStrategy.class); + tools = new DistrictPaperStatisticsTools(districtMapper, monthlyStatisticsMapper, + districtStrategyMapper, permissionService, redisTemplate, esGateway); + User user = new User(); + user.setId(7L); + AuthContextHolder.set(new AuthContext(null, user)); + today = LocalDate.now(); + start = today.minusDays(6).toString(); + end = today.toString(); + } + + @AfterEach + void tearDown() { + AuthContextHolder.clear(); + } + + private void mockDistrictAndDictionary() { + DistrictDistrictStrategy district = new DistrictDistrictStrategy(); + district.setId(9L); + district.setDistrictName("测试站点"); + when(districtMapper.selectOne(any())).thenReturn(district); + lenient().when(redisTemplate.opsForHash()).thenReturn(hashOperations); + when(hashOperations.get("Dictionary", "LocalEsDate")).thenReturn("2020-01-01"); + when(hashOperations.get("Dictionary", "StatisticsDays")).thenReturn("31"); + } + + private static MonthlyStatistics row(LocalDate day, Long positionId, Long toiletId, + Integer gender, int paperLengthCm, int useCount) { + MonthlyStatistics row = new MonthlyStatistics(); + row.setId(1L); + row.setSoftDelete(0); + row.setDistrictId(9L); + row.setStatisticsTime(Date.from(day.atStartOfDay(SH).toInstant())); + row.setGender(gender); + row.setPositionId(positionId); + row.setToiletId(toiletId); + row.setPaperLength(paperLengthCm); + row.setUseCount(useCount); + return row; + } + + @Test + void rejectsInvalidArgumentsBeforeAnyQuery() { + assertThrows(IllegalArgumentException.class, () -> tools.districtPaperStatisticsQuery( + null, start, end, null, null, null, null, null)); + assertThrows(IllegalArgumentException.class, () -> tools.districtPaperStatisticsQuery( + 0L, start, end, null, null, null, null, null)); + assertThrows(IllegalArgumentException.class, () -> tools.districtPaperStatisticsQuery( + 9L, "2026/09/01", end, null, null, null, null, null)); + assertThrows(IllegalArgumentException.class, () -> tools.districtPaperStatisticsQuery( + 9L, start, end, "bad", null, null, null, null)); + assertThrows(IllegalArgumentException.class, () -> tools.districtPaperStatisticsQuery( + 9L, start, end, null, 2, null, null, null)); + assertThrows(IllegalArgumentException.class, () -> tools.districtPaperStatisticsQuery( + 9L, end, start, null, null, null, null, null)); + assertThrows(IllegalArgumentException.class, () -> tools.districtPaperStatisticsQuery( + 9L, start, today.plusDays(1).toString(), null, null, null, null, null)); + verifyNoInteractions(districtMapper, monthlyStatisticsMapper, redisTemplate, esGateway); + } + + @Test + void rejectsInvisibleDistrict() { + when(districtMapper.selectOne(any())).thenReturn(null); + + IllegalStateException exception = assertThrows(IllegalStateException.class, + () -> tools.districtPaperStatisticsQuery(9L, start, end, null, null, null, null, null)); + assertEquals("站点不存在或无权访问", exception.getMessage()); + verify(permissionService).applyDistrictRowFilter(any(), any()); + verifyNoInteractions(monthlyStatisticsMapper, esGateway); + } + + @Test + void failsFastWhenDictionaryMissing() { + DistrictDistrictStrategy district = new DistrictDistrictStrategy(); + district.setId(9L); + when(districtMapper.selectOne(any())).thenReturn(district); + when(redisTemplate.opsForHash()).thenReturn(hashOperations); + when(hashOperations.get("Dictionary", "LocalEsDate")).thenReturn(null); + + assertThrows(IllegalStateException.class, + () -> tools.districtPaperStatisticsQuery(9L, start, end, null, null, null, null, null)); + verifyNoInteractions(monthlyStatisticsMapper, esGateway); + } + + @Test + void rejectsRangeExceedingStatisticsDays() { + mockDistrictAndDictionary(); + when(hashOperations.get("Dictionary", "StatisticsDays")).thenReturn("5"); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> tools.districtPaperStatisticsQuery(9L, start, end, null, null, null, null, null)); + assertEquals("查询时间段最大5天", exception.getMessage()); + verifyNoInteractions(monthlyStatisticsMapper, esGateway); + } + + @Test + void rejectsRangeCrossingLocalEsDateBoundary() { + mockDistrictAndDictionary(); + when(hashOperations.get("Dictionary", "LocalEsDate")).thenReturn(today.minusDays(3).toString()); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> tools.districtPaperStatisticsQuery(9L, start, end, null, null, null, null, null)); + assertTrue(exception.getMessage().startsWith("查询范围跨越本地化数据分界日")); + verifyNoInteractions(monthlyStatisticsMapper, esGateway); + } + + @Test + void aggregatesByGenderAndDayWithMeterConversion() { + mockDistrictAndDictionary(); + LocalDate day1 = today.minusDays(2); + LocalDate day2 = today.minusDays(1); + when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of( + row(day1, 11L, 3L, 0, 100, 2), + row(day1, 12L, 3L, 0, 50, 1), + row(day2, 0L, 7L, 0, 50, 0), + row(day1, 21L, 5L, 1, 200, 4))); + DistrictStrategy strategy = new DistrictStrategy(); + strategy.setId(9L); + strategy.setRfidUseFlag(1); + when(districtStrategyMapper.selectOne(any())).thenReturn(strategy); + when(esGateway.distinctRfidCount(anyString(), anyBoolean(), anyLong(), anyInt(), anyLong(), anyLong(), + any(), any())).thenReturn(8L); + + Map result = tools.districtPaperStatisticsQuery(9L, start, end, null, null, null, null, null); + + assertEquals(9L, result.get("districtId")); + assertEquals("测试站点", result.get("districtName")); + @SuppressWarnings("unchecked") + List> summary = (List>) result.get("summaryByGender"); + assertEquals(2, summary.size()); + + Map male = summary.get(0); + assertEquals(0, male.get("gender")); + assertEquals("男", male.get("genderText")); + assertEquals(2.0, male.get("paperLengthM")); + assertEquals(3L, male.get("useCount")); + assertEquals(0.667, male.get("perUseLengthM")); + // 台日=每天 distinct 厕位/厕数之和:day1 两个厕位 + day2 一个厕墙 = 3 + assertEquals(3L, male.get("deviceDayCount")); + assertEquals(8L, male.get("rfidCount")); + + Map female = summary.get(1); + assertEquals(1, female.get("gender")); + assertEquals(0.5, female.get("perUseLengthM")); + assertEquals(1L, female.get("deviceDayCount")); + + @SuppressWarnings("unchecked") + List> daily = (List>) result.get("daily"); + assertEquals(3, daily.size()); + Map day1Item = daily.get(0); + assertEquals(day1.toString(), day1Item.get("date")); + assertEquals(1.5, day1Item.get("paperLengthM")); + assertEquals(3L, day1Item.get("useCount")); + assertEquals(2L, day1Item.get("deviceDayCount")); + + @SuppressWarnings("unchecked") + List> genders = (List>) result.get("toiletGenders"); + assertEquals(List.of(0, 1), genders.stream().map(g -> g.get("gender")).toList()); + + verify(esGateway).distinctRfidCount(eq("paperuselog"), eq(false), eq(9L), eq(0), + anyLong(), anyLong(), any(), any()); + verify(esGateway).distinctRfidCount(eq("paperuselog"), eq(false), eq(9L), eq(1), + anyLong(), anyLong(), any(), any()); + verify(esGateway, never()).countState2(anyString(), anyBoolean(), anyLong(), anyInt(), + anyLong(), anyLong(), any(), any()); + } + + @Test + void nonRfidStrategyUsesState2Count() { + mockDistrictAndDictionary(); + when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of( + row(today.minusDays(1), 11L, 3L, 0, 100, 2))); + DistrictStrategy strategy = new DistrictStrategy(); + strategy.setId(9L); + strategy.setRfidUseFlag(0); + when(districtStrategyMapper.selectOne(any())).thenReturn(strategy); + when(esGateway.countState2(anyString(), anyBoolean(), anyLong(), anyInt(), anyLong(), anyLong(), + any(), any())).thenReturn(5L); + + Map result = tools.districtPaperStatisticsQuery(9L, start, end, null, null, null, null, null); + + @SuppressWarnings("unchecked") + List> summary = (List>) result.get("summaryByGender"); + assertEquals(5L, summary.get(0).get("rfidCount")); + verify(esGateway).countState2(eq("paperuselog"), eq(false), eq(9L), eq(0), + anyLong(), anyLong(), any(), any()); + verify(esGateway, never()).distinctRfidCount(anyString(), anyBoolean(), anyLong(), anyInt(), + anyLong(), anyLong(), any(), any()); + } + + @Test + void missingStrategyFallsBackToState2Count() { + mockDistrictAndDictionary(); + when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of( + row(today.minusDays(1), 11L, 3L, 0, 100, 2))); + when(districtStrategyMapper.selectOne(any())).thenReturn(null); + when(esGateway.countState2(anyString(), anyBoolean(), anyLong(), anyInt(), anyLong(), anyLong(), + any(), any())).thenReturn(0L); + + tools.districtPaperStatisticsQuery(9L, start, end, null, null, null, null, null); + + verify(esGateway).countState2(anyString(), anyBoolean(), anyLong(), anyInt(), + anyLong(), anyLong(), any(), any()); + verify(esGateway, never()).distinctRfidCount(anyString(), anyBoolean(), anyLong(), anyInt(), + anyLong(), anyLong(), any(), any()); + } + + @Test + void useCountZeroYieldsNullPerUseLength() { + mockDistrictAndDictionary(); + when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of( + row(today.minusDays(1), 0L, 7L, 99, 80, 0))); + when(districtStrategyMapper.selectOne(any())).thenReturn(null); + when(esGateway.countState2(anyString(), anyBoolean(), anyLong(), anyInt(), anyLong(), anyLong(), + any(), any())).thenReturn(0L); + + Map result = tools.districtPaperStatisticsQuery(9L, start, end, null, null, null, null, null); + + @SuppressWarnings("unchecked") + List> summary = (List>) result.get("summaryByGender"); + assertEquals(0.8, summary.get(0).get("paperLengthM")); + assertNull(summary.get(0).get("perUseLengthM")); + assertEquals("未知", summary.get(0).get("genderText")); + } + + @Test + void coldIndexUsedWhenStartOlderThan90Days() { + mockDistrictAndDictionary(); + when(hashOperations.get("Dictionary", "StatisticsDays")).thenReturn("365"); + String coldStart = today.minusDays(120).toString(); + when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of( + row(today.minusDays(100), 11L, 3L, 0, 100, 2))); + when(districtStrategyMapper.selectOne(any())).thenReturn(null); + when(esGateway.countState2(anyString(), anyBoolean(), anyLong(), anyInt(), anyLong(), anyLong(), + any(), any())).thenReturn(0L); + + tools.districtPaperStatisticsQuery(9L, coldStart, end, null, null, null, null, null); + + verify(esGateway).countState2(eq("paperuselog_all"), eq(false), anyLong(), anyInt(), + anyLong(), anyLong(), any(), any()); + } + + @Test + void endBoundaryExcludesTodayButKeepsFullPastDays() { + mockDistrictAndDictionary(); + LocalDate pastEnd = today.minusDays(1); + when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of( + row(pastEnd, 11L, 3L, 0, 100, 2))); + when(districtStrategyMapper.selectOne(any())).thenReturn(null); + when(esGateway.countState2(anyString(), anyBoolean(), anyLong(), anyInt(), anyLong(), anyLong(), + any(), any())).thenReturn(0L); + + // endDate=今天 → 上界取今天 00:00(毫秒-1) + tools.districtPaperStatisticsQuery(9L, start, end, null, null, null, null, null); + // endDate=过去某天 → 上界取当天 23:59:59.999 + tools.districtPaperStatisticsQuery(9L, start, pastEnd.toString(), null, null, null, null, null); + + ArgumentCaptor toCaptor = ArgumentCaptor.forClass(Long.class); + verify(esGateway, org.mockito.Mockito.times(2)).countState2(anyString(), anyBoolean(), anyLong(), anyInt(), + anyLong(), toCaptor.capture(), any(), any()); + assertEquals(List.of( + today.atStartOfDay(SH).toInstant().toEpochMilli() - 1, + pastEnd.plusDays(1).atStartOfDay(SH).toInstant().toEpochMilli() - 1), toCaptor.getAllValues()); + } + + @Test + void springAiPublishesToolSchema() throws Exception { + org.springframework.ai.tool.ToolCallback[] callbacks = org.springframework.ai.tool.method.MethodToolCallbackProvider + .builder().toolObjects(tools).build().getToolCallbacks(); + assertEquals(1, callbacks.length); + assertEquals("district.paper.statistics.query", + callbacks[0].getToolDefinition().name()); + String schema = callbacks[0].getToolDefinition().inputSchema(); + assertTrue(schema.contains("districtId")); + assertTrue(schema.contains("startDate")); + assertTrue(new com.fasterxml.jackson.databind.ObjectMapper().readTree(schema) + .path("required").toString().contains("\"districtId\"")); + } + + @Test + void passesPositionModeAndDeviceTypeThroughToEs() { + mockDistrictAndDictionary(); + when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of( + row(today.minusDays(1), 11L, 3L, 0, 100, 2))); + DistrictStrategy strategy = new DistrictStrategy(); + strategy.setRfidUseFlag(1); + when(districtStrategyMapper.selectOne(any())).thenReturn(strategy); + when(esGateway.distinctRfidCount(anyString(), anyBoolean(), anyLong(), anyInt(), anyLong(), anyLong(), + any(), any())).thenReturn(1L); + + tools.districtPaperStatisticsQuery(9L, start, end, "position", 1, null, null, null); + + verify(esGateway).distinctRfidCount(eq("paperuselog"), eq(false), eq(9L), eq(0), + anyLong(), anyLong(), eq(1), eq("position")); + } +} -- 2.54.0 From 47d5e8d545d649732cc29ab2c3ac30498ea3adba Mon Sep 17 00:00:00 2001 From: my_local_kimi_dev2 Date: Sun, 20 Sep 2026 18:53:51 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E7=94=A8=E7=BA=B8?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=20useCount=20=E5=8F=A3=E5=BE=84=EF=BC=9A?= =?UTF-8?q?=E4=BB=85=E5=8E=95=E4=BD=8D=E6=A8=A1=E5=BC=8F(runl=5Fmode=3D0)?= =?UTF-8?q?=E5=8E=95=E4=BD=8D=E8=A1=8C=E7=B4=AF=E8=AE=A1=EF=BC=8C=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=E8=80=81=E4=BB=A3=E7=A0=81=E6=84=8F=E5=9B=BE=E5=B9=B6?= =?UTF-8?q?=E6=B6=88=E9=99=A4=E5=85=B6=E5=88=86=E6=94=AF=E4=B8=8D=E4=B8=80?= =?UTF-8?q?=E8=87=B4=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../doc/agent-mcp-tools-reference.md | 2 +- .../doc/district-paper-statistics-design.md | 6 ++--- .../tool/DistrictPaperStatisticsTools.java | 14 +++++++++--- .../DistrictPaperStatisticsToolsTest.java | 22 +++++++++++++++++++ 4 files changed, 37 insertions(+), 7 deletions(-) diff --git a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md index 78e1886..b131a2c 100644 --- a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md +++ b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md @@ -755,7 +755,7 @@ - 入参:`districtId`(必填正整数)、`startDate`/`endDate`(必填 yyyy-MM-dd,startDate≤endDate≤今天)、 `positionMode`(position=仅厕位/wall=仅厕墙,只作用于 ES 换纸卷数口径)、`deviceType`(0/1)、`buildingId`、`floor`、`toiletId`。 - 聚合维度 gender(0男1女2母婴3残卫4第三5其他99未知): - `paperLengthM`=Σpaper_length/100(米,3 位小数)、`useCount`=Σuse_count(厕墙行恒 0 直接求和)、 + `paperLengthM`=Σpaper_length/100(米,3 位小数)、`useCount`=Σuse_count(仅厕位模式 runl_mode=0 的厕位行累计,厕墙行恒 0)、 `deviceDayCount`=按日 distinct 厕位/厕数之和(老系统 perPositionsCount,输出改名)、 `perUseLengthM`=paperLengthM/useCount(useCount=0 为 null);另输出按日明细(date/gender/paperLengthM/useCount/deviceDayCount)。 - 换纸卷数 `rfidCount`(ES):读 `district_strategy.rfid_use_flag`(行 id=站点 id)—— diff --git a/xiaoqu-agent-service/doc/district-paper-statistics-design.md b/xiaoqu-agent-service/doc/district-paper-statistics-design.md index dd5d366..771ff8d 100644 --- a/xiaoqu-agent-service/doc/district-paper-statistics-design.md +++ b/xiaoqu-agent-service/doc/district-paper-statistics-design.md @@ -19,8 +19,8 @@ (实现为左闭右开 `[start, end+1)` 避免秒精度丢行);可选 `building_id/floor/toilet_id/device_type` 精确匹配。 - 聚合维度 `gender`(gender 为 null 的行跳过,老 :178-180): - `paperLengthM` = Σpaper_length / 100(paper_length 单位厘米,输出米,3 位小数); - - `useCount` = Σuse_count(老代码对厕位/厕墙、runl_mode 有多个分支,但厕墙行 use_count 恒 0, - 等价于直接求和——老 :188-203 分支差异不影响结果); + - `useCount` = Σuse_count(**仅 position_id>0 且 runl_mode=0 的厕位模式行累计**,厕墙行恒 0; + 这是老 :200-203、:220-222 分支的意图——老 :188 的日分组已存在分支漏判 runlMode,按意图统一); - `deviceDayCount` = 按日 distinct 厕位/厕数之和(老 :190-192、:249:positionSet 存 `p_{positionId}`(厕位行)与 `t_{toiletId}`(厕墙行),每天取 set size 再累加)。 老出参字段名 perPositionsCount 注释有误,本工具改名 deviceDayCount); @@ -91,7 +91,7 @@ | rfid 去重实现 | count+分页拉全量文档,内存 Set 去重 | terms 聚合 size 10000 数 bucket | 不拉全量、低内存低耗时;上限 10000 对正常站点足够,超限部分不计(见 §2.2) | | district_strategy 不存在 / rfid_use_flag=null | NPE(:324-325) | 按"未使用 rfid"口径走 state=2 count | 该表与站点 1:1,缺失视为未启用 rfid;避免 500 | | Dictionary 字典缺失 | NPE 被外层 catch 吞掉,继续执行 | 抛 IllegalStateException | fail fast,避免权限/口径配置异常导致错误数据范围 | -| use_count 聚合 | 厕位/厕墙/runl_mode 多分支 | 直接求和 | 厕墙行恒 0,分支差异不影响结果,简化口径 | +| use_count 聚合 | 厕位/厕墙/runl_mode 多分支,:188 已存在日分组分支漏判 runlMode | 仅 position_id>0 且 runl_mode=0 行累计 | 统一为老代码新分组分支的意图口径(厕位模式使用人次),消除老代码分支不一致的 bug | | DB 时间过滤 | between 含两端(秒精度) | 左闭右开 [start, end+1) | 等价且避免 23:59:59 之后毫秒级数据丢行 | ## 6. 行级权限说明 diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/DistrictPaperStatisticsTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/DistrictPaperStatisticsTools.java index b960b14..aea5e1a 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/DistrictPaperStatisticsTools.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/DistrictPaperStatisticsTools.java @@ -233,13 +233,21 @@ public class DistrictPaperStatisticsTools implements ToolObject { int paperLength = row.getPaperLength() == null ? 0 : row.getPaperLength(); int useCount = row.getUseCount() == null ? 0 : row.getUseCount(); agg.paperCm += paperLength; - agg.useCount += useCount; + boolean positionRow = row.getPositionId() != null && row.getPositionId() > 0; + // useCount 仅厕位模式(runl_mode=0)的厕位行累计,厕墙恒 0(老 :200-203/220-222 意图; + // 老代码日分组已存在分支漏判 runlMode,此处按意图统一,见设计文档差异点) + boolean countUse = positionRow && row.getRunlMode() != null && row.getRunlMode() == 0; + if (countUse) { + agg.useCount += useCount; + } String day = DAY_FORMAT.format(row.getStatisticsTime().toInstant().atZone(SHANGHAI)); DailyAgg dailyAgg = agg.daily.computeIfAbsent(day, key -> new DailyAgg()); dailyAgg.paperCm += paperLength; - dailyAgg.useCount += useCount; + if (countUse) { + dailyAgg.useCount += useCount; + } // 厕位行按 positionId、厕墙行按 toiletId 计台(老 "p_"/"t_" positionSet) - if (row.getPositionId() != null && row.getPositionId() > 0) { + if (positionRow) { dailyAgg.positionKeys.add("p_" + row.getPositionId()); } else { dailyAgg.positionKeys.add("t_" + row.getToiletId()); diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/DistrictPaperStatisticsToolsTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/DistrictPaperStatisticsToolsTest.java index 80be52f..b264ba1 100644 --- a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/DistrictPaperStatisticsToolsTest.java +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/DistrictPaperStatisticsToolsTest.java @@ -116,6 +116,7 @@ class DistrictPaperStatisticsToolsTest { row.setToiletId(toiletId); row.setPaperLength(paperLengthCm); row.setUseCount(useCount); + row.setRunlMode(0); return row; } @@ -285,6 +286,27 @@ class DistrictPaperStatisticsToolsTest { anyLong(), anyLong(), any(), any()); } + @Test + void useCountOnlyAccumulatesForPositionModeRows() { + mockDistrictAndDictionary(); + MonthlyStatistics wallModeRow = row(today.minusDays(1), 11L, 3L, 0, 100, 5); + wallModeRow.setRunlMode(1); + when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of( + wallModeRow, + row(today.minusDays(1), 11L, 3L, 0, 100, 2))); + when(districtStrategyMapper.selectOne(any())).thenReturn(null); + when(esGateway.countState2(anyString(), anyBoolean(), anyLong(), anyInt(), anyLong(), anyLong(), + any(), any())).thenReturn(0L); + + Map result = tools.districtPaperStatisticsQuery(9L, start, end, null, null, null, null, null); + + @SuppressWarnings("unchecked") + List> summary = (List>) result.get("summaryByGender"); + // runl_mode=1 的厕位行 useCount 不计入,仅厕位模式(runl_mode=0)累计 + assertEquals(2L, summary.get(0).get("useCount")); + assertEquals(2.0, summary.get(0).get("paperLengthM")); + } + @Test void useCountZeroYieldsNullPerUseLength() { mockDistrictAndDictionary(); -- 2.54.0