Browse Source

merge(origin/cfclub): 与远端同步后保留 S-cfc-167(shop.coupon.apply 契约测试)

Sisyphus Agent 14 hours ago
parent
commit
1c6422fe69

+ 326 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/MembershipPlanPurchaseContractTest.java

@@ -0,0 +1,326 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.etotem.cfc.controller.MembershipController;
+import com.etotem.cfc.dto.PaymentOrderDTO;
+import com.etotem.cfc.entity.FamilyMembership;
+import com.etotem.cfc.entity.MembershipLevel;
+import com.etotem.cfc.entity.PaymentOrder;
+import com.etotem.cfc.mapper.FamilyMembershipMapper;
+import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.MembershipLevelMapper;
+import com.etotem.cfc.mapper.PaymentOrderMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.CfCommissionService;
+import com.etotem.cfc.service.CouponService;
+import com.etotem.cfc.service.MembershipService;
+import com.etotem.cfc.service.VirtualGoodsConfigService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.web.bind.annotation.PostMapping;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.Collections;
+import java.util.Date;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+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.junit.jupiter.api.Assertions.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:会员档位购买(能力键 {@code membership.plan.purchase},SC-P-58)。
+ *
+ * <p><b>需求/溯源(tests/user-scenarios/应用场景总览.md SC-P-58 · 购买会员/套餐,业务域 F 商城与会员)</b></p>
+ * <blockquote>
+ *   目标:解锁高级能力。流程:选档位 → 支付 → 权益生效。
+ *   边界:<b>支付失败回滚</b>;<b>重复购买幂等</b>。模块:{@code MemberController}(现有载体
+ *   {@code MembershipController} / {@code MembershipService})。
+ * </blockquote>
+ *
+ * <p><b>设计锚点(docs/superpowers/api/API_REFERENCE.md)</b></p>
+ * <ul>
+ *   <li>§4.50 会员权益对比表 {@code POST /api/membership/compare}:购买页/会员中心的档位卡片与权益
+ *       对比已后端化(已合入 origin/cfclub);同节注明 {@code /api/membership/plans}
+ *       为<b>购买档位列表</b>(过滤 LIFETIME/PROVIDER,附 maxChildren/maxTasksPerDay)。</li>
+ *   <li>§4.43 商品赠送会员 {@code POST /api/membership/gift-membership-progress}:家庭维度赠会员链路
+ *       (已合入 origin/cfclub)。</li>
+ *   <li>购买支付现状载体:{@code MembershipController#createOrder}({@code POST /api/membership/orders})
+ *       → {@code MembershipService#createOrder} / {@code #createOrderWithPayType} /
+ *       {@code #processPaymentCallback}(已存在)。</li>
+ * </ul>
+ *
+ * <p><b>契约缺口(本期实现未落地,全部断言预期红)</b></p>
+ * <ul>
+ *   <li><b>契约入口缺失</b>:无一段式档位购买入口({@code MembershipService#purchasePlan(...)} 契约方法、
+ *       {@code POST /api/membership/plan/purchase} 控制器端点均不存在);无支付失败回滚入口
+ *       ({@code markMembershipOrderFailed(orderNo)} 不存在)。</li>
+ *   <li><b>重复购买幂等缺失</b>:{@code createOrder} 不检查家庭<i>已有有效会员</i>与<i>已有待支付同类订单</i>,
+ *       重复购买每次都盲目生成新订单(SC-P-58 边界「重复购买幂等」未实现)。</li>
+ *   <li><b>支付失败回滚缺失</b>:{@code processPaymentCallback} 先置订单 {@code paid} 再开通会员,
+ *       开通失败(如家庭无关联用户)时订单已停留 {@code paid}、会员记录缺失——半态不回滚
+ *       (SC-P-58 边界「支付失败回滚」未实现)。</li>
+ * </ul>
+ *
+ * <p><b>契约方法签名(GREEN 阶段实现方按此落地,落地后本测试自动转绿)</b></p>
+ * <ul>
+ *   <li>{@code Map<String,Object> purchasePlan(Long userId, Long familyId, String levelCode,
+ *       String period, Long couponId)} —— 一段式档位购买:按当前支付模式返回待支付订单 + 支付参数;
+ *       无家庭时<i>最先</i>抛「请先创建或加入家庭后再购买会员」(fail-fast,权限流)。</li>
+ *   <li>{@code boolean markMembershipOrderFailed(String orderNo)} —— 支付失败回滚入口:
+ *       将订单置失败态(不置 paid / 不生会员记录),重复调用幂等。</li>
+ *   <li>{@code createOrder} 追加幂等守卫:家庭已有有效付费会员(paid 未过期)→ 拒绝重复购买;
+ *       已有同类待支付订单 → 复用既有订单不新插入。</li>
+ *   <li>{@code processPaymentCallback} 开通失败时回滚订单状态(不得停留 paid)。</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:纯 Mockito + 反射,无 Spring 上下文 / 无 DB / 无真实微信;宿主契约方法用
+ * 反射锚定(避免直接调用不存在方法导致编译失败),运行时缺失即断言失败(红),与
+ * TeamStarMultiplierContractTest / TeamProfitHookContractTest / FamilyJoinValidationRulesContractTest
+ * 同批风格一致。</p>
+ *
+ * <p><b>权限流</b>:购买需登录({@code JwtInterceptor} 拦截 {@code /api/**})+ 家庭上下文;
+ * 本类以「无家庭 → 拒绝购买」覆盖权限维度(familyId=null fail-fast 契约)。</p>
+ */
+class MembershipPlanPurchaseContractTest {
+
+    private static final String KEY = "[membership.plan.purchase]";
+
+    /** 契约入口购买方法与无家庭拒绝时最先抛出的业务提示(与 createOrder 既有文案同源) */
+    private static final String MSG_NO_FAMILY = "请先创建或加入家庭后再购买会员";
+
+    private MembershipService service;
+    private MembershipLevelMapper levelMapper;
+    private PaymentOrderMapper paymentOrderMapper;
+    private FamilyMembershipMapper membershipMapper;
+    private FamilyMapper familyMapper;
+    private UserMapper userMapper;
+    private CouponService couponService;
+    private CfCommissionService cfCommissionService;
+    private VirtualGoodsConfigService virtualGoodsConfigService;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new MembershipService();
+        levelMapper = mock(MembershipLevelMapper.class);
+        paymentOrderMapper = mock(PaymentOrderMapper.class);
+        membershipMapper = mock(FamilyMembershipMapper.class);
+        familyMapper = mock(FamilyMapper.class);
+        userMapper = mock(UserMapper.class);
+        couponService = mock(CouponService.class);
+        cfCommissionService = mock(CfCommissionService.class);
+        virtualGoodsConfigService = mock(VirtualGoodsConfigService.class);
+
+        inject(service, "levelMapper", levelMapper);
+        inject(service, "paymentOrderMapper", paymentOrderMapper);
+        inject(service, "membershipMapper", membershipMapper);
+        inject(service, "familyMapper", familyMapper);
+        inject(service, "userMapper", userMapper);
+        inject(service, "couponService", couponService);
+        inject(service, "cfCommissionService", cfCommissionService);
+        inject(service, "virtualGoodsConfigService", virtualGoodsConfigService);
+    }
+
+    private static void inject(Object target, String field, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(field);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    // ==================== 正常流(契约入口:选档位 → 支付) ====================
+
+    @Test
+    @DisplayName("正常流-1:MembershipService 应落地 purchasePlan 一段式档位购买契约方法")
+    void purchasePlan_contractMethod_landed() {
+        assertTrue(hasMethod(MembershipService.class, "purchasePlan", 5),
+                KEY + " MembershipService 未落地 purchasePlan(userId, familyId, levelCode, period, couponId)"
+                        + " 契约方法(SC-P-58 选档位→支付一段式购买入口,当前实现未落地)。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:MembershipController 应暴露 POST /api/membership/plan/purchase 端点")
+    void planPurchase_endpoint_exposed() {
+        boolean found = false;
+        for (Method m : MembershipController.class.getDeclaredMethods()) {
+            PostMapping pm = m.getAnnotation(PostMapping.class);
+            if (pm == null) {
+                continue;
+            }
+            for (String v : pm.value()) {
+                if ("/plan/purchase".equals(v)) {
+                    found = true;
+                    break;
+                }
+            }
+        }
+        assertTrue(found,
+                KEY + " MembershipController 未暴露 POST /api/membership/plan/purchase 端点"
+                        + "(SC-P-58 购买页档位购买契约入口,当前实现未落地)。");
+    }
+
+    // ==================== 边界流(SC-P-58:重复购买幂等) ====================
+
+    @Test
+    @DisplayName("边界流-1:家庭已有有效付费会员时重复购买应幂等拦截,不得生成新订单")
+    void repeatPurchase_activeMembership_blocked_noNewOrder() {
+        // 契约(重复购买幂等):同一家庭对同一档位已存在有效付费会员(paid 未过期)时,
+        // 重复购买不得盲目再生成待支付订单——应拒绝并提示(续费走 SC-P-60 流程)。
+        when(membershipMapper.selectOne(any(Wrapper.class))).thenReturn(activeMembership("FAMILY"));
+        when(levelMapper.selectOne(any(Wrapper.class))).thenReturn(familyLevel());
+        when(virtualGoodsConfigService.getEnabled("MEMBERSHIP", "FAMILY")).thenReturn(null);
+
+        try {
+            service.createOrder(1L, 5L, "FAMILY", "wechat", "yearly", null);
+            fail(KEY + " 重复购买未被幂等拦截:家庭已有有效 FAMILY 会员时仍生成新订单(SC-P-58 边界:重复购买幂等)。");
+        } catch (RuntimeException ex) {
+            assertTrue(ex.getMessage().contains("有效会员"),
+                    KEY + " 重复购买拒绝文案应说明「已有有效会员」:实际 " + ex.getMessage());
+        }
+        verify(paymentOrderMapper, never()).insert(any(PaymentOrder.class));
+    }
+
+    @Test
+    @DisplayName("边界流-2:已有同类待支付订单时重复购买应复用既有订单,不重复插入")
+    void repeatPurchase_pendingOrderSamePlan_reuseNoDuplicate() {
+        // 契约(重复购买幂等):家庭已有「同等级、待支付」的购买订单时,再次购买应复用既有订单,
+        // 幂等返回已存在待支付单,禁止为同一意图重复落单。
+        when(levelMapper.selectOne(any(Wrapper.class))).thenReturn(familyLevel());
+        when(virtualGoodsConfigService.getEnabled("MEMBERSHIP", "FAMILY")).thenReturn(null);
+
+        service.createOrder(1L, 5L, "FAMILY", "wechat", "yearly", null);
+
+        verify(paymentOrderMapper, never()).insert(any(PaymentOrder.class));
+    }
+
+    // ==================== 边界流(SC-P-58:支付失败回滚) ====================
+
+    @Test
+    @DisplayName("边界流-3:MembershipService 应落地 markMembershipOrderFailed 支付失败回滚入口")
+    void paymentFailureRollback_contractMethod_landed() {
+        assertTrue(hasMethod(MembershipService.class, "markMembershipOrderFailed", 1),
+                KEY + " MembershipService 未落地 markMembershipOrderFailed(orderNo) 契约方法:"
+                        + "支付失败时需显式回滚订单(置失败态、不置 paid、不生成会员记录,重复调用幂等)"
+                        + "(SC-P-58 边界:支付失败回滚)。");
+    }
+
+    @Test
+    @DisplayName("边界流-4:支付回调开通失败时订单状态必须回滚,不得停留 paid 半态")
+    void paymentCallback_activationFailed_orderMustRollback() {
+        PaymentOrder order = pendingOrder("MO_PLAN_001");
+        when(paymentOrderMapper.selectOne(any(Wrapper.class))).thenReturn(order);
+        // 家庭无关联用户 → 会员开通失败(activateMembership 返回 false)
+        when(familyMapper.selectById(5L)).thenReturn(null);
+        when(userMapper.selectList(any(Wrapper.class))).thenReturn(Collections.emptyList());
+
+        boolean ok = service.processPaymentCallback("MO_PLAN_001", "wx_tx_fail", "wechat");
+
+        assertFalse(ok, KEY + " 开通失败时回调应返回 false");
+        assertEquals("pending", order.getStatus(),
+                KEY + " 支付失败回滚:回调开通失败后订单必须回滚(不置 paid),"
+                        + "当前实现先置 paid 再开通、失败不回滚 → 遗留“订单 paid 但无会员记录”半态"
+                        + "(SC-P-58 边界:支付失败回滚)。");
+    }
+
+    // ==================== 权限流:无家庭 → 购买入口拒绝(fail-fast) ====================
+
+    @Test
+    @DisplayName("权限流-1:无家庭(familyId=null)时购买入口必须最先拒绝")
+    void permissionFlow_noFamily_purchaseRejected() {
+        // 契约:purchasePlan 在触碰任何数据前最先校验家庭上下文(与 createOrder 既有文案同源)。
+        Object host = contractHost("purchasePlan", 5);
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> call(host, "purchasePlan", 1L, null, "FAMILY", "yearly", null));
+        assertTrue(ex.getMessage().contains("家庭"),
+                KEY + " 无家庭购买必须拒绝并提示家庭上下文:实际 " + ex.getMessage());
+    }
+
+    // ==================== 辅助构造 ====================
+
+    private MembershipLevel familyLevel() {
+        MembershipLevel l = new MembershipLevel();
+        l.setId(3L);
+        l.setLevelCode("FAMILY");
+        l.setLevelName("家庭版");
+        l.setLevelOrder(3);
+        l.setPriceYearly(39800);
+        return l;
+    }
+
+    private FamilyMembership activeMembership(String levelCode) {
+        FamilyMembership m = new FamilyMembership();
+        m.setId(1L);
+        m.setFamilyId(5L);
+        m.setLevelCode(levelCode);
+        m.setPaymentStatus("paid");
+        m.setStartDate(new Date(System.currentTimeMillis() - 86400000L));
+        m.setEndDate(new Date(System.currentTimeMillis() + 30L * 86400000L));
+        return m;
+    }
+
+    private PaymentOrder pendingOrder(String orderNo) {
+        PaymentOrder o = new PaymentOrder();
+        o.setId(1L);
+        o.setOrderNo(orderNo);
+        o.setFamilyId(5L);
+        o.setLevelCode("FAMILY");
+        o.setPaymentType("wechat");
+        o.setPeriod("yearly");
+        o.setAmount(39800);
+        o.setStatus("pending");
+        o.setPeriodNo(1);
+        o.setCreatedAt(new Date());
+        return o;
+    }
+
+    // ==================== 反射辅助(锚定契约宿主方法,避免编译期依赖不存在方法) ====================
+
+    private static boolean hasMethod(Class<?> clazz, String name, int paramCount) {
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals(name) && m.getParameterCount() == paramCount) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private Object contractHost(String method, int arity) {
+        assertTrue(hasMethod(MembershipService.class, method, arity),
+                KEY + " 契约方法 MembershipService#" + method + "(...) 未实现(能力键实现未落地,GREEN 阶段按 javadoc 签名落地)。");
+        return new MembershipService();
+    }
+
+    /** 按方法名 + 参数个数匹配并调用;被调方法抛出的 RuntimeException 原样上抛(供 assertThrows 断言)。 */
+    private Object call(Object host, String method, Object... args) {
+        for (Method m : host.getClass().getDeclaredMethods()) {
+            if (m.getName().equals(method) && m.getParameterCount() == args.length) {
+                try {
+                    return m.invoke(host, args);
+                } catch (InvocationTargetException e) {
+                    Throwable cause = e.getCause();
+                    if (cause instanceof RuntimeException) {
+                        throw (RuntimeException) cause;
+                    }
+                    if (cause instanceof Error) {
+                        throw (Error) cause;
+                    }
+                    fail(KEY + " 契约方法 " + method + " 调用异常:" + cause);
+                    throw new IllegalStateException("unreachable");
+                } catch (Exception e) {
+                    fail(KEY + " 契约方法 " + method + " 调用失败:" + e.getMessage());
+                    throw new IllegalStateException("unreachable");
+                }
+            }
+        }
+        fail(KEY + " 契约方法 MembershipService#" + method + " 未实现(按 javadoc 签名落地)");
+        throw new IllegalStateException("unreachable");
+    }
+}

+ 314 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ShopOrderCreateSpecialDealContractTest.java

@@ -0,0 +1,314 @@
+package com.etotem.cfc.unit;
+
+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.AfterSalesRequestMapper;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.ProductOrderMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.CouponService;
+import com.etotem.cfc.service.InventoryService;
+import com.etotem.cfc.service.PaymentService;
+import com.etotem.cfc.service.ProductGiftRuleService;
+import com.etotem.cfc.service.ProductOrderService;
+import com.etotem.cfc.service.ProductService;
+import com.etotem.cfc.service.SpecialPriceService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.Collections;
+import java.util.Date;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+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;
+
+/**
+ * 契约级单测:商城下单 shop.order.create —— 特价下单分支(specialDealId)。
+ *
+ * <p>契约来源:docs/superpowers/api/API_REFERENCE.md §4.8 商品与订单 / §4.53 限时特价「下单(特价路径)」:
+ * <blockquote>
+ *   POST /api/product/order/create 请求体新增 specialDealId(Long,可选)。若 specialDealId != null 走特价下单分支:
+ *   1. 校验活动存在、status=active、NOW() ∈ [start_time, end_time]、商品一致;
+ *   2. 每人限购校验(countByDeal 累计 &lt; perUserLimit);
+ *   3. allowCoupon=0 且 couponId 非空 → 拒绝;
+ *   4. 原子扣量 consumeQuota(id, qty);
+ *   5. insert 订单行 special_deal_id = dealId,unit_price = specialPrice;
+ *   6. 回滚挂载点:cancel / approveRefund / OrderTimeoutCancelTask 调用 releaseQuota。
+ * </blockquote></p>
+ *
+ * <p>溯源需求:tests/user-scenarios/应用场景总览.md SC-P-54(加购/下单)、SC-P-55(订单支付),业务域 F 商城与会员。</p>
+ *
+ * <p>纯 Mockito + 反射注入,无 Spring 上下文 / 无 DB / 无外部服务(与同批 TeamProfitHookContractTest 风格一致)。</p>
+ */
+class ShopOrderCreateSpecialDealContractTest {
+
+    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 = "PO20261107S";
+
+    private ProductOrderService service;
+    private SpecialPriceService specialPriceService;
+    private ProductMapper productMapper;
+    private UserMapper userMapper;
+    private ProductOrderMapper orderMapper;
+    private ProductService productService;
+    private InventoryService inventoryService;
+    private CouponService couponService;
+    private ProductGiftRuleService productGiftRuleService;
+    private PaymentService paymentService;
+    private AfterSalesRequestMapper afterSalesRequestMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new ProductOrderService();
+        specialPriceService = mock(SpecialPriceService.class);
+        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);
+        paymentService = mock(PaymentService.class);
+        afterSalesRequestMapper = mock(AfterSalesRequestMapper.class);
+
+        inject(service, "specialPriceService", specialPriceService);
+        inject(service, "productMapper", productMapper);
+        inject(service, "userMapper", userMapper);
+        inject(service, "orderMapper", orderMapper);
+        inject(service, "productService", productService);
+        inject(service, "inventoryService", inventoryService);
+        inject(service, "couponService", couponService);
+        inject(service, "productGiftRuleService", productGiftRuleService);
+        inject(service, "paymentService", paymentService);
+        inject(service, "afterSalesRequestMapper", afterSalesRequestMapper);
+    }
+
+    private static void inject(Object target, String field, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(field);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    // ==================== helpers ====================
+
+    private SpecialPriceDeal activeDeal(int sold, int perUserLimit, int allowCoupon) {
+        SpecialPriceDeal deal = new SpecialPriceDeal();
+        deal.setId(DEAL_ID);
+        deal.setProductId(PRODUCT_ID);
+        deal.setName("限时特价活动");
+        deal.setSpecialPrice(9900);
+        deal.setStartTime(new Date(System.currentTimeMillis() - 86400000L));
+        deal.setEndTime(new Date(System.currentTimeMillis() + 86400000L));
+        deal.setTotalQuantity(100);
+        deal.setSoldQuantity(sold);
+        deal.setPerUserLimit(perUserLimit);
+        deal.setAllowCoupon(allowCoupon);
+        deal.setStatus("active");
+        return deal;
+    }
+
+    private Product onShelfProduct() {
+        Product product = new Product();
+        product.setId(PRODUCT_ID);
+        product.setName("特价测试商品");
+        product.setPrice(19800);
+        product.setProductType("physical");
+        product.setStatus("on_shelf");
+        product.setStock(100);
+        return product;
+    }
+
+    private User buyer() {
+        User user = new User();
+        user.setId(BUYER_ID);
+        user.setFamilyId(1L);
+        return user;
+    }
+
+    private CreateProductOrderDTO specialDealDTO(int quantity) {
+        CreateProductOrderDTO dto = new CreateProductOrderDTO();
+        dto.setProductId(PRODUCT_ID);
+        dto.setQuantity(quantity);
+        dto.setSpecialDealId(DEAL_ID);
+        dto.setPaymentMethod("wechat");
+        dto.setAddressSnapshot("{\"receiverName\":\"张三\",\"phone\":\"13800000000\"}");
+        return dto;
+    }
+
+    // ==================== 正常流 ====================
+
+    @Test
+    @DisplayName("正常流-1:特价下单成功,单价=特价、状态待支付、扣量并入库")
+    void 正常流_特价下单_按特价计价并扣量入库() {
+        when(specialPriceService.getById(DEAL_ID)).thenReturn(activeDeal(0, 2, 0));
+        when(specialPriceService.getUserPurchased(DEAL_ID, BUYER_ID)).thenReturn(0);
+        when(productMapper.selectById(PRODUCT_ID)).thenReturn(onShelfProduct());
+        when(userMapper.selectById(BUYER_ID)).thenReturn(buyer());
+        when(specialPriceService.consumeQuota(DEAL_ID, 2)).thenReturn(true);
+
+        Result<ProductOrderDTO> result = service.create(specialDealDTO(2), BUYER_ID);
+
+        assertEquals(200, result.getCode(), "特价下单应成功");
+        assertNotNull(result.getData(), "应返回订单数据");
+        assertEquals("pending", result.getData().getStatus(), "新订单应为待支付");
+        assertEquals(9900, result.getData().getUnitPrice(), "单价应为特价");
+        assertEquals(19800, result.getData().getTotalAmount(), "总价应为特价×数量");
+        verify(specialPriceService).consumeQuota(DEAL_ID, 2);
+        verify(orderMapper).insert(any(ProductOrder.class));
+    }
+
+    // ==================== 异常流 ====================
+
+    @Test
+    @DisplayName("异常流-1:活动不存在 → 特价活动不存在")
+    void 异常流_活动不存在() {
+        when(specialPriceService.getById(DEAL_ID)).thenReturn(null);
+
+        Result<ProductOrderDTO> result = service.create(specialDealDTO(2), BUYER_ID);
+
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("特价活动不存在"), "实际: " + result.getMessage());
+        verify(orderMapper, never()).insert(any(ProductOrder.class));
+    }
+
+    @Test
+    @DisplayName("异常流-2:活动未开始 → 拒绝")
+    void 异常流_未开始活动() {
+        SpecialPriceDeal deal = activeDeal(0, 2, 0);
+        deal.setStartTime(new Date(System.currentTimeMillis() + 86400000L));
+        when(specialPriceService.getById(DEAL_ID)).thenReturn(deal);
+
+        Result<ProductOrderDTO> result = service.create(specialDealDTO(2), BUYER_ID);
+
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("未开始"), "实际: " + result.getMessage());
+        verify(orderMapper, never()).insert(any(ProductOrder.class));
+    }
+
+    @Test
+    @DisplayName("异常流-3:活动已结束 → 拒绝")
+    void 异常流_已结束活动() {
+        SpecialPriceDeal deal = activeDeal(0, 2, 0);
+        deal.setEndTime(new Date(System.currentTimeMillis() - 86400000L));
+        when(specialPriceService.getById(DEAL_ID)).thenReturn(deal);
+
+        Result<ProductOrderDTO> result = service.create(specialDealDTO(2), BUYER_ID);
+
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("已结束"), "实际: " + result.getMessage());
+        verify(orderMapper, never()).insert(any(ProductOrder.class));
+    }
+
+    @Test
+    @DisplayName("异常流-4:每人限购超限 → 先查再扣,不扣量不插入")
+    void 异常流_每人限购超限() {
+        when(specialPriceService.getById(DEAL_ID)).thenReturn(activeDeal(0, 2, 0));
+        when(specialPriceService.getUserPurchased(DEAL_ID, BUYER_ID)).thenReturn(5);
+
+        Result<ProductOrderDTO> result = service.create(specialDealDTO(2), BUYER_ID);
+
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("每人限购2件"), "实际: " + result.getMessage());
+        verify(specialPriceService, never()).consumeQuota(any(), anyInt());
+        verify(orderMapper, never()).insert(any(ProductOrder.class));
+    }
+
+    @Test
+    @DisplayName("异常流-5:allowCoupon=0 且带 couponId → 拒绝")
+    void 异常流_优惠券不允许() {
+        when(specialPriceService.getById(DEAL_ID)).thenReturn(activeDeal(0, 2, 0));
+        when(specialPriceService.getUserPurchased(DEAL_ID, BUYER_ID)).thenReturn(0);
+        CreateProductOrderDTO dto = specialDealDTO(2);
+        dto.setCouponId(9L);
+
+        Result<ProductOrderDTO> result = service.create(dto, BUYER_ID);
+
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("不可使用优惠券"), "实际: " + result.getMessage());
+        verify(specialPriceService, never()).consumeQuota(any(), anyInt());
+    }
+
+    @Test
+    @DisplayName("异常流-6:特价仅支持单商品直购,带 items → 拒绝")
+    void 异常流_特价不支持购物车多商品() {
+        CreateProductOrderDTO dto = specialDealDTO(2);
+        dto.setItems(Collections.singletonList(new OrderItemVO()));
+
+        Result<ProductOrderDTO> result = service.create(dto, BUYER_ID);
+
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("单独购买"), "实际: " + result.getMessage());
+        verify(specialPriceService, never()).consumeQuota(any(), anyInt());
+    }
+
+    // ==================== 权限流 ====================
+
+    @Test
+    @DisplayName("权限流-1:未登录特价下单 → 请先登录")
+    void 权限流_未登录特价下单() {
+        Result<ProductOrderDTO> result = service.create(specialDealDTO(2), null);
+
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("请先登录"), "实际: " + result.getMessage());
+        verify(orderMapper, never()).insert(any(ProductOrder.class));
+    }
+
+    // ==================== 回滚挂载点 ====================
+
+    @Test
+    @DisplayName("回滚流-1:取消特价订单 → releaseQuota 释放额度")
+    void 回滚流_取消订单释放额度() {
+        ProductOrder order = specialDealOrder();
+        order.setStatus("pending");
+        when(orderMapper.selectOne(any())).thenReturn(order);
+
+        Result<String> result = service.cancel(ORDER_NO, BUYER_ID);
+
+        assertEquals(200, result.getCode(), "取消订单应成功");
+        assertEquals("cancelled", order.getStatus(), "订单状态应更新为取消");
+        verify(specialPriceService).releaseQuota(DEAL_ID, 2);
+    }
+
+    @Test
+    @DisplayName("回滚流-2:退款完成 → releaseQuota 释放额度")
+    void 回滚流_退款完成释放额度() {
+        ProductOrder order = specialDealOrder();
+        order.setStatus("paid");
+        order.setRefundStatus(1);
+        order.setMoneyAmount(0);
+        when(orderMapper.selectOne(any())).thenReturn(order);
+
+        Result<String> result = service.approveRefund(ORDER_NO, 500L);
+
+        assertEquals(200, result.getCode(), "审核退款应成功");
+        assertEquals("refunded", order.getStatus(), "订单状态应为已退款");
+        verify(specialPriceService).releaseQuota(DEAL_ID, 2);
+    }
+
+    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;
+    }
+}

