diff --git a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md index 8d46e5f..9c9efa1 100644 --- a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md +++ b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md @@ -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)。 + +--- + --- ## 附录:在线查看工具清单 diff --git a/xiaoqu-agent-service/doc/traffic-statistics-mcp-tool-design.md b/xiaoqu-agent-service/doc/traffic-statistics-mcp-tool-design.md new file mode 100644 index 0000000..05937cd --- /dev/null +++ b/xiaoqu-agent-service/doc/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。 diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/TrafficUseCountRow.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/TrafficUseCountRow.java new file mode 100644 index 0000000..739abee --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/TrafficUseCountRow.java @@ -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; +} 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 index b014ea2..ce7584f 100644 --- 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 @@ -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 { @Param("buildingIds") List buildingIds, @Param("floors") List floors, @Param("toiletIds") List toiletIds); + + @SelectProvider(type = MonthlyStatisticsSqlProvider.class, method = "selectTrafficUseCount") + List selectTrafficUseCount( + @Param("districtId") Long districtId, + @Param("startAt") LocalDateTime startAt, + @Param("endExclusive") LocalDateTime endExclusive, + @Param("daily") boolean daily, + @Param("deviceType") Integer deviceType, + @Param("buildingIds") List buildingIds, + @Param("floors") List floors, + @Param("toiletIds") List toiletIds); } diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/MonthlyStatisticsSqlProvider.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/MonthlyStatisticsSqlProvider.java index 01ecd11..07aa4de 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/MonthlyStatisticsSqlProvider.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/MonthlyStatisticsSqlProvider.java @@ -47,6 +47,36 @@ public class MonthlyStatisticsSqlProvider { .append(item).append("}"); } + /** 人流量统计聚合:SUM(use_count),daily=true 时按 DATE(statistics_time)+gender 分组,否则只按 gender。 */ + public String selectTrafficUseCount(Map params) { + boolean daily = Boolean.TRUE.equals(params.get("daily")); + StringBuilder sql = new StringBuilder(""); + return sql.toString(); + } + public enum View { GENDER("gender", "NULL AS statistics_date, NULL AS floor, gender AS gender_code", "gender", "gender"), diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/DistrictTrafficStatisticsTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/DistrictTrafficStatisticsTools.java new file mode 100644 index 0000000..63ae0f0 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/DistrictTrafficStatisticsTools.java @@ -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 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 buildingIds, + @ToolParam(required = false, description = "楼层列表;列表内 IN,与其他位置维度 AND") + List floors, + @ToolParam(required = false, description = "卫生间 id 列表;列表内 IN,与其他位置维度 AND") + List 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 normalizedBuildingIds = positiveIds(buildingIds, "buildingIds"); + List normalizedFloors = floorValues(floors); + List 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 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 output = new LinkedHashMap<>(); + output.put("districtId", districtId); + output.put("districtName", district.getDistrictName()); + Map 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> genderRows(List aggregates, long totalCount) { + List> rows = new ArrayList<>(); + for (TrafficUseCountRow row : sorted(aggregates)) { + long useCount = row.getUseCount() == null ? 0L : row.getUseCount(); + Map 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> dailySeries(List aggregates, + LocalDate start, int queryDays) { + Map valuesByGender = new LinkedHashMap<>(); + Map 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> series = new ArrayList<>(); + valuesByGender.forEach((genderCode, values) -> { + Map item = new LinkedHashMap<>(); + item.put("genderCode", genderCode); + item.put("gender", genderText(genderCode)); + item.put("useCount", totalByGender.get(genderCode)); + List 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 sorted(List aggregates) { + List copy = new ArrayList<>(aggregates); + copy.sort(Comparator.comparing(TrafficUseCountRow::getGenderCode, + Comparator.nullsLast(Comparator.naturalOrder()))); + return copy; + } + + private static List datesOf(LocalDate start, int queryDays) { + List 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 wrapper = + new LambdaQueryWrapper() + .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 positiveIds(List values, String name) { + if (values == null || values.isEmpty()) { + return List.of(); + } + LinkedHashSet 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 floorValues(List values) { + if (values == null || values.isEmpty()) { + return List.of(); + } + LinkedHashSet 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); + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/HardwareStatisticsTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/HardwareStatisticsTools.java index 665ac58..de0c289 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/HardwareStatisticsTools.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/HardwareStatisticsTools.java @@ -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 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 + "天"); } diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/PaperPerCapitaStatisticsTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/PaperPerCapitaStatisticsTools.java index e9f4167..1404888 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/PaperPerCapitaStatisticsTools.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/PaperPerCapitaStatisticsTools.java @@ -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 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 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 + "天"); } diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mapper/m0/MonthlyStatisticsSqlProviderTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mapper/m0/MonthlyStatisticsSqlProviderTest.java index c3b91a4..5a94ce9 100644 --- a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mapper/m0/MonthlyStatisticsSqlProviderTest.java +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mapper/m0/MonthlyStatisticsSqlProviderTest.java @@ -65,4 +65,43 @@ class MonthlyStatisticsSqlProviderTest { case FLOOR_GENDER -> "floor, gender"; }; } + + @Test + void trafficGenderGroupsByGenderOnly() { + Map 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 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")); + } } diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/DistrictTrafficStatisticsToolsTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/DistrictTrafficStatisticsToolsTest.java new file mode 100644 index 0000000..6b006be --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/DistrictTrafficStatisticsToolsTest.java @@ -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 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 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> rows = (List>) 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 result = query(9L, "gender", null); + + @SuppressWarnings("unchecked") + List> rows = (List>) 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 result = query(9L, "daily_gender", null); + + @SuppressWarnings("unchecked") + List dates = (List) result.get("dates"); + assertEquals(List.of(start, middle.toString(), end), dates); + @SuppressWarnings("unchecked") + List> series = (List>) 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 genderResult = query(9L, "gender", null); + assertEquals(0L, genderResult.get("totalCount")); + assertEquals(List.of(), genderResult.get("rows")); + assertEquals("区间内无人流量统计数据", genderResult.get("notice")); + + Map 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 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; + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/HardwareStatisticsToolsTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/HardwareStatisticsToolsTest.java index 3d77ebf..59abbae 100644 --- a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/HardwareStatisticsToolsTest.java +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/HardwareStatisticsToolsTest.java @@ -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(); diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/PaperPerCapitaStatisticsToolsTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/PaperPerCapitaStatisticsToolsTest.java index 3e09bf9..27faa2f 100644 --- a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/PaperPerCapitaStatisticsToolsTest.java +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/PaperPerCapitaStatisticsToolsTest.java @@ -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();