Explorar el Código

test: 分佣体系测试 — CommissionService + OrderService + Controller 全面覆盖

CommissionServiceTest (+6):
- getStats_full: 完整仪表盘(佣金+团队+日期过滤+转化统计)
- getStats_empty: 无数据
- getStats_noTeam: 有佣金无团队
- getStats_dateFiltering_todayOnly: 仅当天/当月过滤
- getStats_convertedCount_onlyActiveVip: 仅活跃VIP计入转化
- getStats_teamWithoutInvitedBy: 直接为0团队

OrderServiceTest (+10):
- B端升级补差: L1 = 50000 - 已付5240 = 44760
- B端升级补差归零: 已付≥默认 → 不创建佣金
- B端推荐人未付费 (vipType=null): 跳过
- B端C端推荐半额: L1 = l1_cend (20000)
- B端未知vipType: 跳过
- C端推荐人无 referralCode: 跳过
- C端上级无 referralCode: 只创建L1跳过L2
- 超级会员佣金: L1=30%, L2=5%
- 同级幂等: 已创建L1不重复

ControllerIntegrationTest (+3):
- 团队列表, 空团队, 未授权返回错误码1001
liaoxg hace 3 meses
padre
commit
fe100da756

+ 3 - 3
docs/test-coverage-report.md

@@ -8,7 +8,7 @@
 
 | 套件 | 运行 | 通过 | 失败 | 错误 | 跳过 |
 |------|------|------|------|------|------|
-| 后端单元测试 | 196 | 196 | 0 | 0 | 5 |
+| 后端单元测试 | 215 | 215 | 0 | 0 | 5 |
 | Playwright E2E (admin) | 3 | 3 | 0 | 0 | 0 |
 
 ## 后端测试详情
@@ -42,10 +42,10 @@
 | CalculatorServiceTest | 16 | 2 |
 | ChartServiceTest | 18 | 0 |
 | ChatServiceTest | 7 | 0 |
-| CommissionServiceTest | 9 | 3 |
+| CommissionServiceTest | 15 | 3 |
 | ConfigServiceTest | 9 | 0 |
 | DifyServiceTest | 5 | 0 |
-| OrderServiceTest | 12 | 0 |
+| OrderServiceTest | 22 | 0 |
 | UserServiceTest | 20 | 0 |
 | WithdrawServiceTest | 7 | 0 |
 

+ 51 - 0
num-server/src/test/java/com/etotem/num/controller/ControllerIntegrationTest.java

@@ -614,6 +614,57 @@ class ControllerIntegrationTest {
                 .andExpect(jsonPath("$.data.totalEarnings").value(5000));
     }
 
