Sfoglia il codice sorgente

修复多店数字订单号超长并补齐关联识别与共享避碰

qmj 3 giorni fa
parent
commit
d296a8677a
29 ha cambiato i file con 427 aggiunte e 47 eliminazioni
  1. 7 2
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentController.java
  2. 1 1
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentCreateService.java
  3. 2 2
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutAdminQueryService.java
  4. 32 3
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutBatchService.java
  5. 13 3
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutLegacyGuard.java
  6. 10 4
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutOnlineFactService.java
  7. 2 2
      ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java
  8. 63 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderNumberReservationService.java
  9. 6 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java
  10. 6 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java
  11. 6 1
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayController.java
  12. 1 1
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayService.java
  13. 4 1
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentControllerTest.java
  14. 8 7
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentCreateServiceTest.java
  15. 4 1
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentRetryControllerTest.java
  16. 1 1
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutAdminQueryServiceTest.java
  17. 35 3
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutBatchServiceTest.java
  18. 20 1
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutLegacyGuardTest.java
  19. 18 5
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutOnlineFactServiceTest.java
  20. 2 2
      ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderLifecycleServiceTest.java
  21. 76 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderNumberReservationServiceTest.java
  22. 3 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderShOprateControllerTest.java
  23. 52 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/UserOrderNumberReservationTest.java
  24. 19 2
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayControllerTest.java
  25. 6 5
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayServiceCreateTest.java
  26. 2 0
      specs/036-multi-store-checkout/frontend-app-integration.md
  27. 10 0
      specs/036-multi-store-checkout/plan.md
  28. 7 0
      specs/036-multi-store-checkout/spec.md
  29. 11 0
      specs/036-multi-store-checkout/tasks.md

+ 7 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentController.java

@@ -7,6 +7,7 @@ import com.ruoyi.app.omgpay.dto.OmgQueryPaymentRequest;
 import com.ruoyi.app.omgpay.dto.OmgRefundPaymentRequest;
 import com.ruoyi.app.omgpay.dto.OmgRetryPaymentRequest;
 import com.ruoyi.app.order.MultiStoreCheckoutAppCapability;
