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:
@@ -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;
|
||||
}
|
||||
+12
@@ -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);
|
||||
}
|
||||
|
||||
+30
@@ -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 >= #{startAt}")
|
||||
.append(" AND statistics_time < #{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"),
|
||||
|
||||
+308
@@ -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);
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -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 + "天");
|
||||
}
|
||||
|
||||
+8
-2
@@ -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 + "天");
|
||||
}
|
||||
|
||||
+39
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
+245
@@ -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;
|
||||
}
|
||||
}
|
||||
+12
@@ -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();
|
||||
|
||||
+12
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user