Просмотр исходного кода

feat(order): 特价下单分支 + 三处回滚 + reviewUpdate 防护

Sisyphus Agent 4 дней назад
Родитель
Сommit
6c05e6f3ad

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/CreateProductOrderDTO.java

@@ -30,6 +30,7 @@ public class CreateProductOrderDTO {
     private Long pickupLocationId;      // 自取地点ID
     private Long timeSlotId;             // 时间段ID(自取/线上)
     private Long servicePersonId;       // 服务者ID(线上-可选人)
+    private Long specialDealId;          // 限时特价活动ID(扫码闪购,非空=特价单)
 
     // Explicit getters needed due to Lombok issues
     public List<OrderItemVO> getItems() { return items; }
@@ -46,4 +47,6 @@ public class CreateProductOrderDTO {
     public Long getAddressId() { return addressId; }
     public void setAddressId(Long addressId) { this.addressId = addressId; }
     public Map<String, String> getPurchaseInfo() { return purchaseInfo; }
+    public Long getSpecialDealId() { return specialDealId; }
+    public void setSpecialDealId(Long specialDealId) { this.specialDealId = specialDealId; }
 }

+ 210 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

@@ -23,6 +23,7 @@ import com.etotem.cfc.entity.ProductPickupLocation;
 import com.etotem.cfc.entity.ProductPickupTimeSlot;
 import com.etotem.cfc.entity.ProductServicePerson;
 import com.etotem.cfc.entity.ProductServicePersonSlot;
+import com.etotem.cfc.entity.SpecialPriceDeal;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.service.FamilyPlatformPointsService;
 import com.etotem.cfc.service.PlatformPointsService;
@@ -205,7 +206,14 @@ public class ProductOrderService {
     @Resource
     private CouponService couponService;
 
+    @Resource
+    private SpecialPriceService specialPriceService;
+
     public Result<ProductOrderDTO> create(CreateProductOrderDTO dto, Long buyerId) {
+        // 限时特价(扫码闪购):委托独立事务入口(规格 §5.2)
+        if (dto.getSpecialDealId() != null) {
+            return createSpecialDealOrder(dto, buyerId);
+        }
         if (buyerId == null) {
             return Result.error("请先登录");
         }
@@ -321,6 +329,190 @@ public class ProductOrderService {
         return Result.success(ProductOrderDTO.from(order));
     }
 
+    /**
+     * 限时特价下单(扫码闪购):独立事务入口(规格 §5.2)。
+     * - 特价覆盖会员价,保留积分抵扣与赠品;优惠券按 allow_coupon 开关
+     * - 原子扣量 + 订单插入同一事务(任一异常整体回滚)
+     *
+     * <p>校验顺序裁决(2026-10-21):先做全部可失败前校验(每人限购/券开关/商品/履约/赠品),
+     * 原子扣量紧跟订单 insert 之前 —— 规格 §5.2「每人限购(先查再扣,与扣量同事务)」,
+     * 避免"扣量成功但后续校验失败返回 Result.error"导致的额度泄漏(@Transactional 仅对异常回滚,
+     * Result.error 返回会提交事务)。</p>
+     */
+    @Transactional(rollbackFor = Exception.class)
+    private Result<ProductOrderDTO> createSpecialDealOrder(CreateProductOrderDTO dto, Long buyerId) {
+        if (buyerId == null) {
+            return Result.error("请先登录");
+        }
+        // 1. 特价仅支持单商品直购,不走购物车
+        if (dto.getItems() != null && !dto.getItems().isEmpty()) {
+            return Result.error("特价商品请单独购买");
+        }
+        Long dealId = dto.getSpecialDealId();
+        SpecialPriceDeal deal = specialPriceService.getById(dealId);
+        if (deal == null) {
+            return Result.error("特价活动不存在");
+        }
+        if (!"active".equals(deal.getStatus())) {
+            return Result.error("特价活动已结束或不存在");
+        }
+        Date now = new Date();
+        if (deal.getStartTime() != null && now.before(deal.getStartTime())) {
+            return Result.error("特价活动未开始/已结束");
+        }
+        if (deal.getEndTime() != null && now.after(deal.getEndTime())) {
+            return Result.error("特价活动未开始/已结束");
+        }
+        if (deal.getSpecialPrice() == null) {
+            return Result.error("特价价格异常");
+        }
+        if (dto.getQuantity() == null || dto.getQuantity() < 1) {
+            return Result.error("购买数量不合法");
+        }
+        int quantity = dto.getQuantity();
+        if (dto.getProductId() == null || !dto.getProductId().equals(deal.getProductId())) {
+            return Result.error("该特价活动不适用于此商品");
+        }
+        // 2. 每人限购(先查再扣)
+        int perUserLimit = deal.getPerUserLimit() != null ? deal.getPerUserLimit() : 1;
+        int userPurchased = specialPriceService.getUserPurchased(dealId, buyerId);
+        if (userPurchased + quantity > perUserLimit) {
+            return Result.error("每人限购" + perUserLimit + "件,您已购买" + userPurchased + "件");
+        }
+        // 3. 优惠券开关
+        if ((deal.getAllowCoupon() == null || deal.getAllowCoupon() == 0) && dto.getCouponId() != null) {
+            return Result.error("该特价商品不可使用优惠券");
+        }
+        // 4. 商品校验(复用 create 单商品逻辑:在架/库存/套餐/最低购买量)
+        Product product = productMapper.selectById(dto.getProductId());
+        if (product == null) {
+            return Result.error("商品不存在");
+        }
+        if (!"on_shelf".equals(product.getStatus())) {
+            return Result.error("商品已下架");
+        }
+        if ("physical".equals(product.getProductType())
+                && product.getStock() != null
+                && product.getStock() < quantity) {
+            return Result.error("库存不足");
+        }
+        if ("bundle".equals(product.getProductType())) {
+            Result<String> bundleCheck = inventoryService.checkBundleStock(product.getId(), quantity);
+            if (bundleCheck.getCode() != 200) {
+                return Result.error(bundleCheck.getMessage());
+            }
+        }
+        if (product.getMinQuantity() != null && product.getMinQuantity() > 1
+                && quantity < product.getMinQuantity()) {
+            return Result.error("该商品最少需购买" + product.getMinQuantity() + "件");
+        }
+        // 5. 配送方式与履约校验
+        FulfillValidation fv = validateFulfill(product, dto, buyerId);
+        if (fv.error != null) return Result.error(fv.error);
+
+        // 6. 构建订单(定价=特价,覆盖会员价)
+        User buyer = userMapper.selectById(buyerId);
+        ProductOrder order = new ProductOrder();
+        order.setOrderNo(generateOrderNo());
+        order.setProductId(product.getId());
+        order.setProductName(product.getName());
+        order.setProductType(product.getProductType());
+        order.setBuyerId(buyerId);
+        order.setFamilyId(buyer != null ? buyer.getFamilyId() : null);
+        order.setQuantity(quantity);
+        order.setUnitPrice(deal.getSpecialPrice());
+        order.setTotalAmount(deal.getSpecialPrice() * quantity);
+        order.setPointsUsed(dto.getPointsUsed() != null ? dto.getPointsUsed() : 0);
+        int pointsCost = dto.getPointsUsed() != null ? dto.getPointsUsed() : 0;
+        order.setPointsCost(pointsCost);
+        int discountAmount = dto.getDiscountAmount() != null ? dto.getDiscountAmount() : 0;
+        Long appliedCouponId = null;
+        if (dto.getCouponId() != null) {
+            Integer couponDiscount = couponService.apply(buyerId, dto.getCouponId(), "PRODUCT", order.getTotalAmount());
+            if (couponDiscount != null && couponDiscount > 0) {
+                discountAmount += couponDiscount;
+                appliedCouponId = dto.getCouponId();
+            }
+        }
+        int moneyAmount = order.getTotalAmount() - pointsCost - discountAmount;
+        order.setMoneyAmount(moneyAmount > 0 ? moneyAmount : 0);
+        order.setGiftAmount(productGiftRuleService.getActiveByProductId(product.getId()) != null
+                ? product.getPrice() * quantity : 0);
+        order.setStatus("pending");
+        order.setPaymentMethod(dto.getPaymentMethod() != null ? dto.getPaymentMethod() : "wechat");
+        order.setRemark(dto.getRemark());
+        order.setCoverImage(product.getCoverImage());
+        order.setDiscountAmount(discountAmount);
+        order.setCouponId(appliedCouponId);
+        resolveOrderAddress(order, dto, buyerId);
+        if (dto.getPurchaseInfo() != null && !dto.getPurchaseInfo().isEmpty()) {
+            order.setPurchaseInfo(JSON.toJSONString(dto.getPurchaseInfo()));
+        }
+        order.setDeliveryMethod(fv.deliveryMethod != null ? fv.deliveryMethod
+                : (dto.getDeliveryMethod() != null ? dto.getDeliveryMethod() : 1));
+        // 履约扩展校验结果(与 create 一致)
+        if (fv.unitPrice != null) {
+            order.setUnitPrice(fv.unitPrice);
+            order.setTotalAmount(fv.unitPrice * quantity);
+            int ma = order.getTotalAmount() - order.getPointsCost() - discountAmount;
+            order.setMoneyAmount(ma > 0 ? ma : 0);
+        }
+        if (fv.pickupLocationId != null) order.setPickupLocationId(fv.pickupLocationId);
+        if (fv.pickupLocationSnapshot != null) order.setPickupLocationSnapshot(fv.pickupLocationSnapshot);
+        if (fv.timeSlotId != null) order.setTimeSlotId(fv.timeSlotId);
+        if (fv.timeSlotSnapshot != null) order.setTimeSlotSnapshot(fv.timeSlotSnapshot);
+        if (fv.servicePersonId != null) order.setServicePersonId(fv.servicePersonId);
+        if (fv.servicePersonSnapshot != null) order.setServicePersonSnapshot(fv.servicePersonSnapshot);
+        order.setFulfillStatus("pending");
+        if (order.getDeliveryMethod() != null && order.getDeliveryMethod() == 2) {
+            order.setPickupCode(generatePickupCode());
+        }
+        order.setSpecialDealId(dealId);
+        order.setCancelAt(new Date(System.currentTimeMillis() + 30 * 60 * 1000));
+        order.setCreatedAt(new Date());
+        order.setUpdatedAt(new Date());
+
+        // 7. 原子扣量(紧跟 insert 之前;insert 或其后异常由事务整体回滚,扣量一并回滚)
+        try {
+            specialPriceService.consumeQuota(dealId, quantity);
+        } catch (IllegalStateException e) {
+            // 并发售罄/状态漂移:重查活动给出明确原因(规格 §5.2:影响行数=0 时重查判定原因)
+            return Result.error(specialDealUnavailableReason(dealId));
+        }
+        orderMapper.insert(order);
+        // 占用已选优惠券(避免 pending 期间其他订单重复使用同一张)
+        if (appliedCouponId != null) {
+            try {
+                couponService.reserve(appliedCouponId, order.getId());
+            } catch (Exception e) {
+                log.error("占用优惠券失败: orderNo={}, couponId={}, error={}", order.getOrderNo(), appliedCouponId, e.getMessage());
+            }
+        }
+        return Result.success(ProductOrderDTO.from(order));
+    }
+
+    /**
+     * 扣量失败后的原因判定(规格 §5.2:影响行数=0 → 重查活动判定已售罄/已结束/未开始/非活动期)。
+     */
+    private String specialDealUnavailableReason(Long dealId) {
+        SpecialPriceDeal deal = specialPriceService.getById(dealId);
+        if (deal == null) {
+            return "特价活动不存在";
+        }
+        String runStatus = SpecialPriceService.computeRunStatus(
+                deal.getStatus(),
+                deal.getStartTime(),
+                deal.getEndTime(),
+                deal.getSoldQuantity() != null ? deal.getSoldQuantity() : 0,
+                deal.getTotalQuantity() != null ? deal.getTotalQuantity() : 0);
+        if ("NOT_STARTED".equals(runStatus)) return "特价活动未开始";
+        if ("ENDED".equals(runStatus)) return "特价活动已结束";
+        if ("SOLD_OUT".equals(runStatus)) return "特价活动已售罄";
+        if ("DRAFT".equals(runStatus)) return "特价活动待启用";
+        if ("DISABLED".equals(runStatus)) return "特价活动已停用";
+        return "特价活动不可用";
+    }
+
     /**
      * 配送方式与履约校验(设计文档十一节)
      * 分支校验 + 生成快照 + 扣减时间段余量;返回 error!=null 表示校验失败
@@ -1027,6 +1219,13 @@ public class ProductOrderService {
         // 记录退款入库流水
         inventoryService.recordRefundInbound(order.getProductId(), null, order.getQuantity(),
                 "order", order.getId(), null, "取消订单:" + orderNo);
+        // 特价订单回滚扣量(与恢复库存并列,失败仅记日志不阻断取消)
+        try {
+            specialPriceService.releaseQuota(order.getSpecialDealId(), order.getQuantity());
+        } catch (Exception ex) {
+            log.warn("特价扣量回滚失败: dealId={}, orderNo={}, err={}",
+                    order.getSpecialDealId(), order.getOrderNo(), ex.getMessage());
+        }
         return Result.success("订单已取消");
     }
 
@@ -1300,6 +1499,13 @@ public class ProductOrderService {
                 "order", order.getId(), adminId, "退款:" + orderNo);
         // 退款成功:归还优惠券;CF 兑换来的券同时退还 CF 值
         releaseCouponForCancelledOrder(order.getCouponId());
+        // 特价订单回滚扣量(与恢复库存并列,失败仅记日志不阻断退款)
+        try {
+            specialPriceService.releaseQuota(order.getSpecialDealId(), order.getQuantity());
+        } catch (Exception ex) {
+            log.warn("特价扣量回滚失败(refund): dealId={}, orderNo={}, err={}",
+                    order.getSpecialDealId(), order.getOrderNo(), ex.getMessage());
+        }
         syncAfterSalesRequestStatus(orderNo, "approved", adminId);
         log.info("退款完成: orderNo={}, adminId={}", orderNo, adminId);
         return Result.success("退款已处理");
@@ -1625,6 +1831,10 @@ public class ProductOrderService {
         if (order == null) {
             return Result.error("订单不存在");
         }
+        // 特价订单禁止供应商改价(含非价格字段,保持简单——扫码闪购定价由活动决定)
+        if (order.getSpecialDealId() != null) {
+            return Result.error("特价订单不允许修改价格");
+        }
         if (!"pending".equals(order.getStatus())) {
             return Result.error("仅待支付订单可审核修改");
         }

+ 23 - 0
cfc-backend/src/main/java/com/etotem/cfc/task/OrderTimeoutCancelTask.java

@@ -6,6 +6,7 @@ import com.etotem.cfc.mapper.ProductOrderMapper;
 import com.etotem.cfc.service.CouponService;
 import com.etotem.cfc.service.InventoryService;
 import com.etotem.cfc.service.ProductService;
+import com.etotem.cfc.service.SpecialPriceService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Value;
@@ -41,6 +42,9 @@ public class OrderTimeoutCancelTask {
     @Resource
     private CouponService couponService;
 
+    @Resource
+    private SpecialPriceService specialPriceService;
+
     @Value("${shop.order.payment-timeout-minutes:30}")
     private int paymentTimeoutMinutes;
 
@@ -95,9 +99,28 @@ public class OrderTimeoutCancelTask {
                         order.getOrderNo(), order.getProductId(), e.getMessage());
             }
 
+            // 特价订单(扫码闪购)超时取消:释放已扣的特价供应量
+            releaseSpecialDealQuotaIfNeeded(order);
+
             log.info("订单 {} 超时未支付,已自动取消", order.getOrderNo());
         }
 
         log.info("本次共取消 {} 个超时订单", timeoutOrders.size());
     }
+
+    /**
+     * 超时取消单个订单时释放特价扣量(若有)。
+     * 抽取为可单测的 package-private 方法:cancelTimeoutOrders() 主循环受
+     * DatabaseInitializer.isMigrationsComplete() 静态开关保护,单测无法进入循环体。
+     */
+    void releaseSpecialDealQuotaIfNeeded(ProductOrder order) {
+        if (order == null || order.getSpecialDealId() == null) return;
+        try {
+            specialPriceService.releaseQuota(order.getSpecialDealId(), order.getQuantity());
+            log.info("订单 {} 超时取消:释放特价扣量 dealId={}", order.getOrderNo(), order.getSpecialDealId());
+        } catch (Exception ex) {
+            log.error("订单 {} 超时取消:释放特价扣量失败: dealId={}, err={}",
+                    order.getOrderNo(), order.getSpecialDealId(), ex.getMessage());
+        }
+    }
 }

+ 263 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/ProductOrderServiceSpecialDealTest.java

@@ -0,0 +1,263 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.CreateProductOrderDTO;
+import com.etotem.cfc.dto.OrderItemVO;
+import com.etotem.cfc.dto.ProductOrderDTO;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.ProductOrder;
+import com.etotem.cfc.entity.SpecialPriceDeal;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.ProductOrderMapper;
+import com.etotem.cfc.mapper.SpecialPriceDealMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 特价下单分支 + 回滚挂载点测试。
+ * 模式参考 ProductOrderServiceUpdateExpressFieldTest(Mockito + ReflectionTestUtils,不起 Spring 上下文)。
+ *
+ * SpecialPriceService 用真实实例 + mock 的 SpecialPriceDealMapper(plan 要求:单独 mock
+ * SpecialPriceDealMapper 供 consumeQuota / userPurchased / selectById 桩)。
+ */
+public class ProductOrderServiceSpecialDealTest {
+
+    private static final long DEAL_ID = 1L;
+    private static final long PRODUCT_ID = 100L;
+    private static final long BUYER_ID = 200L;
+    private static final String ORDER_NO = "O20261021TEST";
+
+    private ProductOrderService orderService;
+    private SpecialPriceDealMapper specialDealMapper;
+    private ProductMapper productMapper;
+    private UserMapper userMapper;
+    private ProductOrderMapper orderMapper;
+    private ProductService productService;
+    private InventoryService inventoryService;
+    private CouponService couponService;
+    private ProductGiftRuleService productGiftRuleService;
+
+    @BeforeEach
+    public void setUp() {
+        orderService = new ProductOrderService();
+        SpecialPriceService specialPriceService = new SpecialPriceService();
+        specialDealMapper = mock(SpecialPriceDealMapper.class);
+        ReflectionTestUtils.setField(specialPriceService, "specialPriceDealMapper", specialDealMapper);
+        productMapper = mock(ProductMapper.class);
+        userMapper = mock(UserMapper.class);
+        orderMapper = mock(ProductOrderMapper.class);
+        productService = mock(ProductService.class);
+        inventoryService = mock(InventoryService.class);
+        couponService = mock(CouponService.class);
+        productGiftRuleService = mock(ProductGiftRuleService.class);
+        ReflectionTestUtils.setField(orderService, "specialPriceService", specialPriceService);
+        ReflectionTestUtils.setField(orderService, "productMapper", productMapper);
+        ReflectionTestUtils.setField(orderService, "userMapper", userMapper);
+        ReflectionTestUtils.setField(orderService, "orderMapper", orderMapper);
+        ReflectionTestUtils.setField(orderService, "productService", productService);
+        ReflectionTestUtils.setField(orderService, "inventoryService", inventoryService);
+        ReflectionTestUtils.setField(orderService, "couponService", couponService);
+        ReflectionTestUtils.setField(orderService, "productGiftRuleService", productGiftRuleService);
+    }
+
+    // ==================== helpers ====================
+
+    private SpecialPriceDeal activeDeal(int sold, int perUser) {
+        SpecialPriceDeal deal = new SpecialPriceDeal();
+        deal.setId(DEAL_ID);
+        deal.setProductId(PRODUCT_ID);
+        deal.setName("开学季特价");
+        deal.setSpecialPrice(9900);
+        deal.setStartTime(new Date(System.currentTimeMillis() - 86400_000L));
+        deal.setEndTime(new Date(System.currentTimeMillis() + 86400_000L));
+        deal.setTotalQuantity(100);
+        deal.setSoldQuantity(sold);
+        deal.setPerUserLimit(perUser);
+        deal.setAllowCoupon(0);
+        deal.setStatus("active");
+        return deal;
+    }
+
+    private Product onShelfProduct() {
+        Product product = new Product();
+        product.setId(PRODUCT_ID);
+        product.setName("DAN测评任务模板");
+        product.setPrice(19800);
+        product.setProductType("virtual");
+        product.setStatus("on_shelf");
+        product.setCoverImage("http://example.com/a.png");
+        return product;
+    }
+
+    private User buyer() {
+        User user = new User();
+        user.setId(BUYER_ID);
+        return user;
+    }
+
+    private CreateProductOrderDTO dealDTO(int quantity) {
+        CreateProductOrderDTO dto = new CreateProductOrderDTO();
+        dto.setSpecialDealId(DEAL_ID);
+        dto.setProductId(PRODUCT_ID);
+        dto.setQuantity(quantity);
+        dto.setAddressSnapshot("{\"receiverName\":\"张三\",\"phone\":\"13800000000\"}");
+        return dto;
+    }
+
+    // ==================== 特价下单分支 ====================
+
+    @Test
+    public void create_withSpecialDealId_pricesAtSpecialPrice_consumesQuotaAndInserts() {
+        when(specialDealMapper.selectById(DEAL_ID)).thenReturn(activeDeal(0, 2));
+        when(specialDealMapper.userPurchased(DEAL_ID, BUYER_ID)).thenReturn(0);
+        when(productMapper.selectById(PRODUCT_ID)).thenReturn(onShelfProduct());
+        when(userMapper.selectById(BUYER_ID)).thenReturn(buyer());
+        when(specialDealMapper.consumeQuota(DEAL_ID, 2)).thenReturn(1);
+
+        Result<ProductOrderDTO> result = orderService.create(dealDTO(2), BUYER_ID);
+
+        assertEquals(200, result.getCode());
+        ArgumentCaptor<ProductOrder> captor = ArgumentCaptor.forClass(ProductOrder.class);
+        verify(orderMapper).insert(captor.capture());
+        ProductOrder order = captor.getValue();
+        // 特价覆盖会员价:unitPrice/totalAmount 按特价
+        assertEquals(PRODUCT_ID, order.getProductId());
+        assertEquals(DEAL_ID, order.getSpecialDealId());
+        assertEquals(9900, order.getUnitPrice());
+        assertEquals(19800, order.getTotalAmount());
+        assertEquals(2, order.getQuantity());
+        assertEquals("pending", order.getStatus());
+        verify(specialDealMapper).consumeQuota(DEAL_ID, 2);
+        verify(specialDealMapper).userPurchased(DEAL_ID, BUYER_ID);
+    }
+
+    @Test
+    public void create_whenQuotaConsumeFails_returnsErrorAndDoesNotInsert() {
+        when(specialDealMapper.selectById(DEAL_ID)).thenReturn(activeDeal(100, 2)); // sold=total -> SOLD_OUT
+        when(specialDealMapper.userPurchased(DEAL_ID, BUYER_ID)).thenReturn(0);
+        when(productMapper.selectById(PRODUCT_ID)).thenReturn(onShelfProduct());
+        when(userMapper.selectById(BUYER_ID)).thenReturn(buyer());
+        when(specialDealMapper.consumeQuota(DEAL_ID, 2)).thenReturn(0); // 原子扣量失败(并发售罄)
+
+        Result<ProductOrderDTO> result = orderService.create(dealDTO(2), BUYER_ID);
+
+        assertNotEquals(200, result.getCode());
+        assertTrue(result.getMessage().contains("售罄"));
+        verify(orderMapper, never()).insert(any(ProductOrder.class));
+    }
+
+    @Test
+    public void create_overPerUserLimit_returnsLimitErrorBeforeQuota() {
+        when(specialDealMapper.selectById(DEAL_ID)).thenReturn(activeDeal(0, 2));
+        when(specialDealMapper.userPurchased(DEAL_ID, BUYER_ID)).thenReturn(5); // 已购5件,限2件
+
+        Result<ProductOrderDTO> result = orderService.create(dealDTO(2), BUYER_ID);
+
+        assertNotEquals(200, result.getCode());
+        assertTrue(result.getMessage().contains("每人限购2件,您已购买5件"));
+        // 先查再扣:限购失败不应触发扣量,也不插入订单
+        verify(specialDealMapper, never()).consumeQuota(any(), anyInt());
+        verify(orderMapper, never()).insert(any(ProductOrder.class));
+    }
+
+    @Test
+    public void create_couponNotAllowed_rejected() {
+        when(specialDealMapper.selectById(DEAL_ID)).thenReturn(activeDeal(0, 2));
+        when(specialDealMapper.userPurchased(DEAL_ID, BUYER_ID)).thenReturn(0);
+        CreateProductOrderDTO dto = dealDTO(2);
+        dto.setCouponId(9L); // allowCoupon=0
+
+        Result<ProductOrderDTO> result = orderService.create(dto, BUYER_ID);
+
+        assertNotEquals(200, result.getCode());
+        assertTrue(result.getMessage().contains("不可使用优惠券"));
+        verify(specialDealMapper, never()).consumeQuota(any(), anyInt());
+    }
+
+    @Test
+    public void create_withItems_rejected_specialDealMustBeSingleProduct() {
+        CreateProductOrderDTO dto = dealDTO(2);
+        dto.setItems(Collections.singletonList(new OrderItemVO()));
+
+        Result<ProductOrderDTO> result = orderService.create(dto, BUYER_ID);
+
+        assertNotEquals(200, result.getCode());
+        assertTrue(result.getMessage().contains("单独购买"));
+        verify(specialDealMapper, never()).consumeQuota(any(), anyInt());
+    }
+
+    // ==================== 回滚挂载 ====================
+
+    @Test
+    public void cancel_releasesQuotaForSpecialDealOrder() {
+        ProductOrder order = specialDealOrder();
+        order.setStatus("pending");
+        when(orderMapper.selectOne(any())).thenReturn(order);
+
+        Result<String> result = orderService.cancel(ORDER_NO, BUYER_ID);
+
+        assertEquals(200, result.getCode());
+        assertEquals("cancelled", order.getStatus());
+        verify(specialDealMapper).releaseQuota(DEAL_ID, 2);
+    }
+
+    @Test
+    public void approveRefund_releasesQuotaForSpecialDealOrder() {
+        ProductOrder order = specialDealOrder();
+        order.setStatus("paid");
+        order.setRefundStatus(1);
+        order.setMoneyAmount(0); // 实付0:跳过微信退款分支,直接置 refunded
+        when(orderMapper.selectOne(any())).thenReturn(order);
+
+        Result<String> result = orderService.approveRefund(ORDER_NO, 500L);
+
+        assertEquals(200, result.getCode());
+        assertEquals("refunded", order.getStatus());
+        verify(specialDealMapper).releaseQuota(DEAL_ID, 2);
+    }
+
+    @Test
+    public void reviewUpdate_specialDealOrder_rejectedAtStart() {
+        ProductOrder order = specialDealOrder();
+        order.setStatus("pending");
+        when(orderMapper.selectOne(any())).thenReturn(order);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("orderNo", ORDER_NO);
+        Result<Map<String, Object>> result = orderService.reviewUpdate(params, 300L);
+
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("特价订单不允许修改价格"));
+        verify(orderMapper, never()).updateById(any(ProductOrder.class));
+        verify(productMapper, never()).selectById(any());
+    }
+
+    private ProductOrder specialDealOrder() {
+        ProductOrder order = new ProductOrder();
+        order.setOrderNo(ORDER_NO);
+        order.setBuyerId(BUYER_ID);
+        order.setProductId(PRODUCT_ID);
+        order.setQuantity(2);
+        order.setSpecialDealId(DEAL_ID);
+        return order;
+    }
+}

+ 61 - 0
cfc-backend/src/test/java/com/etotem/cfc/task/OrderTimeoutCancelTaskSpecialDealTest.java

@@ -0,0 +1,61 @@
+package com.etotem.cfc.task;
+
+import com.etotem.cfc.entity.ProductOrder;
+import com.etotem.cfc.service.SpecialPriceService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+/**
+ * OrderTimeoutCancelTask 超时取消时释放特价扣量(抽取方法单独测试)。
+ * 主循环 cancelTimeoutOrders() 受 DatabaseInitializer.isMigrationsComplete() 静态开关保护,
+ * 单测只覆盖抽取出的 package-private releaseSpecialDealQuotaIfNeeded。
+ */
+public class OrderTimeoutCancelTaskSpecialDealTest {
+
+    private OrderTimeoutCancelTask task;
+    private SpecialPriceService specialPriceService;
+
+    @BeforeEach
+    public void setUp() {
+        task = new OrderTimeoutCancelTask();
+        specialPriceService = mock(SpecialPriceService.class);
+        ReflectionTestUtils.setField(task, "specialPriceService", specialPriceService);
+    }
+
+    @Test
+    public void releaseQuota_whenSpecialDealIdPresent_callsServiceOnce() {
+        ProductOrder order = new ProductOrder();
+        order.setOrderNo("O1");
+        order.setSpecialDealId(1L);
+        order.setQuantity(2);
+
+        task.releaseSpecialDealQuotaIfNeeded(order);
+
+        verify(specialPriceService).releaseQuota(1L, 2);
+        verifyNoMoreInteractions(specialPriceService);
+    }
+
+    @Test
+    public void noInteractions_whenNoSpecialDealId() {
+        ProductOrder order = new ProductOrder();
+        order.setOrderNo("O1");
+        order.setQuantity(2);
+
+        task.releaseSpecialDealQuotaIfNeeded(order);
+
+        verifyNoInteractions(specialPriceService);
+    }
+
+    @Test
+    public void noThrow_whenNullOrder() {
+        assertDoesNotThrow(() -> task.releaseSpecialDealQuotaIfNeeded(null));
+        verifyNoInteractions(specialPriceService);
+    }
+}