10 KiB
10 KiB
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
Dictionaryhash 的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、termgender(每组 gender 各查一次,与老 :272 一致); - range
createTime[gte, lte] 含边界; deviceType非空时 matchdeviceType(老代码该参数为字符串走 matchQuery);positionMode:position → rangepositionsId > 0;wall → termpositionsId = 0;不传不加。
- term
- 口径分支:读 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,不含撤纸、不去重)。
- =1(使用 rfid):state ∈ {2上纸, 4撤纸} 合并后 rfid 去重数。实现用 terms 聚合
(field
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. 出参示例
{
"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。