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

Merged
wangheng merged 1 commits from feat/REQ-20261012-0003 into main 2026-10-12 14:09:25 +08:00
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.PositionsMapper;
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.XqTool;
import me.iiv.agent.permission.PermissionService;
@@ -93,7 +95,7 @@ public class DistrictPaperPositionDetailTools implements ToolObject {
@ToolParam(required = false, description = "厕所 id,正整数,精确匹配") Long toiletId) {
if (districtId == null) {
throw new IllegalArgumentException("districtId 必填");
throw invalid("districtId 必填");
}
validatePositive(districtId, "districtId");
validatePositive(buildingId, "buildingId");
@@ -102,16 +104,16 @@ public class DistrictPaperPositionDetailTools implements ToolObject {
LocalDate end = parseDay(endDate, "endDate");
LocalDate today = LocalDate.now(SHANGHAI);
if (start.isAfter(end)) {
throw new IllegalArgumentException("startDate 不能晚于 endDate");
throw invalid("startDate 不能晚于 endDate");
}
if (end.isAfter(today)) {
throw new IllegalArgumentException("endDate 不得晚于今天");
throw invalid("endDate 不得晚于今天");
}
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) {
throw new IllegalArgumentException("deviceType 仅支持 0、1");
throw invalid("deviceType 仅支持 0、1");
}
User user = AuthContextHolder.get().getUser();
@@ -121,17 +123,16 @@ public class DistrictPaperPositionDetailTools implements ToolObject {
permissionService.applyDistrictRowFilter(districtWrapper, user);
DistrictDistrictStrategy district = districtMapper.selectOne(districtWrapper);
if (district == null) {
throw new IllegalStateException("站点不存在或无权访问");
throw new ToolCallException(McpErrorCode.NOT_FOUND_OR_FORBIDDEN, "站点不存在或无权访问");
}
int queryDays = (int) ChronoUnit.DAYS.between(start, end) + 1;
if (queryDays > MAX_QUERY_DAYS) {
throw new IllegalArgumentException(
"查询时间段最长 " + MAX_QUERY_DAYS + " 天(一个月),请修改日期范围");
throw invalid("查询时间段最长 " + MAX_QUERY_DAYS + " 天(一个月),请修改日期范围");
}
int maxDays = statisticsDays();
if (queryDays > maxDays) {
throw new IllegalArgumentException("查询时间段最大" + maxDays + "天");
throw invalid("查询时间段最大" + maxDays + "天");
}
List<MonthlyStatistics> sourceRows = queryRows(districtId, start, end,
@@ -313,21 +314,26 @@ public class DistrictPaperPositionDetailTools implements ToolObject {
private static LocalDate parseDay(String value, String name) {
if (!StringUtils.hasText(value)) {
throw new IllegalArgumentException(name + " 必填,格式 yyyy-MM-dd");
throw invalid(name + " 必填,格式 yyyy-MM-dd");
}
try {
return LocalDate.parse(value.trim());
} 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) {
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) {
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.PositionsMapper;
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.security.AuthContext;
import me.iiv.agent.security.AuthContextHolder;
@@ -144,24 +146,25 @@ class DistrictPaperPositionDetailToolsTest {
@Test
void rejectsInvalidArgumentsBeforeAnyQuery() {
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperPositionDetailQuery(
assertThrows(ToolCallException.class, () -> tools.districtPaperPositionDetailQuery(
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));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperPositionDetailQuery(
assertThrows(ToolCallException.class, () -> tools.districtPaperPositionDetailQuery(
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));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperPositionDetailQuery(
assertThrows(ToolCallException.class, () -> tools.districtPaperPositionDetailQuery(
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));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperPositionDetailQuery(
assertThrows(ToolCallException.class, () -> tools.districtPaperPositionDetailQuery(
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));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperPositionDetailQuery(
9L, start, end, null, null, null, null, 0L));
ToolCallException exception = assertThrows(ToolCallException.class,
() -> tools.districtPaperPositionDetailQuery(9L, start, end, null, null, null, null, 0L));
assertEquals(McpErrorCode.INVALID_ARGUMENT, exception.getCode());
verifyNoInteractions(districtMapper, monthlyStatisticsMapper, positionsMapper, toiletMapper,
redisTemplate);
}
@@ -170,9 +173,10 @@ class DistrictPaperPositionDetailToolsTest {
void rejectsInvisibleDistrict() {
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));
assertEquals("站点不存在或无权访问", exception.getMessage());
assertEquals(McpErrorCode.NOT_FOUND_OR_FORBIDDEN, exception.getCode());
assertEquals("站点不存在或无权访问", exception.getRawMessage());
verify(permissionService).applyDistrictRowFilter(any(), any());
verifyNoInteractions(monthlyStatisticsMapper, positionsMapper, toiletMapper);
}
@@ -195,9 +199,9 @@ class DistrictPaperPositionDetailToolsTest {
mockDistrictAndDictionary();
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));
assertTrue(exception.getMessage().contains("最长 31 天"));
assertTrue(exception.getRawMessage().contains("最长 31 天"));
verifyNoInteractions(monthlyStatisticsMapper, positionsMapper, toiletMapper);
}
@@ -206,9 +210,9 @@ class DistrictPaperPositionDetailToolsTest {
mockDistrictAndDictionary();
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));
assertEquals("查询时间段最大5天", exception.getMessage());
assertEquals("查询时间段最大5天", exception.getRawMessage());
verifyNoInteractions(monthlyStatisticsMapper, positionsMapper, toiletMapper);
}