162 lines
10 KiB
Markdown
162 lines
10 KiB
Markdown
# 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(**仅 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);
|
||
- `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 多分支,:188 已存在日分组分支漏判 runlMode | 仅 position_id>0 且 runl_mode=0 行累计 | 统一为老代码新分组分支的意图口径(厕位模式使用人次),消除老代码分支不一致的 bug |
|
||
| 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。
|