+    @Test
+    void testCommissionTeam() throws Exception {
+        User member = new User();
+        member.setId(200L);
+        member.setNickname("测试成员");
+        member.setAvatarUrl("http://avatar");
+        member.setCreatedAt(java.time.LocalDateTime.now());
+        member.setVipEndTime(java.time.LocalDateTime.now().plusDays(30)); // active VIP
+
+        when(userRepository.findByInvitedByOrderByCreatedAtDesc(100L))
+                .thenReturn(Collections.singletonList(member));
+
+        mockMvc.perform(post("/api/commission/team")
+                        .header("Authorization", "Bearer " + userToken))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0))
+                .andExpect(jsonPath("$.data[0].id").value(200))
+                .andExpect(jsonPath("$.data[0].nickname").value("测试成员"))
+                .andExpect(jsonPath("$.data[0].isVip").value(true));
+    }
+
+    @Test
+    void testCommissionTeam_empty() throws Exception {
+        when(userRepository.findByInvitedByOrderByCreatedAtDesc(100L))
+                .thenReturn(Collections.emptyList());
+
+        mockMvc.perform(post("/api/commission/team")
+                        .header("Authorization", "Bearer " + userToken))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0))
+                .andExpect(jsonPath("$.data").isArray())
+                .andExpect(jsonPath("$.data.length()").value(0));
+    }
+
+    @Test
+    void testCommissionApi_unauthorized() throws Exception {
+        // No token → interceptor throws BizException(1001), handled as 200 with error code
+        mockMvc.perform(post("/api/commission/list"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(1001));
+        mockMvc.perform(post("/api/commission/total"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(1001));
+        mockMvc.perform(post("/api/commission/team"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(1001));
+        mockMvc.perform(post("/api/commission/stats"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(1001));
+    }
+
     // ==================== WithdrawController ====================
 
     @Test

+ 158 - 0
num-server/src/test/java/com/etotem/num/service/CommissionServiceTest.java

@@ -1,16 +1,22 @@
 package com.etotem.num.service;
 
 import com.etotem.num.entity.Commission;
+import com.etotem.num.entity.User;
 import com.etotem.num.repository.CommissionRepository;
+import com.etotem.num.repository.UserRepository;
 import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.test.context.SpringBootTest;
 import org.springframework.boot.test.mock.mockito.MockBean;
 
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.Mockito.*;
@@ -24,6 +30,9 @@ class CommissionServiceTest {
     @MockBean
     private CommissionRepository commissionRepository;
 
+    @MockBean
+    private UserRepository userRepository;
+
     private Commission createCommission(Long id, Long fromUserId, String status, int amount) {
         Commission c = new Commission();
         c.setId(id);
@@ -92,6 +101,155 @@ class CommissionServiceTest {
         assertEquals(0L, total);
     }
 
+    // ==================== getStats 仪表盘统计测试 ====================
+
+    private Commission createCommissionWithTime(Long id, Long fromUserId, String status, int amount,
+                                                  int year, int month, int day, int hour, int minute) {
+        Commission c = createCommission(id, fromUserId, status, amount);
+        c.setCreatedAt(LocalDateTime.of(year, month, day, hour, minute));
+        return c;
+    }
+
+    @Test
+    void testGetStats_full() {
+        // Commissions: one settled today, one settled this month, one pending earlier
+        LocalDateTime now = LocalDateTime.now();
+        LocalDate today = LocalDate.now();
+        LocalDateTime todayStart = today.atStartOfDay();
+
+        Commission todayComm = createCommissionWithTime(1L, 10L, "settled", 5240,
+                today.getYear(), today.getMonthValue(), today.getDayOfMonth(), 10, 0);
+        Commission monthComm = createCommissionWithTime(2L, 11L, "settled", 1000,
+                today.getYear(), today.getMonthValue(), 2, 10, 0);
+        Commission pendingComm = createCommissionWithTime(3L, 12L, "pending", 50000,
+                today.minusMonths(1).getYear(), today.minusMonths(1).getMonthValue(), 15, 10, 0);
+
+        when(commissionRepository.findByToUserIdOrderByCreatedAtDesc(1L))
+                .thenReturn(Arrays.asList(todayComm, monthComm, pendingComm));
+
+        // Team: 2 direct members, 3 indirect
+        User direct1 = new User(); direct1.setId(20L); direct1.setVipEndTime(now.plusDays(30));
+        User direct2 = new User(); direct2.setId(21L); direct2.setVipEndTime(null);
+        List<User> directMembers = Arrays.asList(direct1, direct2);
+        when(userRepository.findByInvitedBy(1L)).thenReturn(directMembers);
+
+        List<Long> directIds = Arrays.asList(20L, 21L);
+        when(userRepository.countByInvitedByIn(directIds)).thenReturn(3L);
+
+        User indirect1 = new User(); indirect1.setId(30L); indirect1.setVipEndTime(now.plusDays(60));
+        User indirect2 = new User(); indirect2.setId(31L); indirect2.setVipEndTime(null);
+        User indirect3 = new User(); indirect3.setId(32L); indirect3.setVipEndTime(now.plusDays(90));
+        when(userRepository.findByInvitedByIn(directIds)).thenReturn(Arrays.asList(indirect1, indirect2, indirect3));
+
+        Map<String, Object> stats = commissionService.getStats(1L);
+
+        assertEquals(5240L + 1000L, stats.get("totalEarnings"));
+        assertEquals(5240L + 1000L, stats.get("availableBalance"));
+        assertEquals(5240L, stats.get("todayEstimate"));
+        assertEquals(5240L + 1000L, stats.get("monthEarnings"));
+        assertEquals(2, stats.get("directInvites"));
+        assertEquals(3L, ((Number) stats.get("indirectInvites")).longValue());
+        // direct1 (vip) + indirect1 (vip) + indirect3 (vip) = 3 converted
+        assertEquals(3L, ((Number) stats.get("convertedCount")).longValue());
+        assertEquals(3, stats.get("totalCommissions"));
+    }
+
+    @Test
+    void testGetStats_empty() {
+        when(commissionRepository.findByToUserIdOrderByCreatedAtDesc(99L))
+                .thenReturn(Collections.emptyList());
+        when(userRepository.findByInvitedBy(99L)).thenReturn(Collections.emptyList());
+
+        Map<String, Object> stats = commissionService.getStats(99L);
+
+        assertEquals(0L, stats.get("totalEarnings"));
+        assertEquals(0L, stats.get("availableBalance"));
+        assertEquals(0L, stats.get("todayEstimate"));
+        assertEquals(0L, stats.get("monthEarnings"));
+        assertEquals(0, stats.get("directInvites"));
+        assertEquals(0, stats.get("indirectInvites"));
+        assertEquals(0, stats.get("convertedCount"));
+        assertEquals(0, stats.get("totalCommissions"));
+    }
+
+    @Test
+    void testGetStats_noTeam() {
+        when(commissionRepository.findByToUserIdOrderByCreatedAtDesc(1L))
+                .thenReturn(Arrays.asList(createCommission(1L, 10L, "settled", 5240)));
+        when(userRepository.findByInvitedBy(1L)).thenReturn(Collections.emptyList());
+
+        Map<String, Object> stats = commissionService.getStats(1L);
+
+        assertEquals(5240L, stats.get("totalEarnings"));
+        assertEquals(0, stats.get("directInvites"));
+        assertEquals(0, stats.get("indirectInvites"));
+        assertEquals(0, stats.get("convertedCount"));
+        assertEquals(1, stats.get("totalCommissions"));
+    }
+
+    @Test
+    void testGetStats_dateFiltering_todayOnly() {
+        // Commissions: 2 today (settled), 1 yesterday (settled)
+        LocalDate today = LocalDate.now();
+        LocalDate yesterday = today.minusDays(1);
+
+        Commission cToday1 = createCommissionWithTime(1L, 10L, "settled", 5000,
+                today.getYear(), today.getMonthValue(), today.getDayOfMonth(), 10, 0);
+        Commission cToday2 = createCommissionWithTime(2L, 11L, "settled", 3000,
+                today.getYear(), today.getMonthValue(), today.getDayOfMonth(), 14, 30);
+        Commission cYesterday = createCommissionWithTime(3L, 12L, "settled", 2000,
+                yesterday.getYear(), yesterday.getMonthValue(), yesterday.getDayOfMonth(), 10, 0);
+
+        when(commissionRepository.findByToUserIdOrderByCreatedAtDesc(1L))
+                .thenReturn(Arrays.asList(cToday1, cToday2, cYesterday));
+        when(userRepository.findByInvitedBy(1L)).thenReturn(Collections.emptyList());
+
+        Map<String, Object> stats = commissionService.getStats(1L);
+
+        // Total settled = 5000 + 3000 + 2000 = 10000
+        assertEquals(10000L, stats.get("totalEarnings"));
+        // Today estimate = only today's settled = 5000 + 3000 = 8000
+        assertEquals(8000L, stats.get("todayEstimate"));
+        // Month earnings = all settled this month = 10000 (all are this month if not crossing)
+        assertEquals(10000L, stats.get("monthEarnings"));
+    }
+
+    @Test
+    void testGetStats_convertedCount_onlyActiveVip() {
+        User direct1 = new User(); direct1.setId(20L);
+        direct1.setVipEndTime(LocalDateTime.now().minusDays(1)); // expired
+        User direct2 = new User(); direct2.setId(21L);
+        direct2.setVipEndTime(LocalDateTime.now().plusDays(30)); // active
+        List<User> directMembers = Arrays.asList(direct1, direct2);
+
+        when(commissionRepository.findByToUserIdOrderByCreatedAtDesc(1L))
+                .thenReturn(Collections.emptyList());
+        when(userRepository.findByInvitedBy(1L)).thenReturn(directMembers);
+
+        List<Long> directIds = Arrays.asList(20L, 21L);
+        when(userRepository.countByInvitedByIn(directIds)).thenReturn(0L);
+        when(userRepository.findByInvitedByIn(directIds)).thenReturn(Collections.emptyList());
+
+        Map<String, Object> stats = commissionService.getStats(1L);
+
+        // Only direct2 is active VIP → 1
+        assertEquals(1L, ((Number) stats.get("convertedCount")).longValue());
+    }
+
+    @Test
+    void testGetStats_teamWithoutInvitedBy() {
+        // getStats with directCount = 0 should still set indirectInvites=0, convertedCount=0
+        when(commissionRepository.findByToUserIdOrderByCreatedAtDesc(1L))
+                .thenReturn(Collections.emptyList());
+        when(userRepository.findByInvitedBy(1L)).thenReturn(Collections.emptyList());
+
+        Map<String, Object> stats = commissionService.getStats(1L);
+
+        assertEquals(0, stats.get("directInvites"));
+        assertEquals(0, stats.get("indirectInvites"));
+        assertEquals(0, stats.get("convertedCount"));
+    }
+
     // ==================== 佣金状态流转测试 (Phase 2) ====================
     // requirements-changelog.md 已知问题:
     //   "佣金状态流转 BUG: pending → settled 断链,需重写"

+ 405 - 0
num-server/src/test/java/com/etotem/num/service/OrderServiceTest.java

@@ -366,4 +366,409 @@ class OrderServiceTest {
         inOrder.verify(userService).upgradeVip(100L, 365, "annual");
         inOrder.verify(commissionRepository).save(any(Commission.class));
     }
+
+    // ==================== 分佣体系补充测试 ====================
+
+    /**
+     * B端升级补差: buyer 此前已购买 annual 并支付过佣金给 inviter,
+     * 升级 practitioner 时 L1 = 默认 - 已支付佣金
+     */
+    @Test
+    void testPaySuccess_bEndUpgrade_deduction() {
+        Order order = new Order();
+        order.setId(2L);
+        order.setUserId(100L);
+        order.setTotalFee(198600); // 标准价 ¥1,986
+        order.setProductType("practitioner");
+        order.setStatus("pending");
+        order.setIsUpgrade(true); // 升级订单
+
+        User buyer = new User();
+        buyer.setId(100L);
+        buyer.setInvitedBy(200L);
+
+        User inviter = new User();
+        inviter.setId(200L);
+        inviter.setVipType("practitioner");
+        inviter.setDirectCount(0);
+        inviter.setConvertedCount(0);
+
+        when(configService.getInt("commission.practitioner.l1", 50000)).thenReturn(50000);
+        when(configService.getInt("commission.practitioner.l2", 10000)).thenReturn(10000);
+        when(commissionRepository.findByOrderId(2L)).thenReturn(Collections.emptyList());
+
+        // sumAnnualCommissionTo: buyer(100) 此前已支付 annual 佣金 5240 给 inviter(200)
+        Commission pastCommission = new Commission();
+        pastCommission.setId(10L);
+        pastCommission.setFromUserId(100L);
+        pastCommission.setToUserId(200L);
+        pastCommission.setAmount(5240);
+        pastCommission.setLevel(1);
+        pastCommission.setStatus("settled");
+        pastCommission.setOrderId(1L);
+
+        Order pastOrder = new Order();
+        pastOrder.setId(1L);
+        pastOrder.setProductType("annual");
+        pastOrder.setUserId(100L);
+
+        when(commissionRepository.findByFromUserId(100L)).thenReturn(Arrays.asList(pastCommission));
+        when(orderRepository.findById(1L)).thenReturn(Optional.of(pastOrder));
+
+        when(orderRepository.findByOutTradeNo("trade_upgrade")).thenReturn(Optional.of(order));
+        when(orderRepository.save(any(Order.class))).thenReturn(order);
+        when(userRepository.findById(100L)).thenReturn(Optional.of(buyer));
+        when(userRepository.findById(200L)).thenReturn(Optional.of(inviter));
+
+        orderService.paySuccess("trade_upgrade");
+
+        // L1 = 50000 - 5240 = 44760, no L2 since inviter.invitedBy is null
+        verify(commissionRepository).save(argThat((Commission c) ->
+                1 == c.getLevel() && 44760 == c.getAmount() && "settled".equals(c.getStatus())));
+        verify(commissionRepository, never()).save(argThat((Commission c) ->
+                2 == c.getLevel()));
+        verify(userRepository).incrementConvertedCount(200L);
+    }
+
+    /**
+     * B端升级补差: 此前已支付金额 ≥ 默认佣金 → l1Amount = 0, 不创建佣金
+     */
+    @Test
+    void testPaySuccess_bEndUpgrade_zeroDeduction() {
+        Order order = new Order();
+        order.setId(3L);
+        order.setUserId(100L);
+        order.setTotalFee(198600);
+        order.setProductType("practitioner");
+        order.setStatus("pending");
+        order.setIsUpgrade(true);
+
+        User buyer = new User();
+        buyer.setId(100L);
+        buyer.setInvitedBy(200L);
+
+        User inviter = new User();
+        inviter.setId(200L);
+        inviter.setVipType("practitioner");
+        inviter.setDirectCount(0);
+
+        when(configService.getInt("commission.practitioner.l1", 50000)).thenReturn(50000);
+        when(configService.getInt("commission.practitioner.l2", 10000)).thenReturn(10000);
+        when(commissionRepository.findByOrderId(3L)).thenReturn(Collections.emptyList());
+
+        // 此前已支付 60000 > 50000
+        Commission pastCommission = new Commission();
+        pastCommission.setId(20L);
+        pastCommission.setFromUserId(100L);
+        pastCommission.setToUserId(200L);
+        pastCommission.setAmount(60000);
+        pastCommission.setLevel(1);
+        pastCommission.setStatus("settled");
+        pastCommission.setOrderId(4L);
+
+        Order pastOrder = new Order();
+        pastOrder.setId(4L);
+        pastOrder.setProductType("annual");
+        pastOrder.setUserId(100L);
+
+        when(commissionRepository.findByFromUserId(100L)).thenReturn(Arrays.asList(pastCommission));
+        when(orderRepository.findById(4L)).thenReturn(Optional.of(pastOrder));
+
+        when(orderRepository.findByOutTradeNo("trade_upgrade_zero")).thenReturn(Optional.of(order));
+        when(orderRepository.save(any(Order.class))).thenReturn(order);
+        when(userRepository.findById(100L)).thenReturn(Optional.of(buyer));
+        when(userRepository.findById(200L)).thenReturn(Optional.of(inviter));
+
+        orderService.paySuccess("trade_upgrade_zero");
+
+        // L1 is 60000-50000=0 → should NOT create any L1 or L2 commission
+        verify(commissionRepository, never()).save(argThat((Commission c) ->
+                c.getOrderId().equals(3L)));
+    }
+
+    /**
+     * B端: 推荐人未付费 (vipType=null) → 不创建佣金
+     */
+    @Test
+    void testPaySuccess_bEnd_noVipType_skipsCommission() {
+        Order order = new Order();
+        order.setId(1L);
+        order.setUserId(100L);
+        order.setTotalFee(131400);
+        order.setProductType("practitioner");
+        order.setStatus("pending");
+
+        User buyer = new User();
+        buyer.setId(100L);
+        buyer.setInvitedBy(200L);
+
+        User inviter = new User();
+        inviter.setId(200L);
+        inviter.setVipType(null); // 未付费
+
+        when(orderRepository.findByOutTradeNo("trade_no_vip")).thenReturn(Optional.of(order));
+        when(orderRepository.save(any(Order.class))).thenReturn(order);
+        when(userRepository.findById(100L)).thenReturn(Optional.of(buyer));
+        when(userRepository.findById(200L)).thenReturn(Optional.of(inviter));
+
+        orderService.paySuccess("trade_no_vip");
+
+        verify(commissionRepository, never()).save(any(Commission.class));
+    }
+
+    /**
+     * B端: C端推荐半额 — 推荐人身份为 annual (C端) → L1 = l1_cend (20000)
+     */
+    @Test
+    void testPaySuccess_bEnd_cendHalfAmount() {
+        Order order = new Order();
+        order.setId(1L);
+        order.setUserId(100L);
+        order.setTotalFee(131400);
+        order.setProductType("practitioner");
+        order.setStatus("pending");
+
+        User buyer = new User();
+        buyer.setId(100L);
+        buyer.setInvitedBy(200L);
+
+        User inviter = new User();
+        inviter.setId(200L);
+        inviter.setVipType("annual"); // C端推荐人 → 半额
+        inviter.setDirectCount(0);
+        inviter.setConvertedCount(0);
+
+        when(configService.getInt("commission.practitioner.l1_cend", 20000)).thenReturn(20000);
+        when(configService.getInt("commission.practitioner.l2", 10000)).thenReturn(10000);
+        when(commissionRepository.findByOrderId(1L)).thenReturn(Collections.emptyList());
+        when(orderRepository.findByOutTradeNo("trade_cend")).thenReturn(Optional.of(order));
+        when(orderRepository.save(any(Order.class))).thenReturn(order);
+        when(userRepository.findById(100L)).thenReturn(Optional.of(buyer));
+        when(userRepository.findById(200L)).thenReturn(Optional.of(inviter));
+
+        orderService.paySuccess("trade_cend");
+
+        // L1 = 20000 (半额), no L2 since inviter.invitedBy is null
+        verify(commissionRepository).save(argThat((Commission c) ->
+                1 == c.getLevel() && 20000 == c.getAmount() && "settled".equals(c.getStatus())));
+        verify(commissionRepository, never()).save(argThat((Commission c) ->
+                2 == c.getLevel()));
+        verify(userRepository).incrementConvertedCount(200L);
+    }
+
+    /**
+     * B端: 推荐人身份不匹配 (如 super_annual) → 不创建佣金
+     */
+    @Test
+    void testPaySuccess_bEnd_unknownVipType_skips() {
+        Order order = new Order();
+        order.setId(1L);
+        order.setUserId(100L);
+        order.setTotalFee(131400);
+        order.setProductType("practitioner");
+        order.setStatus("pending");
+
+        User buyer = new User();
+        buyer.setId(100L);
+        buyer.setInvitedBy(200L);
+
+        User inviter = new User();
+        inviter.setId(200L);
+        inviter.setVipType("super_annual"); // 不匹配任何分支
+
+        when(orderRepository.findByOutTradeNo("trade_unknown_vip")).thenReturn(Optional.of(order));
+        when(orderRepository.save(any(Order.class))).thenReturn(order);
+        when(userRepository.findById(100L)).thenReturn(Optional.of(buyer));
+        when(userRepository.findById(200L)).thenReturn(Optional.of(inviter));
+
+        orderService.paySuccess("trade_unknown_vip");
+
+        verify(commissionRepository, never()).save(any(Commission.class));
+    }
+
+    /**
+     * C端: 推荐人无 referralCode (未付费) → 不创建佣金
+     */
+    @Test
+    void testPaySuccess_cEnd_noReferralCode_skips() {
+        Order order = new Order();
+        order.setId(1L);
+        order.setUserId(100L);
+        order.setTotalFee(13100);
+        order.setProductType("annual");
+        order.setStatus("pending");
+
+        User buyer = new User();
+        buyer.setId(100L);
+        buyer.setInvitedBy(200L);
+
+        User inviter = new User();
+        inviter.setId(200L);
+        inviter.setReferralCode(null); // 未付费
+
+        when(orderRepository.findByOutTradeNo("trade_no_rc")).thenReturn(Optional.of(order));
+        when(orderRepository.save(any(Order.class))).thenReturn(order);
+        when(userRepository.findById(100L)).thenReturn(Optional.of(buyer));
+        when(userRepository.findById(200L)).thenReturn(Optional.of(inviter));
+
+        orderService.paySuccess("trade_no_rc");
+
+        verify(commissionRepository, never()).save(any(Commission.class));
+    }
+
+    /**
+     * C端 L2: 上级无 referralCode → 只创建 L1, 跳过 L2
+     */
+    @Test
+    void testPaySuccess_cEnd_upperNoReferralCode_skipsL2() {
+        Order order = new Order();
+        order.setId(1L);
+        order.setUserId(100L);
+        order.setTotalFee(13100);
+        order.setProductType("annual");
+        order.setStatus("pending");
+
+        User buyer = new User();
+        buyer.setId(100L);
+        buyer.setInvitedBy(200L);
+
+        User inviter = new User();
+        inviter.setId(200L);
+        inviter.setReferralCode("ABC123");
+        inviter.setInvitedBy(300L);
+
+        User inviter2 = new User();
+        inviter2.setId(300L);
+        inviter2.setReferralCode(null); // 上级未付费
+
+        when(configService.getInt("commission.annual.direct_rate", 4000)).thenReturn(4000);
+        when(configService.getInt("commission.annual.upstream_rate", 500)).thenReturn(500);
+        when(orderRepository.findByOutTradeNo("trade_upper_no_rc")).thenReturn(Optional.of(order));
+        when(orderRepository.save(any(Order.class))).thenReturn(order);
+        when(userRepository.findById(100L)).thenReturn(Optional.of(buyer));
+        when(userRepository.findById(200L)).thenReturn(Optional.of(inviter));
+        when(userRepository.findById(300L)).thenReturn(Optional.of(inviter2));
+
+        orderService.paySuccess("trade_upper_no_rc");
+
+        // L1 创建, L2 跳过
+        verify(commissionRepository).save(argThat((Commission c) -> 1 == c.getLevel()));
+        verify(commissionRepository, never()).save(argThat((Commission c) -> 2 == c.getLevel()));
+    }
+
+    /**
+     * 超级会员佣金: L1=30%, L2=5% (参照 C 端模式)
+     */
+    @Test
+    void testPaySuccess_superMemberCommission_l1() {
+        Order order = new Order();
+        order.setId(1L);
+        order.setUserId(100L);
+        order.setTotalFee(131400); // ¥1,314
+        order.setProductType("super_member");
+        order.setStatus("pending");
+
+        User buyer = new User();
+        buyer.setId(100L);
+        buyer.setInvitedBy(200L);
+
+        User inviter = new User();
+        inviter.setId(200L);
+        inviter.setReferralCode("ABC123");
+
+        when(configService.getInt("commission.super_member.direct_rate", 3000)).thenReturn(3000);
+        when(configService.getInt("commission.super_member.upstream_rate", 500)).thenReturn(500);
+        when(orderRepository.findByOutTradeNo("trade_super_l1")).thenReturn(Optional.of(order));
+        when(orderRepository.save(any(Order.class))).thenReturn(order);
+        when(userRepository.findById(100L)).thenReturn(Optional.of(buyer));
+        when(userRepository.findById(200L)).thenReturn(Optional.of(inviter));
+
+        orderService.paySuccess("trade_super_l1");
+
+        // L1 = 30% of 131400 = 39420
+        verify(commissionRepository).save(argThat((Commission c) ->
+                1 == c.getLevel() && 39420 == c.getAmount() && "settled".equals(c.getStatus())));
+    }
+
+    @Test
+    void testPaySuccess_superMemberCommission_l2() {
+        Order order = new Order();
+        order.setId(1L);
+        order.setUserId(100L);
+        order.setTotalFee(131400);
+        order.setProductType("super_member");
+        order.setStatus("pending");
+
+        User buyer = new User();
+        buyer.setId(100L);
+        buyer.setInvitedBy(200L);
+
+        User inviter = new User();
+        inviter.setId(200L);
+        inviter.setReferralCode("ABC123");
+        inviter.setInvitedBy(300L);
+
+        User inviter2 = new User();
+        inviter2.setId(300L);
+        inviter2.setReferralCode("DEF456");
+
+        when(configService.getInt("commission.super_member.direct_rate", 3000)).thenReturn(3000);
+        when(configService.getInt("commission.super_member.upstream_rate", 500)).thenReturn(500);
+        when(orderRepository.findByOutTradeNo("trade_super_l2")).thenReturn(Optional.of(order));
+        when(orderRepository.save(any(Order.class))).thenReturn(order);
+        when(userRepository.findById(100L)).thenReturn(Optional.of(buyer));
+        when(userRepository.findById(200L)).thenReturn(Optional.of(inviter));
+        when(userRepository.findById(300L)).thenReturn(Optional.of(inviter2));
+
+        orderService.paySuccess("trade_super_l2");
+
+        // L1 = 30% = 39420, L2 = 5% = 6570
+        verify(commissionRepository).save(argThat((Commission c) ->
+                1 == c.getLevel() && 39420 == c.getAmount()));
+        verify(commissionRepository).save(argThat((Commission c) ->
+                2 == c.getLevel() && 6570 == c.getAmount()));
+    }
+
+    /**
+     * 幂等: 同一订单已创建过 L1 佣金 → 不重复创建
+     */
+    @Test
+    void testPaySuccess_bEnd_idempotentCommission() {
+        Order order = new Order();
+        order.setId(1L);
+        order.setUserId(100L);
+        order.setTotalFee(131400);
+        order.setProductType("practitioner");
+        order.setStatus("pending");
+
+        User buyer = new User();
+        buyer.setId(100L);
+        buyer.setInvitedBy(200L);
+
+        User inviter = new User();
+        inviter.setId(200L);
+        inviter.setVipType("practitioner");
+        inviter.setDirectCount(0);
+
+        // Existing commission for this order already created
+        Commission existingComm = new Commission();
+        existingComm.setId(99L);
+        existingComm.setOrderId(1L);
+        existingComm.setLevel(1);
+        existingComm.setToUserId(200L);
+
+        when(configService.getInt("commission.practitioner.l1", 50000)).thenReturn(50000);
+        when(configService.getInt("commission.practitioner.l2", 10000)).thenReturn(10000);
+        when(commissionRepository.findByOrderId(1L)).thenReturn(Arrays.asList(existingComm));
+        when(orderRepository.findByOutTradeNo("trade_idem")).thenReturn(Optional.of(order));
+        when(orderRepository.save(any(Order.class))).thenReturn(order);
+        when(userRepository.findById(100L)).thenReturn(Optional.of(buyer));
+        when(userRepository.findById(200L)).thenReturn(Optional.of(inviter));
+
+        orderService.paySuccess("trade_idem");
+
+        // Should not create any new commissions (same orderId + level already exists)
+        verify(commissionRepository, never()).save(argThat((Commission c) ->
+                "settled".equals(c.getStatus())));
+    }
 }