+ 256 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/SupplierOrderFulfillContractTest.java

@@ -0,0 +1,256 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.ProductOrder;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.ProductOrderMapper;
+import com.etotem.cfc.service.ProductOrderService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:服务商订单到店核销(能力键 {@code supplier.order.fulfill},任务 S-cfc-169)。
+ *
+ * <p><b>需求/设计锚点</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-S-04 订单核销(到店)——
+ *       扫码核销→完成;边界:<b>重复核销幂等;超期订单提示不可核销</b>;</li>
+ *   <li>派生用例:{@code tests/test-cases/vendor.md} TC-S-04(重复核销幂等;超期订单提示不可核销)、
+ *       TC-S-04-E(重复扫码核销同订单;超期订单核销;核销非本服务商订单 → 403)、
+ *       TC-S-04-P(操作非本服务商订单被拒;<b>核销码校验防冒用</b>)、
+ *       TC-S-03-E(核销码被冒用/篡改 → 核销码校验失败拒);</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.8 商品与订单 ——
+ *       {@code /api/product/order/vendor}、{@code /vendor-confirm}、{@code /ship}
+ *       (已合入 origin/cfclub);SC-S-04 到店核销入口为本契约新增能力(TDD-RED 阶段)。</li>
+ * </ul>
+ *
+ * <p><b>宿主契约(TDD-RED 锚点)</b>:{@code com.etotem.cfc.service.ProductOrderService#redeem(...)}
+ * —— 服务商到店核销入口。当前(2026-11-12)src/main 中 {@code pickupCode} 自提码仅在订单创建时生成
+ * ({@code generatePickupCode()}),<b>任何地方均未消费/校验</b>——无核销方法、无到期判定、无防冒用校验,
+ * 即 SC-S-04 实现未落地(真实缺口)。本测试通过反射锚定该契约方法(存在父类非关键依赖,仅以
+ * {@code ProductOrderService} 反射探测),运行时契约方法缺失即断言失败(红),暴露"实现未落地";
+ * GREEN 阶段在 ProductOrderService 落地 {@code redeem} 后本契约自动转绿。</p>
+ *
+ * <p><b>契约方法签名(实现方按此落地)</b></p>
+ * <pre>
+ * public Result&lt;String&gt; redeem(String orderNo, String pickupCode, Long vendorId)
+ * </pre>
+ * <ul>
+ *   <li><b>正常流</b>(SC-S-04):本服务商 + 自提码匹配 + 未超期 → 核销完成,订单置「已完成」,返回成功;</li>
+ *   <li><b>异常流</b>(TC-S-03-E 防冒用):自提码不匹配 → 拒绝(提示核销码校验失败),不落库;</li>
+ *   <li><b>边界流</b>(TC-S-04):重复核销 → 幂等(第二次返回成功提示、不重复落库/不重复副作用);
+ *       超期订单(自提时段已过)→ 提示不可核销、状态不变更;</li>
+ *   <li><b>权限流</b>(TC-S-04-P):非本服务商商品订单核销 → 「无权操作」,不落库。</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:纯契约级单测,无 Spring 上下文 / 无 DB / 无外部服务;Mockito + 反射注入 mapper。</p>
+ *
+ * <p><b>RED 预期</b>:{@code ProductOrderService#redeem} 未实现,全部用例在反射探测处失败(红),
+ * 断言原文为「RED[supplier.order.fulfill] 契约方法 ProductOrderService#redeem 未实现」。</p>
+ */
+class SupplierOrderFulfillContractTest {
+
+    /** 契约宿主:已存在的订单服务类(redeem 方法缺失)。 */
+    private static final String HOST_CLASS = "com.etotem.cfc.service.ProductOrderService";
+
+    /** 到店核销自提码(订单创建时生成的字面值)。 */
+    private static final String PICKUP_CODE = "A1B2C3";
+
+    private ProductOrderService service;
+    private ProductOrderMapper orderMapper;
+    private ProductMapper productMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new ProductOrderService();
+        orderMapper = mock(ProductOrderMapper.class);
+        productMapper = mock(ProductMapper.class);
+        inject(service, "orderMapper", orderMapper);
+        inject(service, "productMapper", productMapper);
+    }
+
+    private static void inject(Object target, String field, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(field);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    // ==================== 正常流(SC-S-04:扫码核销 → 完成) ====================
+
+    @Test
+    @DisplayName("正常流:本服务商 + 自提码匹配 + 未超期 → 核销完成,订单置已完成")
+    void 正常流_自提码匹配_核销完成订单已完成() {
+        ProductOrder o = order("FO-001", "paid", 2);
+        when(orderMapper.selectOne(any(Wrapper.class))).thenReturn(o);
+        when(productMapper.selectById(10L)).thenReturn(product(7L));
+
+        Result<String> result = redeem("FO-001", PICKUP_CODE, 7L);
+
+        assertEquals(200, result.getCode(), "核销成功应返回 200");
+        assertEquals("completed", o.getStatus(), "到店核销后订单应置「已完成」");
+        verify(orderMapper).updateById(o);
+    }
+
+    // ==================== 异常流(TC-S-03-E:核销码防冒用) ====================
+
+    @Test
+    @DisplayName("异常流:自提码不匹配 → 拒绝核销(防冒用),不落库")
+    void 异常流_核销码不匹配_拒绝且不落库() {
+        ProductOrder o = order("FO-002", "paid", 2);
+        when(orderMapper.selectOne(any(Wrapper.class))).thenReturn(o);
+        when(productMapper.selectById(10L)).thenReturn(product(7L));
+
+        Result<String> result = redeem("FO-002", "WRONG-CODE", 7L);
+
+        assertEquals(500, result.getCode(), "自提码不匹配应拒绝核销");
+        assertTrue(result.getMessage().contains("核销码"),
+                "防冒用提示应含「核销码」,实际: " + result.getMessage());
+        assertEquals("paid", o.getStatus(), "拒绝核销不得变更订单状态");
+        verify(orderMapper, never()).updateById(any(ProductOrder.class));
+    }
+
+    // ==================== 边界流(TC-S-04:重复核销幂等;超期不可核销) ====================
+
+    @Test
+    @DisplayName("边界流:重复核销同订单 → 幂等(第二次成功返回,仅落库一次,无重复副作用)")
+    void 边界流_重复核销_幂等仅一次副作用() {
+        ProductOrder o = order("FO-003", "paid", 2);
+        when(orderMapper.selectOne(any(Wrapper.class))).thenReturn(o);
+        when(productMapper.selectById(10L)).thenReturn(product(7L));
+
+        Result<String> first = redeem("FO-003", PICKUP_CODE, 7L);
+        Result<String> second = redeem("FO-003", PICKUP_CODE, 7L);
+
+        assertEquals(200, first.getCode(), "首次核销应成功");
+        assertEquals(200, second.getCode(), "重复核销应幂等返回成功(不允许重复报错)");
+        assertEquals("completed", o.getStatus());
+        verify(orderMapper, times(1)).updateById(any(ProductOrder.class));
+    }
+
+    @Test
+    @DisplayName("边界流:自提时段已超期 → 提示不可核销,状态不变更")
+    void 边界流_超期订单_不可核销() {
+        ProductOrder o = order("FO-004", "paid", 2);
+        // 自提时段快照:结束时间已过期(早于今天),契约要求核销入口做超期判定
+        o.setTimeSlotSnapshot("{\"endTime\":\"2020-01-01 09:00:00\"}");
+        when(orderMapper.selectOne(any(Wrapper.class))).thenReturn(o);
+        when(productMapper.selectById(10L)).thenReturn(product(7L));
+
+        Result<String> result = redeem("FO-004", PICKUP_CODE, 7L);
+
+        assertEquals(500, result.getCode(), "超期订单应拒绝核销");
+        assertTrue(result.getMessage().contains("超期"),
+                "超期提示应含「超期」,实际: " + result.getMessage());
+        assertEquals("paid", o.getStatus(), "超期拒绝不得变更订单状态");
+        verify(orderMapper, never()).updateById(any(ProductOrder.class));
+    }
+
+    // ==================== 权限流(TC-S-04-P:非本服务商拒) ====================
+
+    @Test
+    @DisplayName("权限流:非本服务商订单核销 → 无权操作,不落库")
+    void 权限流_非本服务商_无权核销() {
+        ProductOrder o = order("FO-005", "paid", 2);
+        when(orderMapper.selectOne(any(Wrapper.class))).thenReturn(o);
+        when(productMapper.selectById(10L)).thenReturn(product(7L)); // 商品归属供应商 7
+
+        Result<String> result = redeem("FO-005", PICKUP_CODE, 8L); // 供应商 8 ≠ 7
+
+        assertEquals(500, result.getCode(), "非本服务商应拒绝核销");
+        assertTrue(result.getMessage().contains("无权"),
+                "越权提示应含「无权」,实际: " + result.getMessage());
+        assertEquals("paid", o.getStatus(), "越权拒绝不得变更订单状态");
+        verify(orderMapper, never()).updateById(any(ProductOrder.class));
+    }
+
+    // ==================== 契约方法调用(反射锚定,缺失即红) ====================
+
+    /**
+     * 按契约签名 {@code redeem(String, String, Long)} 反射调用。
+     * 方法缺失 → 断言失败(红),暴露「SC-S-04 到店核销未落地」。
+     */
+    private Result<String> redeem(String orderNo, String pickupCode, Long vendorId) {
+        Method m = findMethod("redeem", String.class, String.class, Long.class);
+        if (m == null) {
+            fail("RED[supplier.order.fulfill] 契约方法 ProductOrderService#redeem(String, String, Long) 未实现:"
+                    + "SC-S-04 到店核销(扫码核销→完成)未落地——pickupCode 自提码仅在订单创建时生成、"
+                    + "任何地方均未消费/校验(缺防冒用、缺重复核销幂等、缺超期判定、缺非本服务商越权拒绝)。"
+                    + "GREEN 阶段在 ProductOrderService 落地 redeem 后本契约自动转绿。");
+            throw new IllegalStateException("unreachable");
+        }
+        try {
+            Object r = m.invoke(service, orderNo, pickupCode, vendorId);
+            if (r instanceof Result) {
+                @SuppressWarnings("unchecked")
+                Result<String> res = (Result<String>) r;
+                return res;
+            }
+            fail("RED[supplier.order.fulfill] 契约方法 redeem 应返回 Result,实际返回: " + r);
+            throw new IllegalStateException("unreachable");
+        } catch (Exception e) {
+            fail("RED[supplier.order.fulfill] 契约方法 redeem 调用失败: " + e.getMessage());
+            throw new IllegalStateException("unreachable");
+        }
+    }
+
+    private Method findMethod(String name, Class<?> p0, Class<?> p1, Class<?> p2) {
+        for (Method m : ProductOrderService.class.getDeclaredMethods()) {
+            if (m.getName().equals(name) && m.getParameterCount() == 3
+                    && m.getParameterTypes()[0].equals(p0)
+                    && m.getParameterTypes()[1].equals(p1)
+                    && m.getParameterTypes()[2].equals(p2)) {
+                return m;
+            }
+        }
+        for (Method m : ProductOrderService.class.getMethods()) {
+            if (m.getName().equals(name) && m.getParameterCount() == 3
+                    && m.getParameterTypes()[0].equals(p0)
+                    && m.getParameterTypes()[1].equals(p1)
+                    && m.getParameterTypes()[2].equals(p2)) {
+                return m;
+            }
+        }
+        return null;
+    }
+
+    // ==================== 夹具 ====================
+
+    private ProductOrder order(String orderNo, String status, int deliveryMethod) {
+        ProductOrder o = new ProductOrder();
+        o.setId(1L);
+        o.setOrderNo(orderNo);
+        o.setStatus(status);
+        o.setBuyerId(1L);
+        o.setProductId(10L);
+        o.setProductName("到店核销课程");
+        o.setProductType("physical");
+        o.setQuantity(1);
+        o.setDeliveryMethod(deliveryMethod);
+        o.setFulfillStatus("pending");
+        o.setPickupCode(PICKUP_CODE);
+        return o;
+    }
+
+    private Product product(Long vendorId) {
+        Product p = new Product();
+        p.setId(10L);
+        p.setVendorId(vendorId);
+        return p;
+    }
+}