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
@@ -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();