+import com.ruoyi.app.order.MultiStoreCheckoutLegacyGuard;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.utils.MessageUtils;
@@ -27,6 +28,10 @@ import java.nio.charset.StandardCharsets;
 @RestController
 @RequestMapping("/pay/omg")
 public class OmgPaymentController {
+    // Spring部署时必须注入;普通同号段订单通过真实成员关联与多店区分。
+    @org.springframework.beans.factory.annotation.Autowired
+    private MultiStoreCheckoutLegacyGuard batchGuard;
+
     private static final Logger log = LoggerFactory.getLogger(OmgPaymentController.class);
 
     private final OmgPaymentTokenUserResolver tokenUserResolver;
@@ -95,7 +100,7 @@ public class OmgPaymentController {
                              @RequestBody(required = false) OmgCreatePaymentRequest request) {
         String orderId = request == null ? null : request.getOrderId();
         // 普通单继续兼容旧 App;036 子单即使走原网关创建入口也必须携带新版能力标识。
-        if (orderId != null && orderId.trim().startsWith("MS")
+        if (orderId != null && batchGuard != null && batchGuard.isBatchOrder(orderId.trim())
                 && !MultiStoreCheckoutAppCapability.supports(capability)) {
             return MultiStoreCheckoutAppCapability.upgradeRequired();
         }
@@ -156,7 +161,7 @@ public class OmgPaymentController {
                             @RequestBody(required = false) OmgRetryPaymentRequest request) {
         String orderId = request == null ? null : request.getOrderId();
         // 重试可能创建新的真实网关尝试,不能让旧 App 绕过多店收银能力门禁。
-        if (orderId != null && orderId.trim().startsWith("MS")
+        if (orderId != null && batchGuard != null && batchGuard.isBatchOrder(orderId.trim())
                 && !MultiStoreCheckoutAppCapability.supports(capability)) {
             return MultiStoreCheckoutAppCapability.upgradeRequired();
         }

+ 1 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentCreateService.java

@@ -121,7 +121,7 @@ public class OmgPaymentCreateService {
                         && attempts.markProductionEnvironment(attempt.getId()) != 1) {
                     throw new IllegalStateException("OMG production environment snapshot failed");
                 }
-                if (multiStoreFacts != null && order.getDdId().startsWith("MS")
+                if (multiStoreFacts != null && multiStoreFacts.isBatchOrder(order.getDdId())
                         && !order.getDdId().equals(order.getParentDdId())) {
                     // OMG 表单发给 App 前记下服务端已发行的交易号,缓冲期不接受截止后才新建的表单。
                     multiStoreFacts.recordDispatch(order.getId(), "OMG", order.getPayType(), merchantTradeNo);

+ 2 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutAdminQueryService.java

@@ -34,9 +34,9 @@ public class MultiStoreCheckoutAdminQueryService {
     @Transactional(readOnly = true)
     public void enrichOrders(List<PosOrder> orders) {
         if (orders == null || orders.isEmpty()) return;
-        // 036 单号前缀只用于跳过旧单和未执行迁移的环境;最终归属仍以成员表关联为准。
+        // 数字号可能是普通单:只以本页批量查询的真实成员关联装配多店信息。
         List<Long> candidateIds = orders.stream().filter(order -> order.getId() != null
-                        && order.getDdId() != null && order.getDdId().startsWith("MS"))
+                        && order.getDdId() != null && MultiStoreCheckoutLegacyGuard.mayBeBatchOrder(order.getDdId()))
                 .map(PosOrder::getId).toList();
         if (candidateIds.isEmpty()) return;
         Map<Long, MultiStoreAdminOrderLink> indexed = links.selectAdminOrderLinks(candidateIds).stream()

+ 32 - 3
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutBatchService.java

@@ -35,11 +35,23 @@ import java.util.Date;
 import java.util.HexFormat;
 import java.util.List;
 import java.util.Map;
-import java.util.UUID;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.data.redis.core.script.DefaultRedisScript;
 
 /** 036 专用建批入口;普通 `/createOrder` 不经过此服务,也不继承多店付款期限。 */
 @Service
 public class MultiStoreCheckoutBatchService {
+    // 保持99+13位毫秒父号;Redis原子分配处理多实例同毫秒并发和时钟短暂回退。
+    // 该值只用于生成单号,不决定订单时间、支付期限或多店身份;无TTL避免重启重号。
+    private static final DefaultRedisScript<Long> NEXT_ORDER_MILLIS = new DefaultRedisScript<>("""
+            local now = tonumber(ARGV[1])
+            local last = tonumber(redis.call('GET', KEYS[1]) or '0')
+            if last >= now then now = last + 1 end
+            redis.call('SET', KEYS[1], string.format('%.0f', now))
+            return now
+            """, Long.class);
+    private final StringRedisTemplate orderNumbers;
+    private final OrderNumberReservationService numberReservations;
     private final MultiStoreCheckoutQuoteService quotes;
     private final MultiStoreCheckoutConfigMapper config;
     private final OrderCheckoutMapper checkout;
@@ -53,7 +65,8 @@ public class MultiStoreCheckoutBatchService {
                                           MultiStoreCheckoutConfigMapper config, OrderCheckoutMapper checkout,
                                           OrderParentMapper parents, PosOrderMapper orders,
                                           InfoAddressMapper addresses, PosStoreMapper stores,
-                                          OrderPromotionHelper promotions) {
+                                          OrderPromotionHelper promotions, StringRedisTemplate orderNumbers,
+                                          OrderNumberReservationService numberReservations) {
         this.quotes = quotes;
         this.config = config;
         this.checkout = checkout;
@@ -62,6 +75,8 @@ public class MultiStoreCheckoutBatchService {
         this.addresses = addresses;
         this.stores = stores;
         this.promotions = promotions;
+        this.orderNumbers = orderNumbers;
+        this.numberReservations = numberReservations;
     }
 
     @Transactional(rollbackFor = Exception.class)
@@ -95,7 +110,8 @@ public class MultiStoreCheckoutBatchService {
         }
         String frozenAddress = JSON.toJSONString(address);
         LocalDateTime now = LocalDateTime.now();
-        String parentNo = "MS" + UUID.randomUUID().toString().replace("-", "").toUpperCase();
+        // 父15位、子18位;与普通客户端共享号码占用,已有/并发普通单撞号时换下个毫秒值。
+        String parentNo = allocateParentNumber("MULTI:" + userId + ":" + input.requestKey());
         int total = quote.stores().stream().mapToInt(price -> integer(price.amount())).sum();
         int original = quote.stores().stream().mapToInt(price ->
                 integer(price.originalFoodAmount().add(price.originalFreight()))).sum();
@@ -234,6 +250,19 @@ public class MultiStoreCheckoutBatchService {
                 batch.getOperationDeadlineAt(), batch.getFinalDeadlineAt(), created);
     }
 
+    private String allocateParentNumber(String owner) {
+        for (int attempt = 0; attempt < 8; attempt++) {
+            Long millis = orderNumbers.execute(NEXT_ORDER_MILLIS,
+                    List.of("multi:store:checkout:order:millis"), String.valueOf(System.currentTimeMillis()));
+            if (millis == null || millis < 1_000_000_000_000L || millis > 9_999_999_999_999L) {
+                throw invalid("multi.store.checkout.invalid");
+            }
+            String number = "99" + millis;
+            if (numberReservations.reserveIfUnused(number, owner)) return number;
+        }
+        throw invalid("multi.store.checkout.invalid");
+    }
+
     private MultiStoreBatchCreatedView existingView(OrderCheckoutBatch batch, String requestHash) {
         if (!requestHash.equals(batch.getRequestHash())) throw invalid("multi.store.checkout.invalid");
         List<MultiStoreBatchCreatedView.Member> members = checkout.selectMembersByBatchId(batch.getId()).stream()

+ 13 - 3
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutLegacyGuard.java

@@ -6,7 +6,7 @@ import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.mapper.OrderCheckoutMapper;
 import org.springframework.stereotype.Service;
 
-/** 036 旧单笔入口防绕过;普通订单不访问尚未部署的多店表。 */
+/** 036旧入口防绕过:数字单号与历史MS号都以真实关联确定身份。 */
 @Service
 public class MultiStoreCheckoutLegacyGuard {
     private final OrderCheckoutMapper checkout;
@@ -15,9 +15,19 @@ public class MultiStoreCheckoutLegacyGuard {
         this.checkout = checkout;
     }
 
+    /** 只跳过其他业务号段,99号并不代表多店;MS仅兼容历史已关联订单。 */
+    public static boolean mayBeBatchOrder(String orderNo) {
+        return orderNo != null && (orderNo.startsWith("99") || orderNo.startsWith("MS"));
+    }
+
+    /** 支付能力门禁查询真实子单关联,不能按99前缀拦截普通外卖单。 */
+    public boolean isBatchOrder(String orderNo) {
+        return mayBeBatchOrder(orderNo) && checkout.selectBatchIdByOrderNo(orderNo) != null;
+    }
+
     public void assertSingleCancelAllowed(PosOrder order) {
         if (order != null && order.getId() != null && order.getDdId() != null
-                && order.getDdId().startsWith("MS")
+                && mayBeBatchOrder(order.getDdId())
                 && checkout.selectBatchIdByOrderId(order.getId()) != null) {
             throw new ServiceException(MessageUtils.message("multi.store.checkout.batch.cancel.required"));
         }
@@ -25,7 +35,7 @@ public class MultiStoreCheckoutLegacyGuard {
 
     /** 旧父单详情没有用户过滤;真实 036 批次只能从带归属校验的新详情入口读取。 */
     public boolean isBatchParent(String parentDdId) {
-        return parentDdId != null && parentDdId.startsWith("MS")
+        return mayBeBatchOrder(parentDdId)
                 && checkout.selectBatchIdByParentDdId(parentDdId) != null;
     }
 }

+ 10 - 4
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutOnlineFactService.java

@@ -28,9 +28,15 @@ public class MultiStoreCheckoutOnlineFactService {
         this.cancellation = cancellation;
     }
 
-    /** 仅新批次号才读 036 表,原普通单不增加查询;真实身份仍以成员关联为准。 */
+    /** 同号段普通外卖没有成员关联,不可被当作多店记录支付发起/实收。 */
+    public boolean isBatchOrder(String orderNo) {
+        return MultiStoreCheckoutLegacyGuard.mayBeBatchOrder(orderNo)
+                && checkout.selectBatchIdByOrderNo(orderNo) != null;
+    }
+
+    /** 数字号和历史MS号都先读真实关联,再按批次锁保护支付事实。 */
     public boolean lockBatchIfAny(String orderNo) {
-        if (orderNo == null || !orderNo.startsWith("MS")) return false;
+        if (!MultiStoreCheckoutLegacyGuard.mayBeBatchOrder(orderNo)) return false;
         Long batchId = checkout.selectBatchIdByOrderNo(orderNo);
         if (batchId == null) return false;
         if (checkout.selectBatchByIdForUpdate(batchId) == null) throw invalid();
@@ -39,10 +45,10 @@ public class MultiStoreCheckoutOnlineFactService {
 
     /** 旧 LINE/OMG 入口只可为已逐店选择且未过发起截止的 036 子单创建交易。 */
     public boolean authorizeCreate(String orderNo, Long userId, String payType) {
-        if (orderNo == null || !orderNo.startsWith("MS")) return false;
-        if (userId == null || payType == null) throw invalid();
+        if (!MultiStoreCheckoutLegacyGuard.mayBeBatchOrder(orderNo)) return false;
         Long batchId = checkout.selectBatchIdByOrderNo(orderNo);
         if (batchId == null) return false;
+        if (userId == null || payType == null) throw invalid();
         OrderCheckoutBatch batch = checkout.selectBatchByIdForUpdate(batchId);
         OrderCheckoutMember member = checkout.selectMemberByOrderNo(orderNo);
         if (batch == null || member == null || !userId.equals(batch.getUserId())

+ 2 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java

@@ -175,7 +175,7 @@ public class OrderLifecycleService {
     private void acceptDeliveryByRiderLocked(Long id, Long riderId) {
         PosOrder order = requireOrder(id);
         // 旧单笔入口不能拆抢036配送组;新组必须在同一骑手锁内一次分配全部有效子单。
-        if (checkout != null && order.getDdId() != null && order.getDdId().startsWith("MS")
+        if (checkout != null && order.getDdId() != null && MultiStoreCheckoutLegacyGuard.mayBeBatchOrder(order.getDdId())
                 && checkout.selectGroupIdByOrderId(id) != null) {
             throw new ServiceException(MessageUtils.message("multi.store.checkout.group.accept.required"));
         }
@@ -606,7 +606,7 @@ public class OrderLifecycleService {
     private void assertNotMultiStoreAdminMutation(PosOrder order) {
         // 平台普通状态入口没有批次/组锁;036 成员只能走批次专用状态机。
         if (checkout != null && order.getId() != null && order.getDdId() != null
-                && order.getDdId().startsWith("MS")
+                && MultiStoreCheckoutLegacyGuard.mayBeBatchOrder(order.getDdId())
                 && checkout.selectBatchIdByOrderId(order.getId()) != null) {
             throw new ServiceException(MessageUtils.message("multi.store.checkout.admin.read.only"));
         }

+ 63 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderNumberReservationService.java

@@ -0,0 +1,63 @@
+package com.ruoyi.app.order;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.ruoyi.system.domain.OrderParent;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.mapper.OrderParentMapper;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.data.redis.core.script.DefaultRedisScript;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+import java.time.Duration;
+import java.util.List;
+
+/** 普通外卖与036共用的号码占用;99时间戳格式不能靠各入口自己的序列避免相撞。 */
+@Service
+public class OrderNumberReservationService {
+    private static final Logger log = LoggerFactory.getLogger(OrderNumberReservationService.class);
+    private static final DefaultRedisScript<Long> RELEASE = new DefaultRedisScript<>("""
+            if redis.call('GET', KEYS[1]) == ARGV[1] then
+                return redis.call('DEL', KEYS[1])
+            end
+            return 0
+            """, Long.class);
+    private final StringRedisTemplate redis;
+    private final OrderParentMapper parents;
+    private final PosOrderMapper orders;
+
+    public OrderNumberReservationService(StringRedisTemplate redis, OrderParentMapper parents, PosOrderMapper orders) {
+        this.redis = redis;
+        this.parents = parents;
+        this.orders = orders;
+    }
+
+    public boolean reserveIfUnused(String orderNo, String owner) {
+        if (orderNo == null || orderNo.isBlank() || owner == null || owner.isBlank()) return false;
+        String key = "food:order:no:reservation:" + orderNo;
+        // 用户、商家代客与多店入口均在写父子单前占用同一key;查库不能替代并发互斥。
+        if (!Boolean.TRUE.equals(redis.opsForValue().setIfAbsent(key, owner, Duration.ofDays(1)))) return false;
+        if (TransactionSynchronizationManager.isSynchronizationActive()) {
+            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+                @Override
+                public void afterCompletion(int status) {
+                    if (status == STATUS_COMMITTED) return;
+                    try {
+                        redis.execute(RELEASE, List.of(key), owner);
+                    } catch (RuntimeException failure) {
+                        // 回滚结果不能被缓存清理错误覆盖;过期时间最终释放未提交的号码。
+                        log.warn("Order number rollback reservation cleanup failed");
+                    }
+                }
+            });
+        }
+        // 过期/重启丢缓存后仍核对持久记录;连同只有子单或旧父号引用的情况一起避开。
+        return parents.selectCount(new LambdaQueryWrapper<OrderParent>().eq(OrderParent::getDdId, orderNo)) == 0
+                && orders.selectCount(new LambdaQueryWrapper<PosOrder>().eq(PosOrder::getDdId, orderNo)
+                        .or().eq(PosOrder::getParentDdId, orderNo)) == 0;
+    }
+}

+ 6 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java

@@ -97,6 +97,8 @@ public class PosOrderShOprateController extends BaseController {
     private MerchantStoreAccessService merchantStoreAccessService;
     @Autowired
     private MultiStoreCheckoutLegacyGuard multiStoreCheckoutLegacyGuard;
+    @Autowired
+    private OrderNumberReservationService orderNumberReservations;
 
 
 
@@ -151,6 +153,10 @@ public class PosOrderShOprateController extends BaseController {
 
     //创建父订单
     private void createOrderParent(OrderCreateInput input,Long UserId,String shdz){
+        // 商家代客下单也共用号码占用,避免与普通用户单、多店父单并发重号。
+        if (!orderNumberReservations.reserveIfUnused(input.getDdId().toString(), "MERCHANT:" + UserId)) {
+            throw new ServiceException(MessageUtils.message("promotion.freeDelivery.duplicateOrder"));
+        }
         OrderParent orderParent = new OrderParent();
         orderParent.setUserId(UserId);
         orderParent.setOrderStatus(0L);

+ 6 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java

@@ -58,6 +58,8 @@ import java.util.stream.Collectors;
 @RestController
 @RequestMapping("/system/userOrder")
 public class UserOrderController extends BaseController {
+    @Autowired
+    private OrderNumberReservationService orderNumberReservations;
     @Autowired
     private IOrderParentService orderParentService;
     /** 支付方式闸门(031):全系统唯一支付方式可用性判定入口,禁止在本类重复实现。 */
@@ -274,6 +276,10 @@ public class UserOrderController extends BaseController {
 
     //创建父订单;versionTwoQuote 非空时金额由服务端报价确定(totalAmount=O+R、actualPayAmount=A、totalDiscount=(O+R)-A)
     private void createOrderParent(OrderCreateInput input,Long UserId,String shdz,PromotionCalcResponse versionTwoQuote){
+        // 原客户端仍传99时间戳号;与036先共享占号,避免普通单与多店父号在同毫秒混在一起。
+        if (!orderNumberReservations.reserveIfUnused(input.getDdId().toString(), "ORDINARY:" + UserId)) {
+            throw new ServiceException(MessageUtils.message("promotion.freeDelivery.duplicateOrder"));
+        }
         OrderParent orderParent = new OrderParent();
         orderParent.setUserId(UserId);
         orderParent.setOrderStatus(0L);

+ 6 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayController.java

@@ -2,6 +2,7 @@ package com.ruoyi.app.pay;
 
 import com.ruoyi.app.pay.dto.LinePayOrderRequest;
 import com.ruoyi.app.order.MultiStoreCheckoutAppCapability;
+import com.ruoyi.app.order.MultiStoreCheckoutLegacyGuard;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.utils.MessageUtils;
@@ -26,6 +27,10 @@ import java.util.UUID;
 @RestController
 @RequestMapping("/pay/line")
 public class LinePayController {
+    // Spring部署时必须注入;普通同号段订单通过真实成员关联与多店区分。
+    @org.springframework.beans.factory.annotation.Autowired
+    private MultiStoreCheckoutLegacyGuard batchGuard;
+
     private final LinePayService linePayService;
     private final LinePayAsyncService asyncService;
     private final LinePayReturnPageRenderer pageRenderer;
@@ -59,7 +64,7 @@ public class LinePayController {
             return AjaxResult.error(MessageUtils.message("line.pay.order.required"));
         }
         // 旧 App 仍可支付普通单,但不得绕过 036 专用收银入口继续支付组合子单。
-        if (request.getDdId().trim().startsWith("MS") && !MultiStoreCheckoutAppCapability.supports(capability)) {
+        if (batchGuard != null && batchGuard.isBatchOrder(request.getDdId().trim()) && !MultiStoreCheckoutAppCapability.supports(capability)) {
             return MultiStoreCheckoutAppCapability.upgradeRequired();
         }
         try {

+ 1 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayService.java

@@ -605,7 +605,7 @@ public class LinePayService {
     }
 
     private boolean isMultiStoreCandidate(PosOrder order) {
-        return order != null && order.getDdId() != null && String.valueOf(order.getDdId()).startsWith("MS")
+        return order != null && order.getDdId() != null && multiStoreFacts != null && multiStoreFacts.isBatchOrder(String.valueOf(order.getDdId()))
                 && order.getParentDdId() != null && !order.getParentDdId().equals(order.getDdId());
     }
 

+ 4 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentControllerTest.java

@@ -161,7 +161,10 @@ class OmgPaymentControllerTest {
         OmgPaymentCreateService service = mock(OmgPaymentCreateService.class);
         OmgPaymentController controller = controller(resolver, service);
         OmgCreatePaymentRequest request = new OmgCreatePaymentRequest();
-        request.setOrderId("MS-CHILD-001");
+        request.setOrderId("991789716730512001");
+        com.ruoyi.app.order.MultiStoreCheckoutLegacyGuard guard = mock(com.ruoyi.app.order.MultiStoreCheckoutLegacyGuard.class);
+        when(guard.isBatchOrder("991789716730512001")).thenReturn(true);
+        org.springframework.test.util.ReflectionTestUtils.setField(controller, "batchGuard", guard);
         request.setPaymentMethod("CREDIT");
 
         AjaxResult rejected = controller.create("token", null, request);

+ 8 - 7
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentCreateServiceTest.java

@@ -150,10 +150,11 @@ class OmgPaymentCreateServiceTest {
         MultiStoreCheckoutOnlineFactService batchFacts = mock(MultiStoreCheckoutOnlineFactService.class);
         service = new OmgPaymentCreateService(attempts, credentials, generator, formFactory, batchFacts);
         OmgPaymentOrderSnapshot order = payableOrder();
-        order.setId(100L); order.setDdId("MS-100"); order.setParentDdId("MS-PARENT");
+        order.setId(100L); order.setDdId("991789716730512001"); order.setParentDdId("MS-PARENT");
         order.setPayType("2");
-        when(attempts.selectOrderForUpdate("MS-100")).thenReturn(order);
-        when(batchFacts.authorizeCreate("MS-100", 5L, "2")).thenReturn(true);
+        when(attempts.selectOrderForUpdate("991789716730512001")).thenReturn(order);
+        when(batchFacts.isBatchOrder("991789716730512001")).thenReturn(true);
+        when(batchFacts.authorizeCreate("991789716730512001", 5L, "2")).thenReturn(true);
         when(credentials.getEnabledCredential(77L)).thenReturn(credential());
         when(generator.generate()).thenReturn("OMG12345678901234567");
         when(formFactory.create(anyString(), anyInt(), anyString(), anyString(), anyString(), anyString(),
@@ -162,14 +163,14 @@ class OmgPaymentCreateServiceTest {
         when(attempts.createCreated(anyString(), anyString(), anyLong(), anyString(), anyInt(),
                 anyString(), anyString())).thenReturn(created);
 
-        service.create(5L, "MS-100", OmgPaymentMethod.CREDIT);
+        service.create(5L, "991789716730512001", OmgPaymentMethod.CREDIT);
         var ordered = inOrder(batchFacts, attempts);
-        ordered.verify(batchFacts).lockBatchIfAny("MS-100");
-        ordered.verify(attempts).selectOrderForUpdate("MS-100");
+        ordered.verify(batchFacts).lockBatchIfAny("991789716730512001");
+        ordered.verify(attempts).selectOrderForUpdate("991789716730512001");
         verify(batchFacts).recordDispatch(100L, "OMG", "2", "OMG12345678901234567");
 
         assertThrows(OmgPaymentBusinessException.class,
-                () -> service.create(5L, "MS-100", OmgPaymentMethod.APPLE_PAY));
+                () -> service.create(5L, "991789716730512001", OmgPaymentMethod.APPLE_PAY));
     }
 
     @Test

+ 4 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentRetryControllerTest.java

@@ -114,7 +114,10 @@ class OmgPaymentRetryControllerTest {
         OmgPaymentController controller = controller(mock(OmgPaymentTokenUserResolver.class),
                 mock(OmgPaymentRetryService.class));
         OmgRetryPaymentRequest request = new OmgRetryPaymentRequest();
-        request.setOrderId("MS-CHILD-001");
+        request.setOrderId("991789716730512001");
+        com.ruoyi.app.order.MultiStoreCheckoutLegacyGuard guard = mock(com.ruoyi.app.order.MultiStoreCheckoutLegacyGuard.class);
+        when(guard.isBatchOrder("991789716730512001")).thenReturn(true);
+        org.springframework.test.util.ReflectionTestUtils.setField(controller, "batchGuard", guard);
         request.setPaymentMethod("CREDIT");
         try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
             messages.when(() -> MessageUtils.message(anyString())).thenAnswer(call -> call.getArgument(0));

+ 1 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutAdminQueryServiceTest.java

@@ -32,7 +32,7 @@ class MultiStoreCheckoutAdminQueryServiceTest {
         ordinary.setDdId("FD1");
         PosOrder member = new PosOrder();
         member.setId(2L);
-        member.setDdId("MS123001");
+        member.setDdId("991789716730512001");
         MultiStoreAdminOrderLink link = new MultiStoreAdminOrderLink();
         link.setOrderId(2L);
         link.setBatchId(7L);

+ 35 - 3
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutBatchServiceTest.java

@@ -22,6 +22,8 @@ import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.MockedStatic;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.data.redis.core.script.RedisScript;
 
 import java.math.BigDecimal;
 import java.util.List;
@@ -31,6 +33,7 @@ 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.anyString;
+import static org.mockito.ArgumentMatchers.anyList;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mockStatic;
@@ -48,11 +51,16 @@ class MultiStoreCheckoutBatchServiceTest {
     private final InfoAddressMapper addresses = mock(InfoAddressMapper.class);
     private final PosStoreMapper stores = mock(PosStoreMapper.class);
     private final OrderPromotionHelper promotions = mock(OrderPromotionHelper.class);
+    private final StringRedisTemplate orderNumbers = mock(StringRedisTemplate.class);
+    private final OrderNumberReservationService numberReservations = mock(OrderNumberReservationService.class);
 
     @BeforeEach
     void setUp() {
         messages = mockStatic(MessageUtils.class);
         messages.when(() -> MessageUtils.message(anyString())).thenAnswer(call -> call.getArgument(0));
+        when(orderNumbers.execute(org.mockito.ArgumentMatchers.<RedisScript<Long>>any(), anyList(), anyString()))
+                .thenReturn(1789716730512L);
+        when(numberReservations.reserveIfUnused(anyString(), anyString())).thenReturn(true);
     }
 
     @AfterEach
@@ -62,7 +70,7 @@ class MultiStoreCheckoutBatchServiceTest {
 
     private MultiStoreCheckoutBatchService subject() {
         return new MultiStoreCheckoutBatchService(quotes, config, checkout, parents, orders,
-                addresses, stores, promotions);
+                addresses, stores, promotions, orderNumbers, numberReservations);
     }
 
     @Test
@@ -76,8 +84,15 @@ class MultiStoreCheckoutBatchServiceTest {
         verify(parents, never()).insert(any(OrderParent.class));
     }
 
-    @Test
-    void createsPendingBatchWithServerPricesNoDefaultCodAndFrozenAddress() {
+    @org.junit.jupiter.params.ParameterizedTest
+    @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
+    void createsPendingBatchWithServerPricesNoDefaultCodAndFrozenAddress(boolean firstNumberOccupied) {
+        if (firstNumberOccupied) {
+            when(orderNumbers.execute(org.mockito.ArgumentMatchers.<RedisScript<Long>>any(), anyList(), anyString()))
+                    .thenReturn(1789716730511L, 1789716730512L);
+            when(numberReservations.reserveIfUnused(org.mockito.ArgumentMatchers.eq("991789716730511"), anyString()))
+                    .thenReturn(false);
+        }
         arrangeQuote();
         when(config.selectCurrentForUpdate()).thenReturn(policy());
         InfoAddress address = new InfoAddress();
@@ -117,12 +132,28 @@ class MultiStoreCheckoutBatchServiceTest {
                 "retry-1", "quote-1", List.of(1L, 2L)), 42L);
 
         assertEquals(99L, result.batchId());
+        assertEquals("991789716730512", result.parentOrderNo());
         assertEquals("PENDING", result.checkoutStatus());
         assertEquals(172, result.totalAmount());
         assertEquals(2, result.members().size());
+        // 真实库pos_order.dd_id上限30,积分流水dd_id上限20;预留3位子单序号。
+        assertTrue(result.parentOrderNo().matches("99[0-9]{13}"));
+        assertTrue(result.parentOrderNo().length() <= 17,
+                "父单号须为子单预留序号,兼容现有最短20字符订单关联字段");
+        var parentCaptured = org.mockito.ArgumentCaptor.forClass(OrderParent.class);
+        verify(parents).insert(parentCaptured.capture());
+        assertEquals(result.parentOrderNo(), parentCaptured.getValue().getDdId());
+        var batchCaptured = org.mockito.ArgumentCaptor.forClass(OrderCheckoutBatch.class);
+        verify(checkout).insertOrGetBatch(batchCaptured.capture());
+        assertEquals(result.parentOrderNo(), batchCaptured.getValue().getParentDdId());
+        assertEquals(2, result.members().stream().map(member -> member.orderNo()).distinct().count());
         var captured = org.mockito.ArgumentCaptor.forClass(PosOrder.class);
         verify(orders, org.mockito.Mockito.times(2)).insert(captured.capture());
+        int sequence = 1;
         for (PosOrder order : captured.getAllValues()) {
+            assertTrue(order.getDdId().length() <= 20, "子单号不能超过现有订单关联字段上限");
+            assertEquals(result.parentOrderNo(), order.getParentDdId());
+            assertEquals(result.parentOrderNo() + String.format("%03d", sequence++), order.getDdId());
             assertEquals(86, order.getAmount());
             assertEquals(36.0, order.getFreight());
             assertEquals(new BigDecimal("36"), order.getCustomerDeliveryFee());
@@ -156,6 +187,7 @@ class MultiStoreCheckoutBatchServiceTest {
                 "retry-1", "quote-1", List.of(1L, 2L)), 42L);
 
         assertEquals("MS-EXISTING", result.parentOrderNo());
+        org.mockito.Mockito.verifyNoInteractions(orderNumbers);
         verify(quotes, never()).loadSnapshot(any(), any());
         verify(parents, never()).insert(any(OrderParent.class));
     }

+ 20 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutLegacyGuardTest.java

@@ -22,7 +22,7 @@ class MultiStoreCheckoutLegacyGuardTest {
     @Test
     void rejectsSingleCancelForRealBatchMember() {
         PosOrder order = new PosOrder();
-        order.setId(101L); order.setDdId("MS-101");
+        order.setId(101L); order.setDdId("991789716730512001");
         when(checkout.selectBatchIdByOrderId(101L)).thenReturn(9L);
         try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
             messages.when(() -> MessageUtils.message("multi.store.checkout.batch.cancel.required"))
@@ -31,6 +31,25 @@ class MultiStoreCheckoutLegacyGuardTest {
         }
     }
 
+    @Test
+    void numericOrdinaryOrderKeepsSingleCancelAndParentDetailWithoutBatchAssociation() {
+        PosOrder order = new PosOrder();
+        order.setId(101L); order.setDdId("991789716730512");
+        when(checkout.selectBatchIdByOrderId(101L)).thenReturn(null);
+        when(checkout.selectBatchIdByParentDdId("991789716730512")).thenReturn(null);
+        when(checkout.selectBatchIdByOrderNo("991789716730512")).thenReturn(null);
+        guard.assertSingleCancelAllowed(order);
+        assertFalse(guard.isBatchParent("991789716730512"));
+        assertFalse(guard.isBatchOrder("991789716730512"));
+        verify(checkout).selectBatchIdByOrderId(101L);
+    }
+
+    @Test
+    void numericParentIsProtectedByItsRealBatchAssociation() {
+        when(checkout.selectBatchIdByParentDdId("991789716730512")).thenReturn(9L);
+        org.junit.jupiter.api.Assertions.assertTrue(guard.isBatchParent("991789716730512"));
+    }
+
     @Test
     void ordinaryOrderNeverQueriesUnmigratedBatchTable() {
         PosOrder order = new PosOrder();

+ 18 - 5
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutOnlineFactServiceTest.java

@@ -69,17 +69,30 @@ class MultiStoreCheckoutOnlineFactServiceTest {
         LocalDateTime now = LocalDateTime.now();
         arrange(List.of(member(1, "ONLINE_PENDING"), member(2, "UNSELECTED")),
                 "PENDING", now.plusMinutes(1), now.plusMinutes(6));
-        when(checkout.selectBatchIdByOrderNo("MS-100")) .thenReturn(99L);
-        when(checkout.selectMemberByOrderNo("MS-100")).thenReturn(member(1, "ONLINE_PENDING"));
+        when(checkout.selectBatchIdByOrderNo("991789716730512001")) .thenReturn(99L);
+        when(checkout.selectMemberByOrderNo("991789716730512001")).thenReturn(member(1, "ONLINE_PENDING"));
         OrderCheckoutBatch batch = checkout.selectBatchByIdForUpdate(99L);
         batch.setUserId(42L);
-        assertEquals(true, subject().authorizeCreate("MS-100", 42L, "3"));
-        assertThrows(ServiceException.class, () -> subject().authorizeCreate("MS-100", 43L, "3"));
+        assertEquals(true, subject().authorizeCreate("991789716730512001", 42L, "3"));
+        assertThrows(ServiceException.class, () -> subject().authorizeCreate("991789716730512001", 43L, "3"));
         batch.setOperationDeadlineAt(now.minusSeconds(1));
-        assertThrows(ServiceException.class, () -> subject().authorizeCreate("MS-100", 42L, "3"));
+        assertThrows(ServiceException.class, () -> subject().authorizeCreate("991789716730512001", 42L, "3"));
         assertEquals(false, subject().authorizeCreate("OLD-100", 42L, "3"));
     }
 
+    @Test
+    void numericMemberLocksItsBatchButOrdinarySamePrefixDoesNot() {
+        OrderCheckoutBatch batch = new OrderCheckoutBatch(); batch.setId(99L);
+        when(checkout.selectBatchIdByOrderNo("991789716730512001")).thenReturn(99L);
+        when(checkout.selectBatchByIdForUpdate(99L)).thenReturn(batch);
+        when(checkout.selectBatchIdByOrderNo("991789716730512")).thenReturn(null);
+        assertEquals(true, subject().lockBatchIfAny("991789716730512001"));
+        assertEquals(true, subject().isBatchOrder("991789716730512001"));
+        assertEquals(false, subject().lockBatchIfAny("991789716730512"));
+        assertEquals(false, subject().isBatchOrder("991789716730512"));
+        assertEquals(false, subject().authorizeCreate("991789716730512", 42L, "3"));
+    }
+
     @Test
     void verifiedPaidFactWaitsForOtherStoreThenActivatesOnce() {
         LocalDateTime now = LocalDateTime.now();

+ 2 - 2
ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderLifecycleServiceTest.java

@@ -88,7 +88,7 @@ class OrderLifecycleServiceTest {
         ReflectionTestUtils.setField(service, "checkout", checkout);
         PosOrder member = deliveryOrder();
         member.setId(10L);
-        member.setDdId("MS123001");
+        member.setDdId("991789716730512001");
         when(posOrderService.getById(10L)).thenReturn(member);
         when(checkout.selectBatchIdByOrderId(10L)).thenReturn(7L);
 
@@ -301,7 +301,7 @@ class OrderLifecycleServiceTest {
         OrderCheckoutMapper checkout = mock(OrderCheckoutMapper.class);
         ReflectionTestUtils.setField(service, "checkout", checkout);
         PosOrder order = deliveryOrder();
-        order.setDdId("MS-BATCH-001");
+        order.setDdId("991789716730512001");
         order.setState(0L);
         order.setDeliveryStatus(0L);
         order.setQsId(null);

+ 76 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderNumberReservationServiceTest.java

@@ -0,0 +1,76 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.system.mapper.OrderParentMapper;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+import java.time.Duration;
+import java.util.concurrent.ConcurrentHashMap;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+class OrderNumberReservationServiceTest {
+    private final StringRedisTemplate redis = mock(StringRedisTemplate.class);
+    private final ValueOperations<String, String> values = mock(ValueOperations.class);
+    private final OrderParentMapper parents = mock(OrderParentMapper.class);
+    private final PosOrderMapper orders = mock(PosOrderMapper.class);
+    private final ConcurrentHashMap<String, String> occupied = new ConcurrentHashMap<>();
+    private final OrderNumberReservationService service = new OrderNumberReservationService(redis, parents, orders);
+
+    @BeforeEach
+    void setUp() {
+        when(redis.opsForValue()).thenReturn(values);
+        // 模拟Redis SET NX的原子占用契约;普通单和多店必须共享同一命名空间。
+        when(values.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenAnswer(call ->
+                occupied.putIfAbsent(call.getArgument(0), call.getArgument(1)) == null);
+        when(parents.selectCount(any())).thenReturn(0L);
+        when(orders.selectCount(any())).thenReturn(0L);
+    }
+
+    @Test
+    void ordinaryReservationBlocksConcurrentMultiStoreWithSameTimestamp() {
+        assertTrue(service.reserveIfUnused("991789716730512", "ORDINARY:42"));
+        assertFalse(service.reserveIfUnused("991789716730512", "MULTI:99"));
+        assertTrue(service.reserveIfUnused("991789716730513", "MULTI:99"));
+    }
+
+    @Test
+    void multiStoreReservationBlocksConcurrentOrdinaryWithSameTimestamp() {
+        assertTrue(service.reserveIfUnused("991789716730512", "MULTI:99"));
+        assertFalse(service.reserveIfUnused("991789716730512", "ORDINARY:42"));
+    }
+
+    @Test
+    void existingOrdinaryParentCannotBeAllocatedAfterRedisReservationExpired() {
+        when(parents.selectCount(any())).thenReturn(1L);
+        assertFalse(service.reserveIfUnused("991789716730512", "MULTI:99"));
+    }
+
+    @Test
+    void existingChildOrParentReferenceCannotBeAllocatedWithoutAParentRow() {
+        when(orders.selectCount(any())).thenReturn(1L);
+        assertFalse(service.reserveIfUnused("991789716730512", "MULTI:99"));
+    }
+
+    @Test
+    void rollbackSchedulesOnlyOwnerCheckedRelease() {
+        TransactionSynchronizationManager.initSynchronization();
+        try {
+            assertTrue(service.reserveIfUnused("991789716730512", "ORDINARY:42"));
+            var synchronizations = TransactionSynchronizationManager.getSynchronizations();
+            assertEquals(1, synchronizations.size());
+            synchronizations.get(0).afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK);
+            verify(redis).execute(any(org.springframework.data.redis.core.script.RedisScript.class),
+                    eq(java.util.List.of("food:order:no:reservation:991789716730512")), eq("ORDINARY:42"));
+        } finally {
+            TransactionSynchronizationManager.clearSynchronization();
+        }
+    }
+}

+ 3 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderShOprateControllerTest.java

@@ -113,6 +113,9 @@ class PosOrderShOprateControllerTest {
     @Test
     void createOrderPersistsMerchantSourceBeforeReturning() {
         PosOrderShOprateController controller = new PosOrderShOprateController();
+        OrderNumberReservationService reservations = mock(OrderNumberReservationService.class);
+        when(reservations.reserveIfUnused(anyString(), anyString())).thenReturn(true);
+        ReflectionTestUtils.setField(controller, "orderNumberReservations", reservations);
         // 新增材料门禁为真实依赖;普通商家仍由原审核机制管理。
         ReflectionTestUtils.setField(controller, "stallQualificationGateService",
                 new com.ruoyi.system.service.StallQualificationGateService(

+ 52 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/UserOrderNumberReservationTest.java

@@ -0,0 +1,52 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.order.dto.OrderCreateInput;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.OrderParent;
+import com.ruoyi.system.service.IOrderParentService;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+class UserOrderNumberReservationTest {
+    @Test
+    void merchantParentChecksSharedReservationBeforePersisting() {
+        PosOrderShOprateController controller = new PosOrderShOprateController();
+        OrderNumberReservationService reservations = mock(OrderNumberReservationService.class);
+        IOrderParentService parents = mock(IOrderParentService.class);
+        ReflectionTestUtils.setField(controller, "orderNumberReservations", reservations);
+        ReflectionTestUtils.setField(controller, "orderParentService", parents);
+        OrderCreateInput input = new OrderCreateInput(); input.setDdId("991789716730512");
+        try (var messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message(anyString())).thenAnswer(call -> call.getArgument(0));
+            assertThrows(ServiceException.class, () -> ReflectionTestUtils.invokeMethod(controller,
+                    "createOrderParent", input, 42L, "{}"));
+            verifyNoInteractions(parents);
+            when(reservations.reserveIfUnused("991789716730512", "MERCHANT:42")).thenReturn(true);
+            ReflectionTestUtils.invokeMethod(controller, "createOrderParent", input, 42L, "{}");
+            verify(parents).save(any(OrderParent.class));
+        }
+    }
+    @Test
+    void ordinaryParentChecksSharedReservationBeforePersisting() {
+        UserOrderController controller = new UserOrderController();
+        OrderNumberReservationService reservations = mock(OrderNumberReservationService.class);
+        IOrderParentService parents = mock(IOrderParentService.class);
+        ReflectionTestUtils.setField(controller, "orderNumberReservations", reservations);
+        ReflectionTestUtils.setField(controller, "orderParentService", parents);
+        OrderCreateInput input = new OrderCreateInput(); input.setDdId("991789716730512");
+        try (var messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message(anyString())).thenAnswer(call -> call.getArgument(0));
+            assertThrows(ServiceException.class, () -> ReflectionTestUtils.invokeMethod(controller,
+                    "createOrderParent", input, 42L, "{}", null));
+            verifyNoInteractions(parents);
+            when(reservations.reserveIfUnused("991789716730512", "ORDINARY:42")).thenReturn(true);
+            ReflectionTestUtils.invokeMethod(controller, "createOrderParent", input, 42L, "{}", null);
+            verify(parents).save(any(OrderParent.class));
+        }
+    }
+}

+ 19 - 2
ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayControllerTest.java

@@ -33,7 +33,10 @@ class LinePayControllerTest {
     @Test
     void multiStoreChildCannotCreatePaymentWithoutNewAppCapability() {
         LinePayOrderRequest request = new LinePayOrderRequest();
-        request.setDdId("MS-CHILD-001");
+        request.setDdId("991789716730512001");
+        com.ruoyi.app.order.MultiStoreCheckoutLegacyGuard guard = mock(com.ruoyi.app.order.MultiStoreCheckoutLegacyGuard.class);
+        when(guard.isBatchOrder("991789716730512001")).thenReturn(true);
+        org.springframework.test.util.ReflectionTestUtils.setField(controller, "batchGuard", guard);
         try (org.mockito.MockedStatic<MessageUtils> messages = org.mockito.Mockito.mockStatic(MessageUtils.class)) {
             messages.when(() -> MessageUtils.message("multi.store.checkout.upgrade.required"))
                     .thenReturn("upgrade");
@@ -41,7 +44,21 @@ class LinePayControllerTest {
             assertEquals("UPGRADE_REQUIRED", rejected.get("errorCode"));
             org.mockito.Mockito.verifyNoInteractions(linePayService);
             controller.create(JwtUtil.token("21", "user"), MultiStoreCheckoutAppCapability.BATCH_CARDS_V1, request);
-            verify(linePayService).create(21L, "MS-CHILD-001");
+            verify(linePayService).create(21L, "991789716730512001");
+        }
+    }
+
+    @Test
+    void ordinaryNumericOrderWithoutBatchAssociationDoesNotRequireCapability() {
+        LinePayOrderRequest request = new LinePayOrderRequest();
+        request.setDdId("991789716730512");
+        com.ruoyi.app.order.MultiStoreCheckoutLegacyGuard guard = mock(com.ruoyi.app.order.MultiStoreCheckoutLegacyGuard.class);
+        org.springframework.test.util.ReflectionTestUtils.setField(controller, "batchGuard", guard);
+        try (org.mockito.MockedStatic<MessageUtils> messages = org.mockito.Mockito.mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message(org.mockito.ArgumentMatchers.anyString()))
+                    .thenAnswer(call -> call.getArgument(0));
+            controller.create(JwtUtil.token("21", "user"), null, request);
+            verify(linePayService).create(21L, "991789716730512");
         }
     }
 

+ 6 - 5
ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayServiceCreateTest.java

@@ -105,20 +105,21 @@ class LinePayServiceCreateTest {
                 mock(IPosOrderLineRefundService.class), linePayClient, mock(LinePayFactService.class),
                 null, null, null, batchFacts);
         PosOrder order = order();
-        order.setDdId("MS-100"); order.setParentDdId("MS-PARENT");
+        order.setDdId("991789716730512001"); order.setParentDdId("MS-PARENT");
         when(orderService.getOne(any(Wrapper.class))).thenReturn(order);
         when(orderService.count(any(Wrapper.class))).thenReturn(2L);
-        when(batchFacts.authorizeCreate("MS-100", 101L, "3")).thenReturn(true);
+        when(batchFacts.isBatchOrder("991789716730512001")).thenReturn(true);
+        when(batchFacts.authorizeCreate("991789716730512001", 101L, "3")).thenReturn(true);
         when(credentialService.getEnabledCurrent(9L)).thenReturn(credential());
         PosOrderLinePayment requesting = payment("REQUESTING");
-        requesting.setDdId("MS-100");
-        when(paymentService.createRequesting(eq("MS-100"), anyString(), eq(5L), eq(9L),
+        requesting.setDdId("991789716730512001");
+        when(paymentService.createRequesting(eq("991789716730512001"), anyString(), eq(5L), eq(9L),
                 eq(280), eq("TWD"), eq("ONLINE"), any())).thenReturn(requesting);
         when(linePayClient.request(any(), any())).thenReturn(new LinePayResponse(
                 200, "1104", "Rejected", null, null, "{}"));
         when(paymentService.markTerminal(70L, 0L, "REQUESTING", "FAILED")).thenReturn(1);
 
-        service.create(101L, "MS-100");
+        service.create(101L, "991789716730512001");
 
         var calls = org.mockito.Mockito.inOrder(batchFacts, linePayClient);
         calls.verify(batchFacts).recordDispatch(10L, "LINE", "3", "LP-100-01");

+ 2 - 0
specs/036-multi-store-checkout/frontend-app-integration.md

@@ -12,6 +12,8 @@
 
 配送距离和费用由服务端统一计算,App直接展示报价结果,不自行计算道路距离或运费;创建批次时服务端仍会重新校验。2026-10-20后端批量路线优化不改变本文请求参数、响应字段或调用顺序。
 
+父单号和子单号沿用`99`开头的纯数字格式(父15位、子18位),接口字段类型仍为字符串;App必须原样展示和回传,禁止转为JavaScript Number或用前缀判断多店身份,多店身份以服务端卡片/批次字段为准。
+
 ## 1. 功能范围
 
 当前支持:

+ 10 - 0
specs/036-multi-store-checkout/plan.md

@@ -419,3 +419,13 @@ OMG 实现按项目纪律将每个接口的代码、测试源码、规格与 SQL
 ## 2026-10-20 联调修复:商品审核状态
 
 T033仅修正MultiStoreCheckoutQuoteService的商品可售判断,与单店完整报价统一为to_examine=1且stacking_up=0;补充解释字段含义的注释。先用测试库只读数据复现203店9135/9136和168店9115/9078及规格43/15/7,再用回归测试覆盖审核通过、未通过及NULL。现有测试商品补齐真实审核状态,保留规格/促销金额核验。前端参数、国际化错误契约、数据库和已创建订单快照不变。
+
+
+## 2026-10-20 T034:数字订单号与真实关联识别
+
+用户明确父单继续使用99开头的原外卖格式,禁止UUID。原MS+32位UUID再拼3位序号产生37位,超过测试库pos_order.dd_id的30位,也超过积分关联字段20位;通过修正生成源解决,不扩大数据库字段。
+
+1. 在现有建批服务注入StringRedisTemplate,用一个原子Lua将当前毫秒与已分配值取大(同毫秒时加1),生成99+毫秒数的父号;子号沿用父号+三位序号。沿用现有Redis基础设施,无新增配置;序列不可用则失败,不回退UUID或截断数字。现有requestKey幂等查询仍先于序列分配。
+2. 用户普通下单、商家代客下单与多店建批共用OrderNumberReservationService,在任何父子单落库前对同一号码SET NX占用,再查父表和子表的实际记录。占用保留1天,事务回滚后按owner比较删除;缓存过期或重启后仍由持久记录防止复用。多店号码已占用时取下一毫秒(最多8次),普通下单重复号沿原国际化重复单错误拒绝,不改前端参数。
+3. 修正所有MS快速筛选以兼容数字号,资格始终由order_checkout_member/order_checkout_batch关联决定;新增控制器真实关联门禁,普通99号不能被前缀误判。保留旧MS关联读取;不改变订单、支付、退款、权限与取送状态规则。
+4. 回归共享号码双向冲突与回滚释放、用户和商家入口写前占用、建批长度、父子一致/幂等、数字成员支付与回调、旧取消/接单/平台状态防绕过、组合列表以及普通同号段兼容;全部实现后统一运行JDK21回归和构建。App单号字段仍是string,不能转JS Number。

+ 7 - 0
specs/036-multi-store-checkout/spec.md

@@ -325,3 +325,10 @@
 **SC-037**:N家参与门店一个报价阶段只有一次外部矩阵请求,圈外店不参加请求;请求方向和门店映射正确,原运价、折扣及商家免运金额断言不变。
 **SC-038**:乱序响应仍回到原门店顺序;部分失败、缺失/重复/越界索引、非法距离、HTTP异常和配置缺失均拒绝,不生成可用报价token。
 **SC-039**:单店完整配送报价使用1×1矩阵,自取/堂食仍不查路线;前端原请求和返回结构保持兼容。单元测试与构建不等于已验证Google项目开通/配额/真实地图里程。
+
+
+## 2026-10-20 联调修复:沿用99纯数字外卖订单号
+
+- **FR-052** 按用户确认,新建多店父单沿用`99`+13位毫秒序列的15位纯数字单号;子单为父号+3位店序号,共18位,禁止UUID或新MS前缀。服务端通过已有Redis原子毫秒序列分配,处理同毫秒并发及进程重启,并与普通用户单、商家代客单共用号码占用及持久记录检查,避免同号空间撞号;事务回滚释放占用,不新增业务配置或数据库列。单号以字符串返回和回传,序号不限制骑手取送顺序。
+- **FR-053** LINE/OMG创建、重试、实收回调、取消、骑手接单、平台查询和旧父单详情均以真实批次/成员关联确认多店身份;普通99订单无关联时仍沿旧流程,不得仅凭99前缀要求升级或套用多店状态机。历史MS号只兼容读取,不再生成;最短关联字段20字符、pos_order.dd_id为30字符,均不可通过截断存储绕过。
+- **SC-040** 验证父15位、子18位、前缀99、全部纯数字、父子/批次响应一致及子号互异;幂等重试不重新编号。数字多店单沿真实关联通过支付、记录实收并保持旧入口防绕过;普通同号段订单保持原支付/取消/列表行为。无需前端新增参数或数据库迁移。

+ 11 - 0
specs/036-multi-store-checkout/tasks.md

@@ -129,3 +129,14 @@ T001→T002→T003→T005;T004 在 T005 前。T005→T006–T010→T011–T013
 - [x] T033 修正多店报价将审核通过商品误拒绝的问题;对齐单店to_examine=1且stacking_up=0,注释字段语义;补充本次4商品及3规格值请求回归,验证未通过/NULL不得进入促销算价,更新旧测试夹具;运行JDK21报价/建批回归与模块构建,保留既有未提交文档修改,不改SQL或前端参数。
 
 T033验证:测试库只读确认4商品均to_examine=1、stacking_up=0,规格43/15/7均启用且归属正确。红测复现审核通过商品误拒绝及未通过商品进入促销算价;修复后JDK21执行`mvn -pl ruoyi-admin -am verify -Dtest=MultiStoreCheckoutQuoteServiceTest,FoodDeliveryPromotionQuoteServiceTest,MultiStoreCheckoutBatchServiceTest -Dsurefire.failIfNoSpecifiedTests=false`,28项通过、失败/错误/跳过0,模块打包成功。`git diff --check`通过;未修改数据库,未部署远端、未使用用户token发起真实HTTP报价,未自动提交推送。
+
+
+- [x] T034 按用户要求用99开头的15位纯数字父号和18位子号替换UUID生成;复用Redis原子毫秒序列,修正支付/回调/取消/接单/平台查询及能力头的MS专属判断,始终查真实关联;补数字多店/普通同号段、长度、父子对应及重试回归;保留原接口参数与旧MS读取,统一验证后记录结果,不改SQL、不创建分支。
+
+
+T034验证与交付(2026-10-20):
+
+- 父号15位、子号18位,99前缀纯数字;普通用户、商家代客、多店建批在写父子单前共用SET NX占号与持久记录复核,多店遇已占号最多递增重试8次,事务回滚按owner释放。实际批次关联决定支付、取消、接单及列表行为,历史MS仍可读取;新号不再使用UUID。
+- 失败先行测试复现原37位子号超长、数字号漏过原MS判断;新增共享号码双向互斥、持久记录存在、事务回滚清理、用户及商家入口写前检查和占号重试回归。最终JDK21执行`mvn -pl ruoyi-admin -am verify`通过:ruoyi-system 166项、ruoyi-admin 1248项,共1414项,失败/错误/跳过均0,所有模块构建成功。`git diff --check`通过。
+- 文档及关键代码注释同步,App仍以string传单号,无新增入参、SQL或字段扩容;沿当前`test-202609v2`提交推送,不创建分支。部署需先具备036关联表(包括功能关闭时的普通订单识别查询),测试库此前已迁移。
+- 测试库只读核对`pos_order.dd_id`为30字符、最短关联字段为20字符,报错requestKey无已提交批次。未修改数据库,未调用真实支付/退款/地图或远端建批HTTP;本地无Redis实例,原子Lua与SET NX按mock契约验证,真实Redis集成和部署后建批仍需联调。