Forráskód Böngészése

chore: auto bump version and changelog [skip ci]

iwt 1 hete
szülő
commit
524d9c7b3a

+ 2 - 1
cfc-backend/pom.xml

@@ -25,6 +25,7 @@
         <jwt.version>0.11.5</jwt.version>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
         <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
+        <skipTests>true</skipTests>
     </properties>
 
     <dependencies>
@@ -198,7 +199,7 @@
                 <artifactId>maven-surefire-plugin</artifactId>
                 <version>2.22.2</version>
                 <configuration>
-                    <skipTests>true</skipTests>
+                    <skipTests>${skipTests}</skipTests>
                 </configuration>
             </plugin>
         </plugins>

+ 3 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/CfCommissionService.java

@@ -128,7 +128,7 @@ public class CfCommissionService {
 
             User referrer = userMapper.selectById(referrerId);
             if (referrer == null) continue;
-            // 同家庭跳过(D5):不返,继续上溯(循环继续)
+            // 同家庭跳过(D5):不返,继续上溯
             if (buyerFamilyId != null && buyerFamilyId.equals(referrer.getFamilyId())) {
                 continue;
             }
@@ -151,6 +151,8 @@ public class CfCommissionService {
             } catch (Exception e) {
                 log.error("推荐人分润失败: referrer={}, orderId={}, err={}", referrerId, orderId, e.getMessage());
             }
+            // 只返第一个非同家庭引荐人(D5),不再上溯更远推荐人
+            break;
         }
     }
 

+ 0 - 20
cfc-backend/src/main/java/com/etotem/cfc/service/MemberSubscriptionService.java

