fix(district): position.detail 报错透出对齐 ToolCallException (REQ-20261012-0003)

入参校验/超 31 天/超 StatisticsDays 抛 INVALID_ARGUMENT,站点无权抛
NOT_FOUND_OR_FORBIDDEN,具体文案透出给调用方(对齐 traffic/device_average),
不再被统一掩码成 DATA_INTEGRITY_ERROR。Redis StatisticsDays 缺失维持
IllegalStateException 兜底掩码(服务端配置问题)。
This commit is contained in:
my_local_kimi_dev2
2026-10-12 14:09:10 +08:00
parent 744378082a
commit 377145cafe
2 changed files with 38 additions and 28 deletions
@@ -10,6 +10,8 @@ import me.iiv.agent.mapper.m0.DistrictDistrictStrategyMapper;
import me.iiv.agent.mapper.m0.MonthlyStatisticsMapper; import me.iiv.agent.mapper.m0.MonthlyStatisticsMapper;
import me.iiv.agent.mapper.m0.PositionsMapper; import me.iiv.agent.mapper.m0.PositionsMapper;
import me.iiv.agent.mapper.m0.ToiletMapper; import me.iiv.agent.mapper.m0.ToiletMapper;
import me.iiv.agent.mcp.McpErrorCode;
import me.iiv.agent.mcp.ToolCallException;
import me.iiv.agent.mcp.ToolObject; import me.iiv.agent.mcp.ToolObject;
import me.iiv.agent.mcp.XqTool; import me.iiv.agent.mcp.XqTool;
import me.iiv.agent.permission.PermissionService; import me.iiv.agent.permission.PermissionService;
@@ -93,7 +95,7 @@ public class DistrictPaperPositionDetailTools implements ToolObject {
@ToolParam(required = false, description = "厕所 id,正整数,精确匹配") Long toiletId) { @ToolParam(required = false, description = "厕所 id,正整数,精确匹配") Long toiletId) {
if (districtId == null) { if (districtId == null) {
throw new IllegalArgumentException("districtId 必填"); throw invalid("districtId 必填");
} }
validatePositive(districtId, "districtId"); validatePositive(districtId, "districtId");
validatePositive(buildingId, "buildingId"); validatePositive(buildingId, "buildingId");
@@ -102,16 +104,16 @@ public class DistrictPaperPositionDetailTools implements ToolObject {
LocalDate end = parseDay(endDate, "endDate"); LocalDate end = parseDay(endDate, "endDate");
LocalDate today = LocalDate.now(SHANGHAI); LocalDate today = LocalDate.now(SHANGHAI);
if (start.isAfter(end)) { if (start.isAfter(end)) {
throw new IllegalArgumentException("startDate 不能晚于 endDate"); throw invalid("startDate 不能晚于 endDate");
} }
if (end.isAfter(today)) { if (end.isAfter(today)) {
throw new IllegalArgumentException("endDate 不得晚于今天"); throw invalid("endDate 不得晚于今天");
} }
if (StringUtils.hasText(positionMode) && !"position".equals(positionMode) && !"wall".equals(positionMode)) { if (StringUtils.hasText(positionMode) && !"position".equals(positionMode) && !"wall".equals(positionMode)) {
throw new IllegalArgumentException("positionMode 仅支持 position、wall"); throw invalid("positionMode 仅支持 position、wall");
} }
if (deviceType != null && deviceType != 0 && deviceType != 1) { if (deviceType != null && deviceType != 0 && deviceType != 1) {
throw new IllegalArgumentException("deviceType 仅支持 0、1"); throw invalid("deviceType 仅支持 0、1");
} }
User user = AuthContextHolder.get().getUser(); User user = AuthContextHolder.get().getUser();
@@ -121,17 +123,16 @@ public class DistrictPaperPositionDetailTools implements ToolObject {
permissionService.applyDistrictRowFilter(districtWrapper, user); permissionService.applyDistrictRowFilter(districtWrapper, user);
DistrictDistrictStrategy district = districtMapper.selectOne(districtWrapper); DistrictDistrictStrategy district = districtMapper.selectOne(districtWrapper);
if (district == null) { if (district == null) {
throw new IllegalStateException("站点不存在或无权访问"); throw new ToolCallException(McpErrorCode.NOT_FOUND_OR_FORBIDDEN, "站点不存在或无权访问");
} }
int queryDays = (int) ChronoUnit.DAYS.between(start, end) + 1; int queryDays = (int) ChronoUnit.DAYS.between(start, end) + 1;
if (queryDays > MAX_QUERY_DAYS) { if (queryDays > MAX_QUERY_DAYS) {
throw new IllegalArgumentException( throw invalid("查询时间段最长 " + MAX_QUERY_DAYS + " 天(一个月),请修改日期范围");
"查询时间段最长 " + MAX_QUERY_DAYS + " 天(一个月),请修改日期范围");
} }
int maxDays = statisticsDays(); int maxDays = statisticsDays();
if (queryDays > maxDays) { if (queryDays > maxDays) {
throw new IllegalArgumentException("查询时间段最大" + maxDays + "天"); throw invalid("查询时间段最大" + maxDays + "天");
} }
List<MonthlyStatistics> sourceRows = queryRows(districtId, start, end, List<MonthlyStatistics> sourceRows = queryRows(districtId, start, end,
@@ -313,21 +314,26 @@ public class DistrictPaperPositionDetailTools implements ToolObject {
private static LocalDate parseDay(String value, String name) { private static LocalDate parseDay(String value, String name) {
if (!StringUtils.hasText(value)) { if (!StringUtils.hasText(value)) {
throw new IllegalArgumentException(name + " 必填,格式 yyyy-MM-dd"); throw invalid(name + " 必填,格式 yyyy-MM-dd");
} }
try { try {
return LocalDate.parse(value.trim()); return LocalDate.parse(value.trim());
} catch (java.time.format.DateTimeParseException e) { } catch (java.time.format.DateTimeParseException e) {
throw new IllegalArgumentException(name + " 格式非法,要求 yyyy-MM-dd: " + value); throw invalid(name + " 格式非法,要求 yyyy-MM-dd: " + value);
} }
} }
private static void validatePositive(Long value, String name) { private static void validatePositive(Long value, String name) {
if (value != null && value < 1) { if (value != null && value < 1) {
throw new IllegalArgumentException(name + " 必须为正整数"); throw invalid(name + " 必须为正整数");
} }
} }
/** 入参类错误透出具体文案给调用方(对齐 traffic/device_average 风格),便于 LLM 自愈 */
private static ToolCallException invalid(String message) {
return new ToolCallException(McpErrorCode.INVALID_ARGUMENT, message);
}
private static double round2(double value) { private static double round2(double value) {
return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP).doubleValue(); return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP).doubleValue();
} }
@@ -11,6 +11,8 @@ import me.iiv.agent.mapper.m0.DistrictDistrictStrategyMapper;
import me.iiv.agent.mapper.m0.MonthlyStatisticsMapper; import me.iiv.agent.mapper.m0.MonthlyStatisticsMapper;
import me.iiv.agent.mapper.m0.PositionsMapper; import me.iiv.agent.mapper.m0.PositionsMapper;
import me.iiv.agent.mapper.m0.ToiletMapper; import me.iiv.agent.mapper.m0.ToiletMapper;
import me.iiv.agent.mcp.McpErrorCode;
import me.iiv.agent.mcp.ToolCallException;
import me.iiv.agent.permission.PermissionService; import me.iiv.agent.permission.PermissionService;
import me.iiv.agent.security.AuthContext; import me.iiv.agent.security.AuthContext;
import me.iiv.agent.security.AuthContextHolder; import me.iiv.agent.security.AuthContextHolder;
@@ -144,24 +146,25 @@ class DistrictPaperPositionDetailToolsTest {
@Test @Test
void rejectsInvalidArgumentsBeforeAnyQuery() { void rejectsInvalidArgumentsBeforeAnyQuery() {
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperPositionDetailQuery( assertThrows(ToolCallException.class, () -> tools.districtPaperPositionDetailQuery(
null, start, end, null, null, null, null, null)); null, start, end, null, null, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperPositionDetailQuery( assertThrows(ToolCallException.class, () -> tools.districtPaperPositionDetailQuery(
0L, start, end, null, null, null, null, null)); 0L, start, end, null, null, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperPositionDetailQuery( assertThrows(ToolCallException.class, () -> tools.districtPaperPositionDetailQuery(
9L, "2026/09/01", end, null, null, null, null, null)); 9L, "2026/09/01", end, null, null, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperPositionDetailQuery( assertThrows(ToolCallException.class, () -> tools.districtPaperPositionDetailQuery(
9L, start, end, "bad", null, null, null, null)); 9L, start, end, "bad", null, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperPositionDetailQuery( assertThrows(ToolCallException.class, () -> tools.districtPaperPositionDetailQuery(
9L, start, end, null, 2, null, null, null)); 9L, start, end, null, 2, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperPositionDetailQuery( assertThrows(ToolCallException.class, () -> tools.districtPaperPositionDetailQuery(
9L, end, start, null, null, null, null, null)); 9L, end, start, null, null, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperPositionDetailQuery( assertThrows(ToolCallException.class, () -> tools.districtPaperPositionDetailQuery(
9L, start, today.plusDays(1).toString(), null, null, null, null, null)); 9L, start, today.plusDays(1).toString(), null, null, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperPositionDetailQuery( assertThrows(ToolCallException.class, () -> tools.districtPaperPositionDetailQuery(
9L, start, end, null, null, -1L, null, null)); 9L, start, end, null, null, -1L, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperPositionDetailQuery( ToolCallException exception = assertThrows(ToolCallException.class,
9L, start, end, null, null, null, null, 0L)); () -> tools.districtPaperPositionDetailQuery(9L, start, end, null, null, null, null, 0L));
assertEquals(McpErrorCode.INVALID_ARGUMENT, exception.getCode());
verifyNoInteractions(districtMapper, monthlyStatisticsMapper, positionsMapper, toiletMapper, verifyNoInteractions(districtMapper, monthlyStatisticsMapper, positionsMapper, toiletMapper,
redisTemplate); redisTemplate);
} }
@@ -170,9 +173,10 @@ class DistrictPaperPositionDetailToolsTest {
void rejectsInvisibleDistrict() { void rejectsInvisibleDistrict() {
when(districtMapper.selectOne(any())).thenReturn(null); when(districtMapper.selectOne(any())).thenReturn(null);
IllegalStateException exception = assertThrows(IllegalStateException.class, ToolCallException exception = assertThrows(ToolCallException.class,
() -> tools.districtPaperPositionDetailQuery(9L, start, end, null, null, null, null, null)); () -> tools.districtPaperPositionDetailQuery(9L, start, end, null, null, null, null, null));
assertEquals("站点不存在或无权访问", exception.getMessage()); assertEquals(McpErrorCode.NOT_FOUND_OR_FORBIDDEN, exception.getCode());
assertEquals("站点不存在或无权访问", exception.getRawMessage());
verify(permissionService).applyDistrictRowFilter(any(), any()); verify(permissionService).applyDistrictRowFilter(any(), any());
verifyNoInteractions(monthlyStatisticsMapper, positionsMapper, toiletMapper); verifyNoInteractions(monthlyStatisticsMapper, positionsMapper, toiletMapper);
} }
@@ -195,9 +199,9 @@ class DistrictPaperPositionDetailToolsTest {
mockDistrictAndDictionary(); mockDistrictAndDictionary();
String wideStart = today.minusDays(40).toString(); String wideStart = today.minusDays(40).toString();
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, ToolCallException exception = assertThrows(ToolCallException.class,
() -> tools.districtPaperPositionDetailQuery(9L, wideStart, end, null, null, null, null, null)); () -> tools.districtPaperPositionDetailQuery(9L, wideStart, end, null, null, null, null, null));
assertTrue(exception.getMessage().contains("最长 31 天")); assertTrue(exception.getRawMessage().contains("最长 31 天"));
verifyNoInteractions(monthlyStatisticsMapper, positionsMapper, toiletMapper); verifyNoInteractions(monthlyStatisticsMapper, positionsMapper, toiletMapper);
} }
@@ -206,9 +210,9 @@ class DistrictPaperPositionDetailToolsTest {
mockDistrictAndDictionary(); mockDistrictAndDictionary();
when(hashOperations.get("Dictionary", "StatisticsDays")).thenReturn("5"); when(hashOperations.get("Dictionary", "StatisticsDays")).thenReturn("5");
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, ToolCallException exception = assertThrows(ToolCallException.class,
() -> tools.districtPaperPositionDetailQuery(9L, start, end, null, null, null, null, null)); () -> tools.districtPaperPositionDetailQuery(9L, start, end, null, null, null, null, null));
assertEquals("查询时间段最大5天", exception.getMessage()); assertEquals("查询时间段最大5天", exception.getRawMessage());
verifyNoInteractions(monthlyStatisticsMapper, positionsMapper, toiletMapper); verifyNoInteractions(monthlyStatisticsMapper, positionsMapper, toiletMapper);
} }