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:
@@ -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