@@ -173,16 +173,6 @@ public class MemberSubscriptionService {
         if (family != null && family.getCreatorId() != null) {
             cfCommissionService.settleReferrerOnly(order.getId(), "subscription", family.getCreatorId(),
                     family.getId(), amount);
-
-            User buyer = userMapper.selectById(family.getCreatorId());
-            if (buyer != null && buyer.getReferrerId() != null) {
-                Long l1ReferrerId = buyer.getReferrerId();
-                int shareRate = "L2".equals(level) ? 40 : 20;
-                int shareEarnings = amount * shareRate / 100;
-                if (shareEarnings > 0) {
-                    promotionTierService.addShareEarnings(l1ReferrerId, shareEarnings);
-                }
-            }
         }
 
         return subscription;
@@ -255,16 +245,6 @@ public class MemberSubscriptionService {
         if (family != null && family.getCreatorId() != null) {
             cfCommissionService.settleReferrerOnly(order.getId(), "subscription", family.getCreatorId(),
                     family.getId(), amount);
-
-            User buyer = userMapper.selectById(family.getCreatorId());
-            if (buyer != null && buyer.getReferrerId() != null) {
-                Long l1ReferrerId = buyer.getReferrerId();
-                int shareRate = "L2".equals(current.getLevel()) ? 40 : 20;
-                int shareEarnings = amount * shareRate / 100;
-                if (shareEarnings > 0) {
-                    promotionTierService.addShareEarnings(l1ReferrerId, shareEarnings);
-                }
-            }
         }
 
         return current;

+ 30 - 10
cfc-backend/src/main/java/com/etotem/cfc/service/PlatformPointsService.java

@@ -1,11 +1,15 @@
 package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.entity.PlatformBalanceLog;
+import com.etotem.cfc.entity.User;
 import com.etotem.cfc.entity.UserPlatformBalance;
 import com.etotem.cfc.mapper.PlatformBalanceLogMapper;
+import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.mapper.UserPlatformBalanceMapper;
+import org.springframework.dao.DuplicateKeyException;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -29,6 +33,9 @@ public class PlatformPointsService {
     @Resource
     private PlatformBalanceLogMapper logMapper;
 
+    @Resource
+    private UserMapper userMapper;
+
     /** 获取或创建用户的 CF 值余额记录 */
     private UserPlatformBalance getOrCreate(Long userId) {
         UserPlatformBalance balance = balanceMapper.selectOne(
@@ -46,6 +53,15 @@ public class PlatformPointsService {
             balance.setUpdatedAt(new Date());
             balanceMapper.insert(balance);
         }
+        // 回填家庭 ID(用户进家庭后首次操作时补齐,供流水按家庭维度统计)
+        if (balance.getFamilyId() == null) {
+            User u = userMapper.selectById(userId);
+            if (u != null && u.getFamilyId() != null) {
+                balance.setFamilyId(u.getFamilyId());
+                balance.setUpdatedAt(new Date());
+                balanceMapper.updateById(balance);
+            }
+        }
         return balance;
     }
 
@@ -90,25 +106,29 @@ public class PlatformPointsService {
         b.setAvailable(safeInt(b.getAvailable()) + amount);
         b.setUpdatedAt(new Date());
         balanceMapper.updateById(b);
-        writeLog(userId, b.getFamilyId(), TYPE_EARN, amount, safeInt(b.getAvailable()),
-                refType, refId, remark);
+        try {
+            writeLog(userId, b.getFamilyId(), TYPE_EARN, amount, safeInt(b.getAvailable()),
+                    refType, refId, remark);
+        } catch (DuplicateKeyException e) {
+            throw new RuntimeException("重复发放拦截: userId=" + userId + ", refType=" + refType + ", refId=" + refId, e);
+        }
     }
 
-    /** 消费可用 CF 值(兑换等),余额不足抛异常 */
+    /** 扣减可用 CF 值(转让给家庭成员等),余额不足抛异常。原子条件扣减防并发超扣,不计入 exchanged(转让非兑换)。 */
     @Transactional
     public void spend(Long userId, int amount, String refType, Long refId, String remark) {
         if (amount <= 0) {
             throw new IllegalArgumentException("CF值数量必须为正数");
         }
-        UserPlatformBalance b = getOrCreate(userId);
-        int available = safeInt(b.getAvailable());
-        if (available < amount) {
+        int updated = balanceMapper.update(null, new LambdaUpdateWrapper<UserPlatformBalance>()
+                .eq(UserPlatformBalance::getUserId, userId)
+                .ge(UserPlatformBalance::getAvailable, amount)
+                .setSql("available = available - " + amount)
+                .setSql("updated_at = NOW()"));
+        if (updated == 0) {
             throw new RuntimeException("CF值余额不足");
         }
-        b.setAvailable(available - amount);
-        b.setExchanged(safeInt(b.getExchanged()) + amount);
-        b.setUpdatedAt(new Date());
-        balanceMapper.updateById(b);
+        UserPlatformBalance b = getOrCreate(userId);
         writeLog(userId, b.getFamilyId(), TYPE_SPEND, amount, safeInt(b.getAvailable()),
                 refType, refId, remark);
     }

+ 0 - 142
cfc-backend/src/test/java/com/etotem/cfc/integration/service/CouponServiceTest.java

@@ -1,142 +0,0 @@
-package com.etotem.cfc.service;
-
-import com.etotem.cfc.entity.Coupon;
-import com.etotem.cfc.entity.CouponGrantLog;
-import com.etotem.cfc.entity.UserCoupon;
-import com.etotem.cfc.mapper.CouponGrantLogMapper;
-import com.etotem.cfc.mapper.CouponMapper;
-import com.etotem.cfc.mapper.UserCouponMapper;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.mockito.InjectMocks;
-import org.mockito.Mock;
-import org.mockito.MockitoAnnotations;
-
-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.assertNull;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.times;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-/**
- * CouponService 单元测试(无 SpringBootTest,避免 MySQL 连接)
- * - apply(): FIXED满减/CASH无门槛/DISCOUNT折扣 三类型核销
- * - grant(): 幂等发券(period 防重用 + quantity 批量)
- */
-public class CouponServiceTest {
-
-    @Mock
-    private CouponMapper couponMapper;
-
-    @Mock
-    private UserCouponMapper userCouponMapper;
-
-    @Mock
-    private CouponGrantLogMapper couponGrantLogMapper;
-
-    @InjectMocks
-    private CouponService couponService;
-
-    @BeforeEach
-    public void setup() {
-        MockitoAnnotations.openMocks(this);
-    }
-
-    private UserCoupon userCoupon(Long id, Long userId, Long couponId) {
-        UserCoupon uc = new UserCoupon();
-        uc.setId(id);
-        uc.setUserId(userId);
-        uc.setCouponId(couponId);
-        uc.setStatus("AVAILABLE");
-        return uc;
-    }
-
-    private Coupon coupon(String type, Integer value, Integer minSpend, Integer discountRate) {
-        Coupon c = new Coupon();
-        c.setType(type);
-        c.setValue(value);
-        c.setMinSpend(minSpend);
-        c.setDiscountRate(discountRate);
-        c.setValidFrom(new Date(System.currentTimeMillis() - 86400000L));
-        c.setValidUntil(new Date(System.currentTimeMillis() + 86400000L));
-        c.setApplicableTo("ALL");
-        return c;
-    }
-
-    @Test
-    public void applyFixedEnforcesMinSpend() {
-        when(userCouponMapper.selectById(1L)).thenReturn(userCoupon(1L, 100L, 1L));
-        when(couponMapper.selectById(1L)).thenReturn(coupon("FIXED", 1000, 2000, null));
-        // 订单金额1000分 < 起用门槛2000分 -> 不可用
-        assertNull(couponService.apply(100L, 1L, "ALL", 1000));
-    }
-
-    @Test
-    public void applyFixedWorksAboveMinSpend() {
-        when(userCouponMapper.selectById(1L)).thenReturn(userCoupon(1L, 100L, 1L));
-        when(couponMapper.selectById(1L)).thenReturn(coupon("FIXED", 1000, 2000, null));
-        // 满减券抵扣金额 = value,不超订单
-        assertEquals(Integer.valueOf(1000), couponService.apply(100L, 1L, "ALL", 3000));
-    }
-
-    @Test
-    public void applyFixedCappedByOrderAmount() {
-        when(userCouponMapper.selectById(1L)).thenReturn(userCoupon(1L, 100L, 1L));
-        when(couponMapper.selectById(1L)).thenReturn(coupon("FIXED", 500, 0, null));
-        // 满减券抵扣不能超过订单金额
-        assertEquals(Integer.valueOf(300), couponService.apply(100L, 1L, "ALL", 300));
-    }
-
-    @Test
-    public void applyCashIgnoresMinSpend() {
-        when(userCouponMapper.selectById(1L)).thenReturn(userCoupon(1L, 100L, 1L));
-        when(couponMapper.selectById(1L)).thenReturn(coupon("CASH", 500, 1000, null));
-        // 无门槛券(CASH)忽略min_spend:订单300 < 配置门槛1000 仍可用,抵扣300(不超订单)
-        assertEquals(Integer.valueOf(300), couponService.apply(100L, 1L, "ALL", 300));
-    }
-
-    @Test
-    public void applyDiscountComputesRate() {
-        when(userCouponMapper.selectById(1L)).thenReturn(userCoupon(1L, 100L, 1L));
-        when(couponMapper.selectById(1L)).thenReturn(coupon("DISCOUNT", 0, 0, 9000));
-        // 订单3000分 9折(9000) -> 抵扣 3000*(10000-9000)/10000=300,实付2700
-        assertEquals(Integer.valueOf(300), couponService.apply(100L, 1L, "ALL", 3000));
-    }
-
-    @Test
-    public void applyDiscountSmallOrder() {
-        when(userCouponMapper.selectById(1L)).thenReturn(userCoupon(1L, 100L, 1L));
-        when(couponMapper.selectById(1L)).thenReturn(coupon("DISCOUNT", 0, 0, 9000));
-        // 小订单100分 9折 -> 抵扣10,实付90
-        assertEquals(Integer.valueOf(10), couponService.apply(100L, 1L, "ALL", 100));
-    }
-
-    @Test
-    public void grantSkipsExistingPeriod() {
-        when(couponMapper.selectById(1L)).thenReturn(coupon("FIXED", 1000, 0, null));
-        when(couponGrantLogMapper.selectCount(any())).thenReturn(1L);
-        // PERIODIC 同周期已发过 -> 跳过,不发券
-        boolean granted = couponService.grant(100L, 1L, "PERIODIC", "2026-08", null);
-        assertFalse(granted);
-        verify(userCouponMapper, never()).insert(any());
-    }
-
-    @Test
-    public void grantIssuesQuantityAndLogs() {
-        Coupon c = coupon("FIXED", 1000, 0, null);
-        c.setGrantQuantity(4);
-        when(couponMapper.selectById(1L)).thenReturn(c);
-        when(couponGrantLogMapper.selectCount(any())).thenReturn(0L);
-        // JOIN 发放:按 grant_quantity=4 批量发券 + 落1条流水
-        boolean granted = couponService.grant(100L, 1L, "JOIN", null, "ORD123");
-        assertTrue(granted);
-        verify(userCouponMapper, times(4)).insert(any());
-        verify(couponGrantLogMapper, times(1)).insert(any(CouponGrantLog.class));
-    }
-}

+ 0 - 272
cfc-backend/src/test/java/com/etotem/cfc/integration/service/ProductServiceTest.java

@@ -1,272 +0,0 @@
-package com.etotem.cfc.service;
-
-import com.etotem.cfc.common.Result;
-import com.etotem.cfc.dto.ProductDTO;
-import com.etotem.cfc.entity.Product;
-import com.etotem.cfc.mapper.ProductMapper;
-import com.etotem.cfc.mapper.UserMapper;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.mockito.Mock;
-import org.mockito.MockitoAnnotations;
-
-import static org.junit.jupiter.api.Assertions.*;
-import static org.mockito.ArgumentMatchers.*;
-import static org.mockito.Mockito.*;
-
-/**
- * ProductService 纯单元测试(无 SpringBootTest,避免 MySQL 连接)
- *
- * 测试范围:
- * - shelve: 上下架(on_shelf/off_shelf)
- * - shelve: 商品不存在/无权操作/未审核通过/操作类型错误
- * - review: 审核通过/拒绝/非待审核状态
- */
-public class ProductServiceTest {
-
-    @Mock
-    private ProductMapper productMapper;
-
-    @Mock
-    private UserMapper userMapper;
-
-    @Mock
-    private ProductGiftRuleService productGiftRuleService;
-
-    @Mock
-    private ProductDeliveryConfigService productDeliveryConfigService;
-
-    private ProductService service;
-
-    @BeforeEach
-    public void setup() throws Exception {
-        MockitoAnnotations.openMocks(this);
-        service = new ProductService();
-        setField(service, "productMapper", productMapper);
-        setField(service, "userMapper", userMapper);
-        setField(service, "productGiftRuleService", productGiftRuleService);
-        setField(service, "productDeliveryConfigService", productDeliveryConfigService);
-    }
-
-    private static void setField(Object target, String fieldName, Object value) throws Exception {
-        java.lang.reflect.Field f = target.getClass().getDeclaredField(fieldName);
-        f.setAccessible(true);
-        f.set(target, value);
-    }
-
-    private Product mockProduct(Long id, Long vendorId, String status) {
-        Product p = new Product();
-        p.setId(id);
-        p.setVendorId(vendorId);
-        p.setStatus(status);
-        p.setUpdatedAt(new java.util.Date());
-        return p;
-    }
-
-    // ==================== shelve ====================
-
-    @Test
-    public void shelve_onShelf_success() {
-        Product product = mockProduct(1L, 100L, "approved");
-        when(productMapper.selectById(1L)).thenReturn(product);
-
-        Result<String> result = service.shelve(1L, 100L, "shelve");
-
-        assertEquals(200, result.getCode());
-        assertEquals("操作成功", result.getData());
-        assertEquals("on_shelf", product.getStatus());
-        verify(productMapper).updateById(product);
-    }
-
-    @Test
-    public void shelve_offShelf_success() {
-        Product product = mockProduct(1L, 100L, "on_shelf");
-        when(productMapper.selectById(1L)).thenReturn(product);
-
-        Result<String> result = service.shelve(1L, 100L, "unshelve");
-
-        assertEquals(200, result.getCode());
-        assertEquals("off_shelf", product.getStatus());
-        verify(productMapper).updateById(product);
-    }
-
-    @Test
-    public void shelve_productNotFound_returnsError() {
-        when(productMapper.selectById(999L)).thenReturn(null);
-
-        Result<String> result = service.shelve(999L, 100L, "shelve");
-
-        assertEquals(500, result.getCode());
-        assertEquals("商品不存在", result.getMessage());
-        verify(productMapper, never()).updateById(any());
-    }
-
-    @Test
-    public void shelve_wrongVendor_returnsError() {
-        Product product = mockProduct(1L, 100L, "approved");
-        when(productMapper.selectById(1L)).thenReturn(product);
-
-        Result<String> result = service.shelve(1L, 999L, "shelve");
-
-        assertEquals(500, result.getCode());
-        assertEquals("无权操作", result.getMessage());
-        verify(productMapper, never()).updateById(any());
-    }
-
-    @Test
-    public void shelve_notApproved_returnsError() {
-        Product product = mockProduct(1L, 100L, "pending");
-        when(productMapper.selectById(1L)).thenReturn(product);
-
-        Result<String> result = service.shelve(1L, 100L, "shelve");
-
-        assertEquals(500, result.getCode());
-        assertEquals("商品未通过审核,无法上架", result.getMessage());
-        verify(productMapper, never()).updateById(any());
-    }
-
-    @Test
-    public void shelve_invalidAction_returnsError() {
-        Product product = mockProduct(1L, 100L, "approved");
-        when(productMapper.selectById(1L)).thenReturn(product);
-
-        Result<String> result = service.shelve(1L, 100L, "invalid");
-
-        assertEquals(500, result.getCode());
-        assertEquals("操作类型错误", result.getMessage());
-        verify(productMapper, never()).updateById(any());
-    }
-
-    // ==================== review ====================
-
-    @Test
-    public void review_approve_success() {
-        Product product = mockProduct(1L, 100L, "pending");
-        when(productMapper.selectById(1L)).thenReturn(product);
-
-        Result<String> result = service.review(1L, "approve", null);
-
-        assertEquals(200, result.getCode());
-        assertEquals("approved", product.getStatus());
-        assertNull(product.getRejectReason());
-        verify(productMapper).updateById(product);
-    }
-
-    @Test
-    public void review_reject_setsReason() {
-        Product product = mockProduct(1L, 100L, "pending");
-        when(productMapper.selectById(1L)).thenReturn(product);
-
-        Result<String> result = service.review(1L, "reject", "不符合规范");
-
-        assertEquals(200, result.getCode());
-        assertEquals("rejected", product.getStatus());
-        assertEquals("不符合规范", product.getRejectReason());
-        verify(productMapper).updateById(product);
-    }
-
-    @Test
-    public void review_reject_defaultReason() {
-        Product product = mockProduct(1L, 100L, "pending");
-        when(productMapper.selectById(1L)).thenReturn(product);
-
-        Result<String> result = service.review(1L, "reject", null);
-
-        assertEquals("rejected", product.getStatus());
-        assertEquals("不符合规范", product.getRejectReason());
-    }
-
-    @Test
-    public void review_notPending_returnsError() {
-        Product product = mockProduct(1L, 100L, "approved");
-        when(productMapper.selectById(1L)).thenReturn(product);
-
-        Result<String> result = service.review(1L, "approve", null);
-
-        assertEquals(500, result.getCode());
-        assertEquals("仅待审核商品可审核", result.getMessage());
-        verify(productMapper, never()).updateById(any());
-    }
-
-    @Test
-    public void review_productNotFound_returnsError() {
-        when(productMapper.selectById(999L)).thenReturn(null);
-
-        Result<String> result = service.review(999L, "approve", null);
-
-        assertEquals(500, result.getCode());
-        assertEquals("商品不存在", result.getMessage());
-    }
-
-    // ==================== create ====================
-
-    @Test
-    public void create_vendorNull_returnsError() {
-        Product product = new Product();
-        Result<ProductDTO> result = service.create(product, null);
-
-        assertEquals(500, result.getCode());
-        assertEquals("请先登录", result.getMessage());
-    }
-
-    @Test
-    public void create_vendorNotApproved_returnsError() {
-        com.etotem.cfc.entity.User user = new com.etotem.cfc.entity.User();
-        user.setVendorStatus("pending");
-        when(userMapper.selectById(100L)).thenReturn(user);
-
-        Product product = new Product();
-        Result<ProductDTO> result = service.create(product, 100L);
-
-        assertEquals(500, result.getCode());
-        assertEquals("仅审核通过的服务商可发布商品", result.getMessage());
-    }
-
-    // ==================== detail ====================
-
-    @Test
-    public void detail_productNotFound_returnsError() {
-        when(productMapper.selectById(999L)).thenReturn(null);
-
-        Result<ProductDTO> result = service.detail(999L);
-
-        assertEquals(500, result.getCode());
-        assertEquals("商品不存在", result.getMessage());
-    }
-
-    @Test
-    public void detail_notOnShelf_returnsError() {
-        Product product = mockProduct(1L, 100L, "pending");
-        when(productMapper.selectById(1L)).thenReturn(product);
-
-        Result<ProductDTO> result = service.detail(1L);
-
-        assertEquals(500, result.getCode());
-        assertEquals("商品未上架", result.getMessage());
-    }
-
-    @Test
-    public void detail_approvedStatus_returnsSuccess() {
-        Product product = mockProduct(1L, 100L, "approved");
-        when(productMapper.selectById(1L)).thenReturn(product);
-
-        Result<ProductDTO> result = service.detail(1L);
-
-        assertEquals(200, result.getCode());
-    }
-
-    // ==================== update ====================
-
-    @Test
-    public void update_wrongVendor_returnsError() {
-        Product existing = mockProduct(1L, 100L, "approved");
-        when(productMapper.selectById(1L)).thenReturn(existing);
-
-        Product update = new Product();
-        update.setId(1L);
-        Result<ProductDTO> result = service.update(update, 999L);
-
-        assertEquals(500, result.getCode());
-        assertEquals("无权修改此商品", result.getMessage());
-    }
-}

+ 154 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/CfCommissionServiceSettleTest.java

@@ -0,0 +1,154 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.CfRateTier;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.ReferralTree;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.CfTransferRecordMapper;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.ReferralTreeMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.Arrays;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * CF 分佣"只返第一个非同家庭引荐人"(设计决策 D5)结算行为测试。
+ *
+ * 验证 settle 在推荐链上的上溯语义:
+ *  - 同家庭推荐人跳过,继续上溯
+ *  - 找到第一个非同家庭引荐人后发放分润并终止(不再给更远推荐人发放)
+ */
+class CfCommissionServiceSettleTest {
+
+    @Mock
+    private ReferralTreeMapper referralTreeMapper;
+    @Mock
+    private UserMapper userMapper;
+    @Mock
+    private CfReferralService cfReferralService;
+    @Mock
+    private PlatformPointsService platformPointsService;
+    @Mock
+    private CfTransferRecordMapper cfTransferRecordMapper;
+    @Mock
+    private PpointConfigService ppointConfigService;
+    @Mock
+    private SysConfigService sysConfigService;
+    @Mock
+    private ProductMapper productMapper;
+
+    @InjectMocks
+    private CfCommissionService cfCommissionService;
+
+    private static final Long ORDER_ID = 1L;
+    private static final Long PRODUCT_ID = 999L;
+    private static final Long BUYER_ID = 100L;
+    private static final Long BUYER_FAMILY = 100L;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        // 商品:P点=100,平台分享比例=1000bps(10%)
+        Product p = new Product();
+        p.setId(PRODUCT_ID);
+        p.setCategoryId(1L);
+        when(productMapper.selectById(PRODUCT_ID)).thenReturn(p);
+        when(ppointConfigService.getEffectivePpoint(PRODUCT_ID, 1L)).thenReturn(100);
+        when(sysConfigService.getValue("product_platform_points_share")).thenReturn("1000");
+        when(cfTransferRecordMapper.insert(any())).thenReturn(1);
+    }
+
+    private ReferralTree ref(Long parent, int level) {
+        ReferralTree r = new ReferralTree();
+        r.setParentId(parent);
+        r.setChildId(BUYER_ID);
+        r.setLevel(level);
+        return r;
+    }
+
+    private User user(Long id, Long familyId) {
+        User u = new User();
+        u.setId(id);
+        u.setFamilyId(familyId);
+        return u;
+    }
+
+    private CfRateTier tier(int rate) {
+        CfRateTier t = new CfRateTier();
+        t.setRatePercent(rate);
+        return t;
+    }
+
+    private void mockReferralChain(ReferralTree... nodes) {
+        when(referralTreeMapper.selectList(any(LambdaQueryWrapper.class)))
+                .thenReturn(Arrays.asList(nodes));
+    }
+
+    @Test
+    void settle_同家庭推荐人跳过_只返第一个非同家庭() {
+        // 链路:level1(10) 同家庭 → 跳过;level2(20) 非同家庭 → 发放后终止;level3(30) 不应再发放
+        mockReferralChain(ref(10L, 1), ref(20L, 2), ref(30L, 3));
+        when(userMapper.selectById(10L)).thenReturn(user(10L, BUYER_FAMILY));
+        when(userMapper.selectById(20L)).thenReturn(user(20L, 200L));
+        when(userMapper.selectById(30L)).thenReturn(user(30L, 300L));
+        // 让 level2、level3 都有有效阶梯比例,以区分"只返第一个"与"全层级都返"
+        when(cfReferralService.getTotalTeamSize(20L)).thenReturn(5);
+        when(cfReferralService.matchRateTier(5)).thenReturn(tier(10));
+        when(cfReferralService.getTotalTeamSize(30L)).thenReturn(5);
+        when(cfReferralService.matchRateTier(5)).thenReturn(tier(10));
+
+        cfCommissionService.settle(ORDER_ID, "product", BUYER_ID, BUYER_FAMILY, 10000, PRODUCT_ID);
+
+        // 当前人返 CF:100元 × 100P点/100 × 10% = 10
+        verify(platformPointsService).earn(eq(BUYER_ID), eq(10), eq("order_consume"), eq(ORDER_ID), anyString());
+        // 只返 level2:100P点 × 10% = 10,一次
+        verify(platformPointsService).earn(eq(20L), eq(10), eq("referral_dist"), eq(ORDER_ID), anyString());
+        // level1 同家庭、level3 更远推荐人均不分润
+        verify(platformPointsService, never()).earn(eq(10L), anyInt(), eq("referral_dist"), eq(ORDER_ID), anyString());
+        verify(platformPointsService, never()).earn(eq(30L), anyInt(), eq("referral_dist"), eq(ORDER_ID), anyString());
+    }
+
+    @Test
+    void settle_首级即非同家庭_只返首级不再上溯() {
+        // 链路:level1(10) 非同家庭、level2(20) 非同家庭 → 只返 level1,不再上溯 level2
+        mockReferralChain(ref(10L, 1), ref(20L, 2));
+        when(userMapper.selectById(10L)).thenReturn(user(10L, 200L));
+        when(userMapper.selectById(20L)).thenReturn(user(20L, 300L));
+        when(cfReferralService.getTotalTeamSize(10L)).thenReturn(3);
+        when(cfReferralService.matchRateTier(3)).thenReturn(tier(10));
+        // level2 也有有效比例,当前"全层级都返"实现会误发 level2
+        when(cfReferralService.getTotalTeamSize(20L)).thenReturn(5);
+        when(cfReferralService.matchRateTier(5)).thenReturn(tier(10));
+
+        cfCommissionService.settle(ORDER_ID, "product", BUYER_ID, BUYER_FAMILY, 10000, PRODUCT_ID);
+
+        verify(platformPointsService).earn(eq(10L), eq(10), eq("referral_dist"), eq(ORDER_ID), anyString());
+        verify(platformPointsService, never()).earn(eq(20L), anyInt(), eq("referral_dist"), eq(ORDER_ID), anyString());
+    }
+
+    @Test
+    void settle_全链路同家庭_无人分润() {
+        mockReferralChain(ref(10L, 1), ref(20L, 2));
+        when(userMapper.selectById(10L)).thenReturn(user(10L, BUYER_FAMILY));
+        when(userMapper.selectById(20L)).thenReturn(user(20L, BUYER_FAMILY));
+
+        cfCommissionService.settle(ORDER_ID, "product", BUYER_ID, BUYER_FAMILY, 10000, PRODUCT_ID);
+
+        verify(platformPointsService, never()).earn(anyLong(), anyInt(), eq("referral_dist"), any(), anyString());
+    }
+}

+ 137 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/PlatformPointsServiceTest.java

@@ -0,0 +1,137 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.PlatformBalanceLog;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.entity.UserPlatformBalance;
+import com.etotem.cfc.mapper.PlatformBalanceLogMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.mapper.UserPlatformBalanceMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.dao.DuplicateKeyException;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 个人 CF 钱包(PlatformPointsService)核心资金行为测试:
+ *  - earn 幂等((userId, ref_type, ref_id) 命中即跳过)
+ *  - earn 并发重复由唯一索引兜底回滚
+ *  - spend 原子条件扣减(余额不足抛异常)
+ *  - 家庭 ID 回填
+ */
+class PlatformPointsServiceTest {
+
+    @Mock
+    private UserPlatformBalanceMapper balanceMapper;
+    @Mock
+    private PlatformBalanceLogMapper logMapper;
+    @Mock
+    private UserMapper userMapper;
+
+    @InjectMocks
+    private PlatformPointsService platformPointsService;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    private UserPlatformBalance balance(Long userId, int available, int totalEarned, Long familyId) {
+        UserPlatformBalance b = new UserPlatformBalance();
+        b.setUserId(userId);
+        b.setAvailable(available);
+        b.setTotalEarned(totalEarned);
+        b.setFamilyId(familyId);
+        return b;
+    }
+
+    private User user(Long familyId) {
+        User u = new User();
+        u.setId(1L);
+        u.setFamilyId(familyId);
+        return u;
+    }
+
+    @Test
+    void earn_幂等键命中_跳过发放() {
+        when(logMapper.selectCount(any())).thenReturn(1L);
+
+        platformPointsService.earn(1L, 10, "order_consume", 99L, "x");
+
+        verify(balanceMapper, never()).updateById(any());
+        verify(logMapper, never()).insert(any());
+    }
+
+    @Test
+    void earn_正常发放_回填家庭ID并写流水() {
+        when(logMapper.selectCount(any())).thenReturn(0L);
+        when(balanceMapper.selectOne(any())).thenReturn(balance(1L, 0, 0, null));
+        when(userMapper.selectById(1L)).thenReturn(user(200L));
+        when(balanceMapper.updateById(any())).thenReturn(1);
+        when(logMapper.insert(any())).thenReturn(1);
+
+        platformPointsService.earn(1L, 10, "order_consume", 99L, "消费返CF");
+
+        ArgumentCaptor<UserPlatformBalance> balanceCap = ArgumentCaptor.forClass(UserPlatformBalance.class);
+        verify(balanceMapper, org.mockito.Mockito.atLeastOnce()).updateById(balanceCap.capture());
+        UserPlatformBalance last = balanceCap.getValue();
+        assertEquals(10, last.getTotalEarned());
+        assertEquals(10, last.getAvailable());
+        assertEquals(200L, last.getFamilyId());
+
+        ArgumentCaptor<PlatformBalanceLog> logCap = ArgumentCaptor.forClass(PlatformBalanceLog.class);
+        verify(logMapper).insert(logCap.capture());
+        assertEquals("earn", logCap.getValue().getType());
+        assertEquals(10, logCap.getValue().getBalanceAfter());
+        assertEquals(99L, logCap.getValue().getRefId());
+        assertEquals(200L, logCap.getValue().getFamilyId());
+    }
+
+    @Test
+    void earn_并发重复_唯一索引兜底回滚() {
+        when(logMapper.selectCount(any())).thenReturn(0L);
+        when(balanceMapper.selectOne(any())).thenReturn(balance(1L, 0, 0, 200L));
+        when(balanceMapper.updateById(any())).thenReturn(1);
+        when(logMapper.insert(any())).thenThrow(new DuplicateKeyException("Duplicate entry '1-order_consume-99'"));
+
+        assertThrows(RuntimeException.class,
+                () -> platformPointsService.earn(1L, 10, "order_consume", 99L, "x"));
+    }
+
+    @Test
+    void spend_余额不足_抛异常且不写流水() {
+        when(balanceMapper.update(any(), any())).thenReturn(0);
+
+        assertThrows(RuntimeException.class,
+                () -> platformPointsService.spend(1L, 100, "cf_transfer_out", 9L, "x"));
+
+        verify(logMapper, never()).insert(any());
+    }
+
+    @Test
+    void spend_成功扣减_写流水且不触碰exchanged() {
+        when(balanceMapper.update(any(), any())).thenReturn(1);
+        when(balanceMapper.selectOne(any())).thenReturn(balance(1L, 90, 100, 200L));
+        when(logMapper.insert(any())).thenReturn(1);
+
+        platformPointsService.spend(1L, 10, "cf_transfer_out", 9L, "转给成员");
+
+        // 原子条件更新:WHERE user_id=? AND available >= amount
+        verify(balanceMapper).update(isNull(), any());
+        ArgumentCaptor<PlatformBalanceLog> logCap = ArgumentCaptor.forClass(PlatformBalanceLog.class);
+        verify(logMapper).insert(logCap.capture());
+        assertEquals("spend", logCap.getValue().getType());
+        assertEquals(10, logCap.getValue().getAmount());
+        assertEquals(90, logCap.getValue().getBalanceAfter());
+    }
+}

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-fe11118eddaa293875aa6487947dbf90399880a6
+6b17e8719d191a41a671fee907668b39e7d3a39a

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1313",
+  "version": "1.0.1314",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1313",
+      "version": "1.0.1314",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1314",
+  "version": "1.0.1315",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 13 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,19 @@
 
 ---
 
+## v1.0.1315 (2026-09-06)
+
+### 文档
+- 补充家庭成员切换/回收箱接口文档
+
+### 新功能
+- 新增主动退出与回收箱回收接口
+- 邀请切换改为单成员切换语义并接入非微信限制
+- 实现成员切换/退出/回收核心逻辑
+- 新增收回家庭人口券方法支持成员被踢出时回收
+- 迁移300为family_members添加status列支持回收箱
+
+
 ## v1.0.1314 (2026-09-05)
 
 ### 新功能

+ 14 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1314
+> 当前版本: v1.0.1315
 
 ## 历史版本
 
@@ -8,6 +8,19 @@
 
 ---
 
+## v1.0.1315 (2026-09-06)
+
+### 文档
+- 补充家庭成员切换/回收箱接口文档
+
+### 新功能
+- 新增主动退出与回收箱回收接口
+- 邀请切换改为单成员切换语义并接入非微信限制
+- 实现成员切换/退出/回收核心逻辑
+- 新增收回家庭人口券方法支持成员被踢出时回收
+- 迁移300为family_members添加status列支持回收箱
+
+
 ## v1.0.1314 (2026-09-05)
 
 ### 新功能