fix(mcp): 修复商城订单工具 CR 问题——时区固定/严格日期校验/权限单测 (REQ-20260909-0004)

- OrderTools: startOrderTime/endOrderTime 解析与输出时间格式化统一固定 Asia/Shanghai;
  endOrderTime 边界改为 < 次日零点(LocalDate.atStartOfDay),不再用 23:59:59 写法
- 日期入参严格校验:正则 ^\d{4}-\d{2}-\d{2}$ 全串匹配 + ISO_LOCAL_DATE/STRICT 解析,
  拒绝 "2026-09-09junk" 尾部垃圾字符与 2026-02-30 等非法日历日
- 新增 PermissionServiceOrderRowFilterTest:applyOrderRowFilter 直接单测,
  覆盖总部不过滤/代理自己+下级+代管/代管物业 OR 分支/销售非负责人收窄/agentId 缺失拒绝
This commit is contained in:
xiaoqu
2026-09-09 18:57:09 +08:00
parent 3d6d70604d
commit af22fd0e45
3 changed files with 291 additions and 26 deletions
@@ -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<Long, String> agentJinideNames = agentJinideNames(orders);
Map<Long, String> propertyJinideNames = propertyJinideNames(orders);
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
List<Map<String, Object>> records = new ArrayList<>();
for (MallGoodsOrder order : orders) {
Map<String, Object> 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<String, Object> 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());
}
}
@@ -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<Map<String, Object>> items = (List<Map<String, Object>>) result.get("items");
@@ -239,7 +245,7 @@ class OrderToolsTest {
List<Map<String, Object>> tracks = (List<Map<String, Object>>) 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<String, Object> 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<LambdaQueryWrapper<MallGoodsOrder>> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(orderMapper).selectList(captor.capture());
LambdaQueryWrapper<MallGoodsOrder> 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));
@@ -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<String, Object, Object> 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<MallGoodsOrder> 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<MallGoodsOrder> wrapper = new LambdaQueryWrapper<>();
permissionService.applyOrderRowFilter(wrapper, user);
String sql = wrapper.getSqlSegment();
Collection<Object> 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<MallGoodsOrder> wrapper = new LambdaQueryWrapper<>();
permissionService.applyOrderRowFilter(wrapper, user);
String sql = wrapper.getSqlSegment();
Collection<Object> 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<MallGoodsOrder> 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<MallGoodsOrder> 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;
}
}