feat(mcp): 新增人流量统计工具,统计类工具查询区间统一限一个月

- district.traffic.statistics.query:复刻老 trafficStatistics 饼图与
  trafficStatisticsDetail 折线图,SUM(use_count) 按 gender/日期×gender 聚合,
  view=gender 占比 + view=daily_gender 按日趋势零填充(修正老系统插入错位 bug)
- 行级权限对齐 district.paper.* 系列;gender=null 归「未配置」不再 NPE
- 日期区间硬限 31 天(一个月),超出报错提示修改日期(2026-09-24 拍板);
  district.paper.percapita.query 与 district.paper.device_average.query 同步加限,
  与 Redis StatisticsDays 取更严
- 新增 12 个单测;设计文档 doc/traffic-statistics-mcp-tool-design.md
This commit is contained in:
wangheng
2026-09-24 16:22:10 +08:00
parent 0e7c7e778b
commit e41ecf9ea1
12 changed files with 826 additions and 6 deletions
@@ -835,7 +835,7 @@
未参与的维度为 null;无数据日期或分组不补零。`daily=false` 另返回区间 `totalPerCapita`。
- gender 文案:0男卫、1女卫、2母婴室、3无障碍、4第三卫、5其他。
- 行级权限:先用 `applyDistrictRowFilter` 校验站点可见性;不可见统一返回“站点不存在或无权访问”。
- 日期不得晚于今天,查询天数受 Redis `Dictionary.StatisticsDays` 限制。
- 日期不得晚于今天;查询区间硬限 31 天(一个月,2026-09-24 拍板),且受 Redis `Dictionary.StatisticsDays` 限制,两者取更严。
- scope:仅绑定 `service_enabled=0` 的用户委托作用域,不向服务身份作用域开放。
详细设计见 `paper-percapita-statistics-mcp-design.md`。
@@ -861,7 +861,7 @@
- gender 文案:0男卫、1女卫、2母婴室、3无障碍、4第三卫、5其他;null 为未配置,其他整数显示 `未知(code)`。
- 行级权限:先用 `applyDistrictRowFilter` 校验站点可见性;不可见统一返回 `NOT_FOUND_OR_FORBIDDEN`。
tenant_service 仅可通过 `ops-query` 调用,按总部视角查询。
- 日期不得晚于上海时区当天,查询自然日数受 Redis `Dictionary.StatisticsDays` 限制。
- 日期不得晚于上海时区当天;查询区间硬限 31 天(一个月,2026-09-24 拍板),且受 Redis `Dictionary.StatisticsDays` 限制,两者取更严。
示例响应:
@@ -926,6 +926,30 @@
---
## 37. district.traffic.statistics.query
查询站点人流量统计(人次,SUM(use_count)),复刻老 `StatisticsReportController.trafficStatistics`(饼图)
与 `trafficStatisticsDetail`(折线图)。
- 分类:customer | 类型:读
- 数据来源:m0 `monthly_statistics` 日汇总表,固定 `soft_delete=0`、指定站点和日期范围,DB 完成聚合;不查 ES。
- 入参:`districtId`、`startDate`、`endDate`、`view`(gender=人流量占比、daily_gender=按日趋势),以及可选
`deviceType`(非负整数透传,不限枚举)、`buildingIds`/`floors`/`toiletIds`(列表内 IN,维度间 AND)。
- `view=gender` 返回 `totalCount` 与 `rows[]`(genderCode/gender/useCount/percent,percent 两位小数 HALF_UP,
totalCount=0 时 percent=0)。
- `view=daily_gender` 返回 `dates[]`(区间全日期)与 `series[]`(genderCode/gender/useCount/values),
values 与 dates 等长零填充——修正了老系统首次分组插入错位的 bug。
- gender 文案:0男卫、1女卫、2母婴室、3无障碍、4第三卫、5其他;null 为「未配置」,其他整数 `未知(code)`。
- 行级权限:`applyDistrictRowFilter` 校验站点可见性,不可见返回「站点不存在或无权访问」;
tenant_service 按总部视角查询。
- 日期不得晚于今天;查询区间硬限 31 天(一个月,2026-09-24 拍板),超出报错提示修改日期,
不依赖 Redis StatisticsDays。
- 无数据时 `totalCount=0`、rows/series 为空,并在 notice 说明。
详细设计见 [traffic-statistics-mcp-tool-design](traffic-statistics-mcp-tool-design.md)。
---
---
## 附录:在线查看工具清单
@@ -0,0 +1,111 @@
# 人流量统计 MCP 工具设计(评审稿)
需求:老系统人流量统计两个接口做成 MCP 工具,2026-09-24 用户截图指派(trafficStatistics 饼图 + trafficStatisticsDetail 折线图),先评审再实现。
拟建工具:`district.traffic.statistics.query`(**一个工具、两个 view**,对齐
`district.paper.device_average.query` 把老系统多接口合并为 view 参数的既有风格)
- `view=gender`:人流量占比(复刻 `/statisticsReport/trafficStatistics` 饼图)
- `view=daily_gender`:人流量按日趋势(复刻 `/statisticsReport/trafficStatisticsDetail` 折线图)
## 背景
老系统 `StatisticsReportController`(xiaoqu-franchisee):
- `trafficStatistics`(:347-442):饼图,按 gender 分组 SUM(use_count),算占比
- `trafficStatisticsDetail`(:453-559):折线图,按 日期×gender 分组 SUM(use_count),区间内日期零填充
两个接口查询的都是 **m0 `monthly_statistics`** 日汇总表(与 district.paper.* 系列同一张表),
过滤条件完全一致:`statistics_time` 区间(start 00:00:00 ~ end 23:59:59)+ `district_id` 必填
+ `setWrapperByInput`(:3062-3070)可选 `idStrList`(`楼id_楼层_厕所id` 拼串,拆成三个 IN)与 `device_type`。
天数上限走 Redis `Dictionary.StatisticsDays`。
老接口的行为与坑:
1. **权限校验是注释掉的**(`// User user = UserUtils.getUser(request)`),裸奔。
2. **`use_count` 即人流量**,与用纸长度无关——现有 `district.paper.statistics.query` 虽也返回 useCount,
但受厕位/厕墙口径约束(厕墙行恒 0,老 :200-203 意图),不能直接当人流量用,所以单独建工具。
3. **折线零填充有 bug**(:518-525):首次出现的 gender 用 `curValueList.add(dayOrder, v)` 是**插入**而非覆盖
(valueList 预填充时又漏了下标 0),序列与日期错位;后续 `set(dayOrder, ...)` 才是对的。新工具修正为
按区间长度预填 0 再 set。
4. **gender 为 null 直接 NPE**(:387 `gender.toString()`),老数据有 null 时会 500。新工具归为「未配置」分组。
5. percent 用 `DecimalFormat("######0.00")` 截断两位;totalCount=0 时 percent=0。
6. 天数上限校验被 try/catch 吞掉(:366-368),Redis 缺 StatisticsDays 时静默放行。新工具 fail fast
(与 paper 系列一致)。
7. idStrList 拼串(`123_F1_456`)是前端树组件的传参方式,MCP 场景语义差,
建议改为与 `district.paper.device_average.query` 一致的 `buildingIds/floors/toiletIds` 三个列表 IN。
## 待拍板决策
**2026-09-24 用户拍板:全部按建议执行(决策 1-7 锁定),并追加两条:**
- **决策 8(追加):日期区间硬限一个月(31 天)**,超出直接报错「查询时间段最长 31 天(一个月),请修改日期范围」。
本工具**不依赖** Redis `Dictionary.StatisticsDays`(硬限即上限,避免双口径)。
- **决策 9(追加):存量工具 `district.paper.percapita.query`(33)与 `district.paper.device_average.query`(34)同步加 31 天硬限**,
原有 StatisticsDays 检查保留(两者取更严)。
| # | 决策点 | 建议 | 备选 |
|---|---|---|---|
| 1 | 工具拆分 | **一个工具两个 view**(对齐 34 号工具合并风格) | 两个工具 1:1 对齐老接口 |
| 2 | 权限 | **做行级权限**(`applyDistrictRowFilter`,与同表的 district.paper.* 一致,不可见返回「站点不存在或无权访问」) | 对齐老系统裸奔(权限已注释) |
| 3 | 位置过滤入参 | **buildingIds/floors/toiletIds 三列表 IN**(对齐 34 号工具) | 复制老 idStrList 拼串 |
| 4 | deviceType 取值 | **不限枚举**,任意非负整数透传(老系统无约束;人流量不限于出纸设备) | 限制 0/1(与纸工具一致) |
| 5 | endDate 晚于今天 | **拒绝**(对齐新工具族;monthly_statistics 是日汇总,查未来无意义) | 老系统不限制,放行 |
| 6 | gender=null | 归为 `genderCode=null, gender="未配置"` 分组 | 跳过该行(老系统 NPE) |
| 7 | 分类 | **customer**(按「分类都放到 customer」口径) | hardware |
## 参数契约
- `districtId`:Long,必填,正整数。
- `startDate` / `endDate`:String,必填,yyyy-MM-dd,含当天;startDate ≤ endDate;endDate 不得晚于今天(决策 5);
区间自然日数 ≤ Redis `Dictionary.StatisticsDays`(缺失/非法 fail fast)。
- `view`:String,必填,`gender`(占比)或 `daily_gender`(按日趋势)。
- `deviceType`:Integer,可选,非负整数透传 `device_type =`(决策 4)。
- `buildingIds` / `floors` / `toiletIds`:List,可选,元素须正整数/非空串;各维度内部 IN、维度之间 AND
(对齐 34 号工具;替代老 idStrList 拼串,决策 3)。
固定条件:`soft_delete=0`、`district_id=`、`statistics_time` 在区间内。
聚合在 DB 完成(GROUP BY gender / DATE_FORMAT(statistics_time)+gender),不查 ES。
## 返回契约
公共:`districtId / districtName / dateRange{startDate,endDate,days} / view / unit="人次"`。
`view=gender`(对齐老饼图 PieStatisticsOutput):
```
totalCount 区间内 SUM(use_count)
rows[]: genderCode / gender / useCount / percent percent=useCount*100/totalCount,HALF_UP 两位;totalCount=0 时 percent=0
notice? 无数据时说明
```
`view=daily_gender`(对齐老折线 LineChartStatisticsOutput,修正零填充错位 bug):
```
dates[] 区间内全部日期(yyyy-MM-dd 升序)
totalCount
series[]: genderCode / gender / useCount(合计) / values[] values 与 dates 等长对齐,无数据日期为 0
notice?
```
gender 文案:0男卫、1女卫、2母婴室、3无障碍、4第三卫、5其他;null 为「未配置」,其他整数 `未知(code)`
(与 34 号工具一致)。
## 与老系统的差异汇总
1. 两个接口合并为一个工具的 view 参数。
2. 行级权限:老系统注释掉了,新工具按同表工具惯例做 `applyDistrictRowFilter`(待拍板,决策 2)。
3. idStrList 拼串改为 buildingIds/floors/toiletIds 三列表(决策 3)。
4. 折线零填充错位 bug 修正(老 :518-525)。
5. gender=null 归「未配置」,不再 NPE。
6. Redis StatisticsDays 缺失 fail fast(老系统 try/catch 吞掉)。
7. endDate 不得晚于今天(老系统不限制,决策 5)。
## 测试要点
- 参数校验:缺 districtId/日期、startDate>endDate、endDate 晚于今天、超 StatisticsDays、view 非法、
deviceType 负数、位置列表非法元素。
- 权限:站点不可见返回「站点不存在或无权访问」(若决策 2 通过);tenant_service 按总部视角。
- view=gender:多 gender 聚合、percent 两位小数、totalCount=0 时 percent=0、未知 code、null gender 归未配置。
- view=daily_gender:零填充序列与 dates 等长对齐(回归老系统插入错位 bug)、跨月区间、空数据。
- 位置三维 IN/AND 语义、deviceType 透传、StatisticsDays 缺失 fail fast。
@@ -0,0 +1,16 @@
package me.iiv.agent.entity.m0;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDate;
/** monthly_statistics 人流量统计数据库聚合结果(SUM(use_count))。 */
@Getter
@Setter
public class TrafficUseCountRow {
private LocalDate statisticsDate;
private Integer genderCode;
private Long useCount;
}
@@ -3,6 +3,7 @@ package me.iiv.agent.mapper.m0;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m0.DeviceAverageAggregateRow;
import me.iiv.agent.entity.m0.MonthlyStatistics;
import me.iiv.agent.entity.m0.TrafficUseCountRow;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.SelectProvider;
@@ -21,4 +22,15 @@ public interface MonthlyStatisticsMapper extends BaseMapper<MonthlyStatistics> {
@Param("buildingIds") List<Long> buildingIds,
@Param("floors") List<String> floors,
@Param("toiletIds") List<Long> toiletIds);
@SelectProvider(type = MonthlyStatisticsSqlProvider.class, method = "selectTrafficUseCount")
List<TrafficUseCountRow> selectTrafficUseCount(
@Param("districtId") Long districtId,
@Param("startAt") LocalDateTime startAt,
@Param("endExclusive") LocalDateTime endExclusive,
@Param("daily") boolean daily,
@Param("deviceType") Integer deviceType,
@Param("buildingIds") List<Long> buildingIds,
@Param("floors") List<String> floors,
@Param("toiletIds") List<Long> toiletIds);
}
@@ -47,6 +47,36 @@ public class MonthlyStatisticsSqlProvider {
.append(item).append("}</foreach>");
}
/** 人流量统计聚合:SUM(use_count),daily=true 时按 DATE(statistics_time)+gender 分组,否则只按 gender。 */
public String selectTrafficUseCount(Map<String, Object> params) {
boolean daily = Boolean.TRUE.equals(params.get("daily"));
StringBuilder sql = new StringBuilder("<script>SELECT ");
if (daily) {
sql.append("DATE(statistics_time) AS statistics_date, ");
} else {
sql.append("NULL AS statistics_date, ");
}
sql.append("gender AS gender_code, SUM(COALESCE(use_count, 0)) AS use_count")
.append(" FROM monthly_statistics")
.append(" WHERE soft_delete = 0")
.append(" AND district_id = #{districtId}")
.append(" AND statistics_time &gt;= #{startAt}")
.append(" AND statistics_time &lt; #{endExclusive}");
if (params.get("deviceType") != null) {
sql.append(" AND device_type = #{deviceType}");
}
appendIn(sql, params, "buildingIds", "building_id", "buildingId");
appendIn(sql, params, "floors", "floor", "floorValue");
appendIn(sql, params, "toiletIds", "toilet_id", "toiletId");
if (daily) {
sql.append(" GROUP BY DATE(statistics_time), gender ORDER BY DATE(statistics_time), gender");
} else {
sql.append(" GROUP BY gender ORDER BY gender");
}
sql.append("</script>");
return sql.toString();
}
public enum View {
GENDER("gender", "NULL AS statistics_date, NULL AS floor, gender AS gender_code",
"gender", "gender"),
@@ -0,0 +1,308 @@
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.TrafficUseCountRow;
import me.iiv.agent.entity.m0.User;
import me.iiv.agent.entity.svc.AgentApiKey;
import me.iiv.agent.mapper.m0.DistrictDistrictStrategyMapper;
import me.iiv.agent.mapper.m0.MonthlyStatisticsMapper;
import me.iiv.agent.mcp.McpErrorCode;
import me.iiv.agent.mcp.ToolCallException;
import me.iiv.agent.mcp.ToolObject;
import me.iiv.agent.mcp.XqTool;
import me.iiv.agent.permission.PermissionService;
import me.iiv.agent.security.AuthContext;
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.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
/**
* 人流量统计(复刻老系统 StatisticsReportController.trafficStatistics 饼图
* 与 trafficStatisticsDetail 折线图,xiaoqu-franchisee :347-442/:453-559)。
* 指标为 m0 monthly_statistics 的 SUM(use_count)(人次),与用纸长度无关。
* 设计文档:doc/traffic-statistics-mcp-tool-design.md(2026-09-24 用户拍板:
* 一个工具两个 view、做行级权限、位置三维列表 IN、deviceType 非负透传、
* endDate 不晚于今天、gender=null 归「未配置」、日期区间硬限 31 天)。
*/
@Component
public class DistrictTrafficStatisticsTools implements ToolObject {
/** 日期区间硬限一个月(用户拍板,不依赖 Redis StatisticsDays) */
private static final int MAX_QUERY_DAYS = 31;
private static final ZoneId SHANGHAI = ZoneId.of("Asia/Shanghai");
private static final String VIEW_GENDER = "gender";
private static final String VIEW_DAILY_GENDER = "daily_gender";
final DistrictDistrictStrategyMapper districtMapper;
final MonthlyStatisticsMapper monthlyStatisticsMapper;
final PermissionService permissionService;
public DistrictTrafficStatisticsTools(DistrictDistrictStrategyMapper districtMapper,
MonthlyStatisticsMapper monthlyStatisticsMapper,
PermissionService permissionService) {
this.districtMapper = districtMapper;
this.monthlyStatisticsMapper = monthlyStatisticsMapper;
this.permissionService = permissionService;
}
@XqTool(category = "customer", kind = "read")
@Tool(name = "district.traffic.statistics.query", description = "查询站点人流量统计(人次,SUM(use_count))。"
+ "view=gender 返回各卫生间类型的人流量与占比(复刻老系统饼图);"
+ "view=daily_gender 返回按日趋势,dates 区间内全日期、各类型 values 与 dates 等长对齐零填充"
+ "(复刻老系统折线图)。数据来自 m0 monthly_statistics 日汇总表,不查 ES。"
+ "日期区间最长 31 天(一个月),超出报错请修改日期;endDate 不得晚于今天。")
public Map<String, Object> districtTrafficStatisticsQuery(
@ToolParam(description = "站点 id,正整数") Long districtId,
@ToolParam(description = "开始日期,yyyy-MM-dd,含当天") String startDate,
@ToolParam(description = "结束日期,yyyy-MM-dd,含当天,不得晚于今天") String endDate,
@ToolParam(description = "视图:gender=人流量占比、daily_gender=按日趋势") String view,
@ToolParam(required = false, description = "设备类型,非负整数精确匹配,缺省不过滤") Integer deviceType,
@ToolParam(required = false, description = "楼 id 列表;列表内 IN,与其他位置维度 AND")
List<Long> buildingIds,
@ToolParam(required = false, description = "楼层列表;列表内 IN,与其他位置维度 AND")
List<String> floors,
@ToolParam(required = false, description = "卫生间 id 列表;列表内 IN,与其他位置维度 AND")
List<Long> toiletIds) {
if (districtId == null || districtId < 1) {
throw invalid("districtId 必填且必须为正整数");
}
LocalDate start = parseDay(startDate, "startDate");
LocalDate end = parseDay(endDate, "endDate");
if (start.isAfter(end)) {
throw invalid("startDate 不能晚于 endDate");
}
if (end.isAfter(LocalDate.now(SHANGHAI))) {
throw invalid("endDate 不得晚于今天");
}
if (!VIEW_GENDER.equals(view) && !VIEW_DAILY_GENDER.equals(view)) {
throw invalid("view 仅支持 gender、daily_gender");
}
if (deviceType != null && deviceType < 0) {
throw invalid("deviceType 必须为非负整数");
}
List<Long> normalizedBuildingIds = positiveIds(buildingIds, "buildingIds");
List<String> normalizedFloors = floorValues(floors);
List<Long> normalizedToiletIds = positiveIds(toiletIds, "toiletIds");
AuthContext context = AuthContextHolder.get();
if (context == null || (context.getUser() == null && !isServicePrincipal(context))) {
throw new ToolCallException(McpErrorCode.UNAUTHENTICATED, "缺少有效身份上下文");
}
DistrictDistrictStrategy district = visibleDistrict(districtId, context.getUser());
int queryDays = (int) ChronoUnit.DAYS.between(start, end) + 1;
if (queryDays > MAX_QUERY_DAYS) {
throw invalid("查询时间段最长 " + MAX_QUERY_DAYS + " 天(一个月),请修改日期范围");
}
List<TrafficUseCountRow> aggregates;
try {
aggregates = monthlyStatisticsMapper.selectTrafficUseCount(
districtId, start.atStartOfDay(), end.plusDays(1).atStartOfDay(),
VIEW_DAILY_GENDER.equals(view), deviceType,
normalizedBuildingIds, normalizedFloors, normalizedToiletIds);
} catch (RuntimeException exception) {
throw new ToolCallException(McpErrorCode.UPSTREAM_UNAVAILABLE, "人流量统计数据暂不可用");
}
if (aggregates == null) {
aggregates = List.of();
}
Map<String, Object> output = new LinkedHashMap<>();
output.put("districtId", districtId);
output.put("districtName", district.getDistrictName());
Map<String, Object> dateRange = new LinkedHashMap<>();
dateRange.put("startDate", start.toString());
dateRange.put("endDate", end.toString());
dateRange.put("days", queryDays);
output.put("dateRange", dateRange);
output.put("view", view);
output.put("unit", "人次");
long totalCount = aggregates.stream()
.mapToLong(row -> row.getUseCount() == null ? 0L : row.getUseCount()).sum();
output.put("totalCount", totalCount);
if (VIEW_GENDER.equals(view)) {
output.put("rows", genderRows(aggregates, totalCount));
} else {
output.put("dates", datesOf(start, queryDays));
output.put("series", dailySeries(aggregates, start, queryDays));
}
if (totalCount == 0) {
output.put("notice", "区间内无人流量统计数据");
}
return output;
}
/** view=gender:各卫生间类型人流量与占比(percent 两位小数 HALF_UP,totalCount=0 时全 0)。 */
private List<Map<String, Object>> genderRows(List<TrafficUseCountRow> aggregates, long totalCount) {
List<Map<String, Object>> rows = new ArrayList<>();
for (TrafficUseCountRow row : sorted(aggregates)) {
long useCount = row.getUseCount() == null ? 0L : row.getUseCount();
Map<String, Object> item = new LinkedHashMap<>();
item.put("genderCode", row.getGenderCode());
item.put("gender", genderText(row.getGenderCode()));
item.put("useCount", useCount);
item.put("percent", percent(useCount, totalCount));
rows.add(item);
}
return rows;
}
/** view=daily_gender:values 与 dates 等长零填充(修正老系统插入错位 bug,:518-525)。 */
private List<Map<String, Object>> dailySeries(List<TrafficUseCountRow> aggregates,
LocalDate start, int queryDays) {
Map<Integer, long[]> valuesByGender = new LinkedHashMap<>();
Map<Integer, Long> totalByGender = new LinkedHashMap<>();
for (TrafficUseCountRow row : sorted(aggregates)) {
if (row.getStatisticsDate() == null) {
continue;
}
int index = (int) ChronoUnit.DAYS.between(start, row.getStatisticsDate());
if (index < 0 || index >= queryDays) {
continue;
}
long[] values = valuesByGender.computeIfAbsent(row.getGenderCode(), key -> new long[queryDays]);
long useCount = row.getUseCount() == null ? 0L : row.getUseCount();
values[index] += useCount;
totalByGender.merge(row.getGenderCode(), useCount, Long::sum);
}
List<Map<String, Object>> series = new ArrayList<>();
valuesByGender.forEach((genderCode, values) -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("genderCode", genderCode);
item.put("gender", genderText(genderCode));
item.put("useCount", totalByGender.get(genderCode));
List<Long> valueList = new ArrayList<>(values.length);
for (long value : values) {
valueList.add(value);
}
item.put("values", valueList);
series.add(item);
});
return series;
}
/** genderCode 升序,null(未配置)排最后。 */
private static List<TrafficUseCountRow> sorted(List<TrafficUseCountRow> aggregates) {
List<TrafficUseCountRow> copy = new ArrayList<>(aggregates);
copy.sort(Comparator.comparing(TrafficUseCountRow::getGenderCode,
Comparator.nullsLast(Comparator.naturalOrder())));
return copy;
}
private static List<String> datesOf(LocalDate start, int queryDays) {
List<String> dates = new ArrayList<>(queryDays);
for (int i = 0; i < queryDays; i++) {
dates.add(start.plusDays(i).toString());
}
return dates;
}
private static BigDecimal percent(long useCount, long totalCount) {
if (totalCount == 0) {
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
}
return BigDecimal.valueOf(useCount * 100.0 / totalCount).setScale(2, RoundingMode.HALF_UP);
}
private DistrictDistrictStrategy visibleDistrict(Long districtId, User user) {
try {
LambdaQueryWrapper<DistrictDistrictStrategy> wrapper =
new LambdaQueryWrapper<DistrictDistrictStrategy>()
.eq(DistrictDistrictStrategy::getSoftDelete, 0)
.eq(DistrictDistrictStrategy::getId, districtId);
permissionService.applyDistrictRowFilter(wrapper, user);
DistrictDistrictStrategy district = districtMapper.selectOne(wrapper);
if (district == null) {
throw new ToolCallException(McpErrorCode.NOT_FOUND_OR_FORBIDDEN,
"站点不存在或无权访问");
}
return district;
} catch (ToolCallException exception) {
throw exception;
} catch (RuntimeException exception) {
throw new ToolCallException(McpErrorCode.UPSTREAM_UNAVAILABLE, "站点权限数据暂不可用");
}
}
private static List<Long> positiveIds(List<Long> values, String name) {
if (values == null || values.isEmpty()) {
return List.of();
}
LinkedHashSet<Long> normalized = new LinkedHashSet<>();
for (Long value : values) {
if (value == null || value < 1) {
throw invalid(name + " 每项必须为正整数");
}
normalized.add(value);
}
return new ArrayList<>(normalized);
}
private static List<String> floorValues(List<String> values) {
if (values == null || values.isEmpty()) {
return List.of();
}
LinkedHashSet<String> normalized = new LinkedHashSet<>();
for (String value : values) {
if (!StringUtils.hasText(value)) {
throw invalid("floors 每项不得为空");
}
normalized.add(value.trim());
}
return new ArrayList<>(normalized);
}
private static LocalDate parseDay(String value, String name) {
if (!StringUtils.hasText(value)) {
throw invalid(name + " 必填,格式 yyyy-MM-dd");
}
try {
return LocalDate.parse(value.trim());
} catch (DateTimeParseException exception) {
throw invalid(name + " 格式非法,要求 yyyy-MM-dd: " + value);
}
}
private static boolean isServicePrincipal(AuthContext context) {
return context.getToken() != null
&& AgentApiKey.PRINCIPAL_TYPE_SERVICE.equals(context.getToken().getPrincipalType());
}
private static String genderText(Integer gender) {
if (gender == null) {
return "未配置";
}
return switch (gender) {
case 0 -> "男卫";
case 1 -> "女卫";
case 2 -> "母婴室";
case 3 -> "无障碍";
case 4 -> "第三卫";
case 5 -> "其他";
default -> "未知(" + gender + ")";
};
}
private static ToolCallException invalid(String message) {
return new ToolCallException(McpErrorCode.INVALID_ARGUMENT, message);
}
}
@@ -39,6 +39,8 @@ public class HardwareStatisticsTools implements ToolObject {
private static final String DICTIONARY_KEY = "Dictionary";
private static final String DICT_STATISTICS_DAYS = "StatisticsDays";
/** 日期区间硬限一个月(2026-09-24 用户拍板),与 Redis StatisticsDays 取更严 */
private static final int MAX_QUERY_DAYS = 31;
private static final ZoneId SHANGHAI = ZoneId.of("Asia/Shanghai");
private static final BigDecimal CENTIMETERS_PER_METER = BigDecimal.valueOf(100);
private static final String COUNT_NOTICE =
@@ -64,7 +66,7 @@ public class HardwareStatisticsTools implements ToolObject {
+ "view 仅支持 gender、daily_gender、floor、daily_floor、floor_gender;"
+ "返回统一 rows,每行包含日期、楼层、卫生间类型、用纸米数、统计记录数和设备日均值。"
+ "分母固定为 monthly_statistics 的 COUNT(*),不是 SUM(use_count)。"
+ "日期范围最大天数受 Redis Dictionary.StatisticsDays 限制。")
+ "日期区间最长 31 天(一个月),且受 Redis Dictionary.StatisticsDays 限制。")
public Map<String, Object> districtPaperDeviceAverageQuery(
@ToolParam(description = "站点 id,正整数") Long districtId,
@ToolParam(description = "开始日期,yyyy-MM-dd,含当天") String startDate,
@@ -107,8 +109,11 @@ public class HardwareStatisticsTools implements ToolObject {
User user = context.getUser();
DistrictDistrictStrategy district = visibleDistrict(districtId, user);
int maxDays = statisticsDays();
int queryDays = (int) ChronoUnit.DAYS.between(start, end) + 1;
if (queryDays > MAX_QUERY_DAYS) {
throw invalid("查询时间段最长 " + MAX_QUERY_DAYS + " 天(一个月),请修改日期范围");
}
int maxDays = statisticsDays();
if (queryDays > maxDays) {
throw invalid("查询时间段最大" + maxDays + "天");
}
@@ -37,6 +37,8 @@ public class PaperPerCapitaStatisticsTools implements ToolObject {
private static final String DICTIONARY_KEY = "Dictionary";
private static final String DICT_STATISTICS_DAYS = "StatisticsDays";
/** 日期区间硬限一个月(2026-09-24 用户拍板),与 Redis StatisticsDays 取更严 */
private static final int MAX_QUERY_DAYS = 31;
private static final ZoneId SHANGHAI = ZoneId.of("Asia/Shanghai");
private static final Set<String> GROUPS = Set.of("gender", "floor", "floorGender");
@@ -61,7 +63,7 @@ public class PaperPerCapitaStatisticsTools implements ToolObject {
+ "daily=true 时再按日分组,仅支持 gender 和 floor。"
+ "返回面向 LLM 的扁平 rows,每行含 date、floor、gender、paperLengthMeters、useCount、perCapita;"
+ "没有数据的日期或分组不会补 0。paperLengthMeters 单位为米,perCapita 单位为米/人次。"
+ "日期范围最大天数受 Redis Dictionary.StatisticsDays 限制。")
+ "日期区间最长 31 天(一个月),且受 Redis Dictionary.StatisticsDays 限制。")
public Map<String, Object> districtPaperPerCapitaQuery(
@ToolParam(description = "站点 id,正整数") Long districtId,
@ToolParam(description = "开始日期,yyyy-MM-dd,含当天") String startDate,
@@ -110,8 +112,12 @@ public class PaperPerCapitaStatisticsTools implements ToolObject {
throw new IllegalStateException("站点不存在或无权访问");
}
int maxDays = statisticsDays();
int queryDays = (int) ChronoUnit.DAYS.between(start, end) + 1;
if (queryDays > MAX_QUERY_DAYS) {
throw new IllegalArgumentException(
"查询时间段最长 " + MAX_QUERY_DAYS + " 天(一个月),请修改日期范围");
}
int maxDays = statisticsDays();
if (queryDays > maxDays) {
throw new IllegalArgumentException("查询时间段最大" + maxDays + "天");
}
@@ -65,4 +65,43 @@ class MonthlyStatisticsSqlProviderTest {
case FLOOR_GENDER -> "floor, gender";
};
}
@Test
void trafficGenderGroupsByGenderOnly() {
Map<String, Object> params = new HashMap<>();
params.put("daily", false);
params.put("deviceType", null);
params.put("buildingIds", List.of());
params.put("floors", List.of());
params.put("toiletIds", List.of());
String sql = provider.selectTrafficUseCount(params);
assertTrue(sql.contains("SUM(COALESCE(use_count, 0)) AS use_count"));
assertTrue(sql.contains("NULL AS statistics_date"));
assertTrue(sql.contains("GROUP BY gender ORDER BY gender"));
assertFalse(sql.contains("DATE(statistics_time) AS statistics_date"));
assertFalse(sql.contains("device_type ="));
assertFalse(sql.contains("building_id IN"));
}
@Test
void trafficDailyGroupsByDateAndGenderWithBoundFilters() {
Map<String, Object> params = new HashMap<>();
params.put("daily", true);
params.put("deviceType", 0);
params.put("buildingIds", List.of(12L));
params.put("floors", List.of("2F"));
params.put("toiletIds", List.of(31L));
String sql = provider.selectTrafficUseCount(params);
assertTrue(sql.contains("DATE(statistics_time) AS statistics_date"));
assertTrue(sql.contains("GROUP BY DATE(statistics_time), gender"));
assertTrue(sql.contains("#{deviceType}"));
assertTrue(sql.contains("collection='buildingIds'"));
assertTrue(sql.contains("collection='floors'"));
assertTrue(sql.contains("collection='toiletIds'"));
assertFalse(sql.contains("2F"));
}
}
@@ -0,0 +1,245 @@
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.TrafficUseCountRow;
import me.iiv.agent.entity.m0.User;
import me.iiv.agent.mapper.m0.DistrictDistrictStrategyMapper;
import me.iiv.agent.mapper.m0.MonthlyStatisticsMapper;
import me.iiv.agent.mcp.McpErrorCode;
import me.iiv.agent.mcp.ToolCallException;
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.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.ZoneId;
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.anyList;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DistrictTrafficStatisticsToolsTest {
private static final ZoneId SHANGHAI = ZoneId.of("Asia/Shanghai");
@Mock
DistrictDistrictStrategyMapper districtMapper;
@Mock
MonthlyStatisticsMapper monthlyStatisticsMapper;
@Mock
PermissionService permissionService;
DistrictTrafficStatisticsTools tools;
LocalDate today;
String start;
String end;
@BeforeEach
void setUp() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
DistrictDistrictStrategy.class);
tools = new DistrictTrafficStatisticsTools(districtMapper, monthlyStatisticsMapper, permissionService);
User user = new User();
user.setId(7L);
AuthContextHolder.set(new AuthContext(null, user));
today = LocalDate.now(SHANGHAI);
start = today.minusDays(2).toString();
end = today.toString();
}
@AfterEach
void tearDown() {
AuthContextHolder.clear();
}
private Map<String, Object> query(Long districtId, String view, Integer deviceType) {
return tools.districtTrafficStatisticsQuery(districtId, start, end, view,
deviceType, null, null, null);
}
@Test
void rejectsInvalidArgumentsBeforeQuerying() {
assertCode(McpErrorCode.INVALID_ARGUMENT, () -> query(null, "gender", null));
assertCode(McpErrorCode.INVALID_ARGUMENT, () -> query(0L, "gender", null));
assertCode(McpErrorCode.INVALID_ARGUMENT,
() -> tools.districtTrafficStatisticsQuery(9L, "2026/09/01", end, "gender", null, null, null, null));
assertCode(McpErrorCode.INVALID_ARGUMENT, () -> query(9L, "unsupported", null));
assertCode(McpErrorCode.INVALID_ARGUMENT, () -> query(9L, "gender", -1));
assertCode(McpErrorCode.INVALID_ARGUMENT,
() -> tools.districtTrafficStatisticsQuery(9L, end, start, "gender", null, null, null, null));
assertCode(McpErrorCode.INVALID_ARGUMENT,
() -> tools.districtTrafficStatisticsQuery(9L, start, today.plusDays(1).toString(),
"gender", null, null, null, null));
assertCode(McpErrorCode.INVALID_ARGUMENT,
() -> tools.districtTrafficStatisticsQuery(9L, start, end, "gender", null,
List.of(0L), null, null));
assertCode(McpErrorCode.INVALID_ARGUMENT,
() -> tools.districtTrafficStatisticsQuery(9L, start, end, "gender", null,
null, List.of(" "), null));
verifyNoInteractions(districtMapper, monthlyStatisticsMapper);
}
@Test
void rejectsRangeLongerThanOneMonth() {
mockDistrict();
// 32 天:超过硬限一个月,直接报错让修改日期(不查 Redis,本工具不依赖 StatisticsDays)
ToolCallException error = assertCode(McpErrorCode.INVALID_ARGUMENT,
() -> tools.districtTrafficStatisticsQuery(9L, today.minusDays(31).toString(), end,
"gender", null, null, null, null));
assertEquals("查询时间段最长 31 天(一个月),请修改日期范围", error.getRawMessage());
verifyNoInteractions(monthlyStatisticsMapper);
}
@Test
void rejectsMissingIdentityAndInvisibleDistrict() {
AuthContextHolder.clear();
assertCode(McpErrorCode.UNAUTHENTICATED, () -> query(9L, "gender", null));
verifyNoInteractions(districtMapper, monthlyStatisticsMapper);
User user = new User();
user.setId(7L);
AuthContextHolder.set(new AuthContext(null, user));
when(districtMapper.selectOne(any())).thenReturn(null);
ToolCallException error = assertCode(McpErrorCode.NOT_FOUND_OR_FORBIDDEN,
() -> query(9L, "gender", null));
assertEquals("站点不存在或无权访问", error.getRawMessage());
verify(permissionService).applyDistrictRowFilter(any(), eq(user));
verifyNoInteractions(monthlyStatisticsMapper);
}
@Test
void genderViewAggregatesUseCountWithPercent() {
mockDistrict();
when(monthlyStatisticsMapper.selectTrafficUseCount(eq(9L), any(), any(), eq(false),
eq(null), anyList(), anyList(), anyList())).thenReturn(List.of(
row(null, 0, 250L), row(null, 1, 50L)));
Map<String, Object> result = query(9L, "gender", null);
assertEquals("人次", result.get("unit"));
assertEquals("gender", result.get("view"));
assertEquals(300L, result.get("totalCount"));
assertEquals("测试站点", result.get("districtName"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> rows = (List<Map<String, Object>>) result.get("rows");
assertEquals(2, rows.size());
assertEquals(0, rows.get(0).get("genderCode"));
assertEquals("男卫", rows.get(0).get("gender"));
assertEquals(250L, rows.get(0).get("useCount"));
assertEquals(new BigDecimal("83.33"), rows.get(0).get("percent"));
assertEquals(new BigDecimal("16.67"), rows.get(1).get("percent"));
}
@Test
void genderViewMapsNullAndUnknownGender() {
mockDistrict();
when(monthlyStatisticsMapper.selectTrafficUseCount(eq(9L), any(), any(), eq(false),
eq(null), anyList(), anyList(), anyList())).thenReturn(List.of(
row(null, null, 10L), row(null, 9, 5L)));
Map<String, Object> result = query(9L, "gender", null);
@SuppressWarnings("unchecked")
List<Map<String, Object>> rows = (List<Map<String, Object>>) result.get("rows");
// null(未配置)排最后,未知 code 显示 未知(9),均不抛错(老系统此处 NPE)
assertEquals(9, rows.get(0).get("genderCode"));
assertEquals("未知(9)", rows.get(0).get("gender"));
assertNull(rows.get(1).get("genderCode"));
assertEquals("未配置", rows.get(1).get("gender"));
}
@Test
void dailyGenderViewZeroFillsDatesAligned() {
mockDistrict();
LocalDate middle = today.minusDays(1);
when(monthlyStatisticsMapper.selectTrafficUseCount(eq(9L), any(), any(), eq(true),
eq(null), anyList(), anyList(), anyList())).thenReturn(List.of(
row(LocalDate.parse(start), 0, 10L), row(middle, 0, 20L), row(middle, 1, 7L)));
Map<String, Object> result = query(9L, "daily_gender", null);
@SuppressWarnings("unchecked")
List<String> dates = (List<String>) result.get("dates");
assertEquals(List.of(start, middle.toString(), end), dates);
@SuppressWarnings("unchecked")
List<Map<String, Object>> series = (List<Map<String, Object>>) result.get("series");
assertEquals(2, series.size());
// values 与 dates 等长、按下标对齐(回归老系统 add(dayOrder) 插入错位 bug)
assertEquals(List.of(10L, 20L, 0L), series.get(0).get("values"));
assertEquals(30L, series.get(0).get("useCount"));
assertEquals(List.of(0L, 7L, 0L), series.get(1).get("values"));
assertEquals(37L, result.get("totalCount"));
}
@Test
void emptyRangeReturnsNoticeAndZeroSeries() {
mockDistrict();
when(monthlyStatisticsMapper.selectTrafficUseCount(eq(9L), any(), any(), anyBoolean(),
eq(null), anyList(), anyList(), anyList())).thenReturn(List.of());
Map<String, Object> genderResult = query(9L, "gender", null);
assertEquals(0L, genderResult.get("totalCount"));
assertEquals(List.of(), genderResult.get("rows"));
assertEquals("区间内无人流量统计数据", genderResult.get("notice"));
Map<String, Object> dailyResult = query(9L, "daily_gender", null);
assertEquals(3, ((List<?>) dailyResult.get("dates")).size());
assertEquals(List.of(), dailyResult.get("series"));
assertEquals("区间内无人流量统计数据", dailyResult.get("notice"));
}
@Test
void passesDeviceTypeThroughWithoutEnumRestriction() {
mockDistrict();
when(monthlyStatisticsMapper.selectTrafficUseCount(eq(9L), any(), any(), eq(false),
eq(4), anyList(), anyList(), anyList())).thenReturn(List.of(row(null, 0, 3L)));
Map<String, Object> result = query(9L, "gender", 4);
assertEquals(3L, result.get("totalCount"));
verify(monthlyStatisticsMapper).selectTrafficUseCount(eq(9L), any(), any(), eq(false),
eq(4), anyList(), anyList(), anyList());
}
private void mockDistrict() {
DistrictDistrictStrategy district = new DistrictDistrictStrategy();
district.setId(9L);
district.setDistrictName("测试站点");
when(districtMapper.selectOne(any())).thenReturn(district);
}
private static TrafficUseCountRow row(LocalDate date, Integer gender, Long useCount) {
TrafficUseCountRow row = new TrafficUseCountRow();
row.setStatisticsDate(date);
row.setGenderCode(gender);
row.setUseCount(useCount);
return row;
}
private static ToolCallException assertCode(McpErrorCode code, Runnable invocation) {
ToolCallException error = assertThrows(ToolCallException.class, invocation::run);
assertEquals(code, error.getCode());
return error;
}
}
@@ -139,6 +139,18 @@ class HardwareStatisticsToolsTest {
verifyNoInteractions(monthlyStatisticsMapper);
}
@Test
void rejectsRangeLongerThanOneMonth() {
mockDistrict();
// 32 天:超过硬限一个月(2026-09-24 用户拍板),在 StatisticsDays 检查之前拦截
ToolCallException error = assertCode(McpErrorCode.INVALID_ARGUMENT,
() -> tools.districtPaperDeviceAverageQuery(9L, today.minusDays(31).toString(), end,
"gender", null, null, null, null));
assertEquals("查询时间段最长 31 天(一个月),请修改日期范围", error.getRawMessage());
verifyNoInteractions(monthlyStatisticsMapper, redisTemplate);
}
@Test
void usesCountStarForGenderViewAndBuildsSummary() {
mockReady();
@@ -126,6 +126,18 @@ class PaperPerCapitaStatisticsToolsTest {
verifyNoInteractions(monthlyStatisticsMapper);
}
@Test
void rejectsRangeLongerThanOneMonth() {
mockDistrict();
// 32 天:超过硬限一个月(2026-09-24 用户拍板),在 StatisticsDays 检查之前拦截
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> tools.districtPaperPerCapitaQuery(9L, today.minusDays(31).toString(), end,
"gender", false, null, null));
assertEquals("查询时间段最长 31 天(一个月),请修改日期范围", exception.getMessage());
verifyNoInteractions(monthlyStatisticsMapper, redisTemplate);
}
@Test
void aggregatesGenderForWholeRangeWithLlmFriendlyRows() {
mockReady();