diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/OrderTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/OrderTools.java index d9647f0..bf86d71 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/OrderTools.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/OrderTools.java @@ -25,14 +25,18 @@ import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; -import java.text.ParseException; -import java.text.SimpleDateFormat; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.format.ResolverStyle; import java.util.ArrayList; import java.util.Date; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.regex.Pattern; import java.util.stream.Collectors; /** @@ -44,7 +48,13 @@ public class OrderTools implements ToolObject { private static final int DEFAULT_ROWS = 20; private static final int MAX_ROWS = 100; - private static final String DATE_FORMAT = "yyyy-MM-dd"; + /** 商城业务时区固定 Asia/Shanghai,日期解析与时间格式化统一走该时区。 */ + private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + private static final Pattern DATE_PATTERN = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$"); + private static final DateTimeFormatter DATE_FORMATTER = + DateTimeFormatter.ISO_LOCAL_DATE.withResolverStyle(ResolverStyle.STRICT); + private static final DateTimeFormatter TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(BUSINESS_ZONE); final MallGoodsOrderMapper orderMapper; final MallOrderDetailsMapper orderDetailsMapper; @@ -95,7 +105,7 @@ public class OrderTools implements ToolObject { @ToolParam(required = false, description = "页码,从 1 开始,默认 1") Integer page, @ToolParam(required = false, description = "每页条数,默认 20,上限 100(超出截断为 100)") Integer rows) { Date startTime = parseDate(startOrderTime, "startOrderTime"); - Date endTime = parseDate(endOrderTime, "endOrderTime"); + Date endTimeExclusive = parseNextDayStart(endOrderTime, "endOrderTime"); User user = AuthContextHolder.get().getUser(); int pageNo = page == null || page < 1 ? 1 : page; boolean truncated = rows != null && rows > MAX_ROWS; @@ -124,8 +134,8 @@ public class OrderTools implements ToolObject { if (startTime != null) { wrapper.ge(MallGoodsOrder::getOrderTime, startTime); } - if (endTime != null) { - wrapper.le(MallGoodsOrder::getOrderTime, endOfDay(endTime)); + if (endTimeExclusive != null) { + wrapper.lt(MallGoodsOrder::getOrderTime, endTimeExclusive); } permissionService.applyOrderRowFilter(wrapper, user); @@ -142,13 +152,12 @@ public class OrderTools implements ToolObject { Map agentJinideNames = agentJinideNames(orders); Map propertyJinideNames = propertyJinideNames(orders); - SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); List> records = new ArrayList<>(); for (MallGoodsOrder order : orders) { Map record = new LinkedHashMap<>(); record.put("id", order.getId()); record.put("orderNo", order.getOrderNo()); - record.put("orderTime", order.getOrderTime() == null ? null : sdf.format(order.getOrderTime())); + record.put("orderTime", order.getOrderTime() == null ? null : formatTime(order.getOrderTime())); record.put("orderType", order.getOrderType()); record.put("agentName", order.getAgentName()); record.put("propertyName", order.getPropertyName()); @@ -206,11 +215,10 @@ public class OrderTools implements ToolObject { .eq(MallOrderDetails::getOrderNo, orderNo) .orderByAsc(MallOrderDetails::getId)); - SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); Map record = new LinkedHashMap<>(); record.put("id", order.getId()); record.put("orderNo", order.getOrderNo()); - record.put("orderTime", order.getOrderTime() == null ? null : sdf.format(order.getOrderTime())); + record.put("orderTime", order.getOrderTime() == null ? null : formatTime(order.getOrderTime())); record.put("orderType", order.getOrderType()); record.put("agentId", order.getAgentId()); record.put("agentName", order.getAgentName()); @@ -237,11 +245,11 @@ public class OrderTools implements ToolObject { record.put("warehouseState", order.getWarehouseState()); record.put("orderInvoiceState", order.getOrderInvoiceState()); record.put("payNo", order.getPayNo()); - record.put("payTime", order.getPayTime() == null ? null : sdf.format(order.getPayTime())); - record.put("receiptTime", order.getReceiptTime() == null ? null : sdf.format(order.getReceiptTime())); - record.put("signTime", order.getSignTime() == null ? null : sdf.format(order.getSignTime())); + record.put("payTime", order.getPayTime() == null ? null : formatTime(order.getPayTime())); + record.put("receiptTime", order.getReceiptTime() == null ? null : formatTime(order.getReceiptTime())); + record.put("signTime", order.getSignTime() == null ? null : formatTime(order.getSignTime())); record.put("memo", order.getMemo()); - record.put("createTime", order.getCreateTime() == null ? null : sdf.format(order.getCreateTime())); + record.put("createTime", order.getCreateTime() == null ? null : formatTime(order.getCreateTime())); record.put("reveiveName", address == null ? null : address.getReveiveName()); record.put("reveiveNumber", address == null ? null : address.getReveiveNumber()); record.put("province", address == null ? null : address.getProvince()); @@ -302,7 +310,6 @@ public class OrderTools implements ToolObject { .last("LIMIT 1")); MallOrderLogistics logistics = logisticsList.isEmpty() ? null : logisticsList.get(0); - SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); if (logistics == null) { result.put("logistics", null); } else { @@ -313,7 +320,7 @@ public class OrderTools implements ToolObject { logisticsMap.put("state", logistics.getState()); logisticsMap.put("deliverName", logistics.getDeliverName()); logisticsMap.put("deliverNumber", logistics.getDeliverNumber()); - logisticsMap.put("sendTime", logistics.getModifyTime() == null ? null : sdf.format(logistics.getModifyTime())); + logisticsMap.put("sendTime", logistics.getModifyTime() == null ? null : formatTime(logistics.getModifyTime())); logisticsMap.put("receivingGoodsName", logistics.getReceivingGoodsName()); logisticsMap.put("reveiveName", logistics.getReveiveName()); logisticsMap.put("reveiveNumber", logistics.getReveiveNumber()); @@ -333,7 +340,7 @@ public class OrderTools implements ToolObject { track.put("memo", detai.getMemo()); track.put("state", detai.getState()); track.put("picList", detai.getPicList()); - track.put("createTime", detai.getCreateTime() == null ? null : sdf.format(detai.getCreateTime())); + track.put("createTime", detai.getCreateTime() == null ? null : formatTime(detai.getCreateTime())); tracks.add(track); } result.put("tracks", tracks); @@ -386,20 +393,34 @@ public class OrderTools implements ToolObject { property -> property.getJinideName() == null ? "" : property.getJinideName(), (a, b) -> a)); } + /** 严格校验 yyyy-MM-dd:先正则挡掉尾部垃圾字符,再 STRICT 解析(拒绝 2026-02-30 之类)。 */ private static Date parseDate(String value, String name) { + LocalDate date = parseLocalDate(value, name); + return date == null ? null : Date.from(date.atStartOfDay(BUSINESS_ZONE).toInstant()); + } + + /** 结束边界取「< 次日零点」,避免 23:59:59 写法漏掉当天末秒内的记录。 */ + private static Date parseNextDayStart(String value, String name) { + LocalDate date = parseLocalDate(value, name); + return date == null ? null : Date.from(date.plusDays(1).atStartOfDay(BUSINESS_ZONE).toInstant()); + } + + private static LocalDate parseLocalDate(String value, String name) { if (!StringUtils.hasText(value)) { return null; } - SimpleDateFormat sdf = new SimpleDateFormat(DATE_FORMAT); - sdf.setLenient(false); + String trimmed = value.trim(); + if (!DATE_PATTERN.matcher(trimmed).matches()) { + throw new IllegalArgumentException(name + " 格式必须为 yyyy-MM-dd"); + } try { - return sdf.parse(value.trim()); - } catch (ParseException e) { + return LocalDate.parse(trimmed, DATE_FORMATTER); + } catch (DateTimeParseException e) { throw new IllegalArgumentException(name + " 格式必须为 yyyy-MM-dd"); } } - private static Date endOfDay(Date date) { - return new Date(date.getTime() + 24L * 3600 * 1000 - 1000); + private static String formatTime(Date date) { + return TIME_FORMATTER.format(date.toInstant()); } } diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/OrderToolsTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/OrderToolsTest.java index 0118eb7..ef02e72 100644 --- a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/OrderToolsTest.java +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/OrderToolsTest.java @@ -30,7 +30,9 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.math.BigDecimal; -import java.text.SimpleDateFormat; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; import java.util.Date; import java.util.List; import java.util.Map; @@ -47,6 +49,10 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class OrderToolsTest { + private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + private static final DateTimeFormatter TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(BUSINESS_ZONE); + @Mock MallGoodsOrderMapper orderMapper; @Mock @@ -178,7 +184,7 @@ class OrderToolsTest { assertEquals("13800000000", record.get("reveiveNumber")); assertEquals("南山区", record.get("countyseat")); assertEquals("科技园 1 栋", record.get("address")); - assertEquals(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date(1700000000000L)), + assertEquals(TIME_FORMATTER.format(new Date(1700000000000L).toInstant()), record.get("orderTime")); @SuppressWarnings("unchecked") List> items = (List>) result.get("items"); @@ -239,7 +245,7 @@ class OrderToolsTest { List> tracks = (List>) result.get("tracks"); assertEquals(1, tracks.size()); assertEquals("包裹已到达深圳转运中心", tracks.get(0).get("memo")); - assertEquals(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date(1700000200000L)), + assertEquals(TIME_FORMATTER.format(new Date(1700000200000L).toInstant()), tracks.get(0).get("createTime")); @SuppressWarnings("unchecked") @@ -254,6 +260,47 @@ class OrderToolsTest { verify(permissionService).applyOrderRowFilter(any(), any()); } + @Test + void listAppliesDateRangeInShanghaiZoneWithExclusiveEnd() { + when(orderMapper.selectCount(any())).thenReturn(0L); + when(orderMapper.selectList(any())).thenReturn(List.of()); + + Map result = tools.mallOrderListQuery( + null, null, null, null, null, null, "2026-09-01", "2026-09-09", null, null); + + assertEquals(0L, result.get("total")); + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(orderMapper).selectList(captor.capture()); + LambdaQueryWrapper wrapper = captor.getValue(); + String where = wrapper.getSqlSegment(); + assertTrue(where.contains("order_time >=")); + assertTrue(where.contains("order_time <")); + Date expectedStart = Date.from(LocalDate.of(2026, 9, 1).atStartOfDay(BUSINESS_ZONE).toInstant()); + // 结束边界为「< 次日零点」(Asia/Shanghai),而非 <= 当天 23:59:59 + Date expectedEndExclusive = Date.from(LocalDate.of(2026, 9, 10).atStartOfDay(BUSINESS_ZONE).toInstant()); + assertTrue(wrapper.getParamNameValuePairs().values().contains(expectedStart)); + assertTrue(wrapper.getParamNameValuePairs().values().contains(expectedEndExclusive)); + } + + @Test + void listRejectsDateParamWithTrailingJunk() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> + tools.mallOrderListQuery(null, null, null, null, null, null, "2026-09-09junk", null, null, null)); + assertEquals("startOrderTime 格式必须为 yyyy-MM-dd", ex.getMessage()); + assertThrows(IllegalArgumentException.class, () -> + tools.mallOrderListQuery(null, null, null, null, null, null, null, "2026-9-9", null, null)); + verifyNoInteractions(orderMapper); + } + + @Test + void listRejectsNonexistentCalendarDate() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> + tools.mallOrderListQuery(null, null, null, null, null, null, null, "2026-02-30", null, null)); + assertEquals("endOrderTime 格式必须为 yyyy-MM-dd", ex.getMessage()); + verifyNoInteractions(orderMapper); + } + @Test void detailAndLogisticsRequireOrderNo() { assertThrows(IllegalArgumentException.class, () -> tools.mallOrderDetailQuery(null)); diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceOrderRowFilterTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceOrderRowFilterTest.java new file mode 100644 index 0000000..7b36d56 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceOrderRowFilterTest.java @@ -0,0 +1,197 @@ +package me.iiv.agent.permission; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import me.iiv.agent.entity.m0.Agent; +import me.iiv.agent.entity.m0.AgentManageDistrictAuth; +import me.iiv.agent.entity.m0.Authority; +import me.iiv.agent.entity.m0.Properyt; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.entity.m1.MallGoodsOrder; +import me.iiv.agent.entity.m2.WarehouseAgentAuth; +import me.iiv.agent.mapper.m0.AgentManageDistrictAuthMapper; +import me.iiv.agent.mapper.m0.AgentMapper; +import me.iiv.agent.mapper.m0.AuthorityMapper; +import me.iiv.agent.mapper.m0.ProperytMapper; +import me.iiv.agent.mapper.m2.WarehouseAgentAuthMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +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 org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.Collection; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +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.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** PermissionService.applyOrderRowFilter 直接单测:商城订单(mall_goods_order)行级权限各分支。 */ +@ExtendWith(MockitoExtension.class) +class PermissionServiceOrderRowFilterTest { + + @Mock + AuthorityMapper authorityMapper; + @Mock + AgentMapper agentMapper; + @Mock + WarehouseAgentAuthMapper warehouseAgentAuthMapper; + @Mock + AgentManageDistrictAuthMapper agentManageDistrictAuthMapper; + @Mock + ProperytMapper properytMapper; + @Mock + StringRedisTemplate redisTemplate; + @Mock + HashOperations hashOperations; + + PermissionService permissionService; + + @BeforeEach + void setUp() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), + MallGoodsOrder.class); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), + Properyt.class); + permissionService = new PermissionService(authorityMapper, agentMapper, + warehouseAgentAuthMapper, agentManageDistrictAuthMapper, properytMapper, redisTemplate); + } + + @Test + void headquartersOrderFilterAddsNoBoundary() { + User user = user(46L, 1L, null); + when(authorityMapper.selectById(1L)).thenReturn(authority(1L, 0)); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + permissionService.applyOrderRowFilter(wrapper, user); + + assertTrue(wrapper.getSqlSegment().isBlank()); + } + + @Test + void agentOrderFilterUsesSelfSubordinateAndManagedSaleCompanies() { + User user = user(1000L, 108L, 1L); + when(authorityMapper.selectById(108L)).thenReturn(authority(108L, 1)); + Agent subordinate = new Agent(); + subordinate.setId(2L); + when(agentMapper.selectList(any())).thenReturn(List.of(subordinate)); + WarehouseAgentAuth managedAuth = new WarehouseAgentAuth(); + managedAuth.setManagerAgentId(3L); + when(warehouseAgentAuthMapper.selectList(any())).thenReturn(List.of(managedAuth)); + when(agentManageDistrictAuthMapper.selectList(any())).thenReturn(List.of()); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + permissionService.applyOrderRowFilter(wrapper, user); + + String sql = wrapper.getSqlSegment(); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(sql.contains("sale_company_id IN")); + assertFalse(sql.contains("property_id")); + assertTrue(values.contains(1L)); + assertTrue(values.contains(2L)); + assertTrue(values.contains(3L)); + } + + @Test + void agentOrderFilterAddsManagedPropertyOrBranch() { + User user = user(1000L, 108L, 1L); + when(authorityMapper.selectById(108L)).thenReturn(authority(108L, 1)); + when(agentMapper.selectList(any())).thenReturn(List.of()); + when(warehouseAgentAuthMapper.selectList(any())).thenReturn(List.of()); + AgentManageDistrictAuth districtAuth = new AgentManageDistrictAuth(); + districtAuth.setPropertyId(44L); + when(agentManageDistrictAuthMapper.selectList(any())).thenReturn(List.of(districtAuth)); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + permissionService.applyOrderRowFilter(wrapper, user); + + String sql = wrapper.getSqlSegment(); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(sql.contains("sale_company_id IN")); + assertTrue(sql.contains("property_id IN")); + assertTrue(sql.contains("OR")); + assertTrue(values.contains(1L)); + assertTrue(values.contains(44L)); + } + + @Test + void salesNonLeaderOrderFilterUsesOwnCreatedPropertyIds() { + User user = user(1000L, 108L, 1L); + user.setDeptId(5L); + user.setPostId(7L); + when(authorityMapper.selectById(108L)).thenReturn(authority(108L, 1)); + when(agentMapper.selectList(any())).thenReturn(List.of()); + when(warehouseAgentAuthMapper.selectList(any())).thenReturn(List.of()); + when(redisTemplate.opsForHash()).thenReturn(hashOperations); + when(hashOperations.get("Dictionary", "authDeptId")).thenReturn(",5,"); + when(hashOperations.get("Dictionary", "authDeptPostId")).thenReturn("8,"); + Properyt property = new Properyt(); + property.setId(55L); + when(properytMapper.selectList(any())).thenReturn(List.of(property)); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + permissionService.applyOrderRowFilter(wrapper, user); + + String sql = wrapper.getSqlSegment(); + assertTrue(sql.contains("property_id IN")); + assertFalse(sql.contains("sale_company_id")); + assertTrue(wrapper.getParamNameValuePairs().values().contains(55L)); + verifyNoInteractions(agentManageDistrictAuthMapper); + } + + @Test + void salesNonLeaderWithoutVisiblePropertiesFiltersToZero() { + User user = user(1000L, 108L, 1L); + user.setDeptId(5L); + user.setPostId(7L); + when(authorityMapper.selectById(108L)).thenReturn(authority(108L, 1)); + when(agentMapper.selectList(any())).thenReturn(List.of()); + when(warehouseAgentAuthMapper.selectList(any())).thenReturn(List.of()); + when(redisTemplate.opsForHash()).thenReturn(hashOperations); + when(hashOperations.get("Dictionary", "authDeptId")).thenReturn(",5,"); + when(hashOperations.get("Dictionary", "authDeptPostId")).thenReturn("8,"); + when(properytMapper.selectList(any())).thenReturn(List.of()); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + permissionService.applyOrderRowFilter(wrapper, user); + + String sql = wrapper.getSqlSegment(); + assertTrue(sql.contains("property_id IN")); + assertFalse(sql.contains("sale_company_id")); + assertTrue(wrapper.getParamNameValuePairs().values().contains(0L)); + verifyNoInteractions(agentManageDistrictAuthMapper); + } + + @Test + void agentUserWithoutAgentIdIsDeniedForOrderFilter() { + User user = user(1000L, 108L, null); + when(authorityMapper.selectById(108L)).thenReturn(authority(108L, 1)); + + assertThrows(IllegalStateException.class, + () -> permissionService.applyOrderRowFilter(new LambdaQueryWrapper<>(), user)); + } + + private static Authority authority(Long id, int isAgent) { + Authority authority = new Authority(); + authority.setId(id); + authority.setIsagent(isAgent); + authority.setSoftDelete(0); + return authority; + } + + private static User user(Long id, Long authorityId, Long agentId) { + User user = new User(); + user.setId(id); + user.setAuthorityId(authorityId); + user.setAgentId(agentId); + return user; + } +}