Files
xiaoqu_mcp_service/xiaoqu-agent-service/doc/district-paper-statistics-design.md
T

10 KiB
Raw Blame History

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. 出参示例

{
  "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。