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

feat(cf): F-5 消费侧6处改投统一账本+券挂家庭

iwt 2 недель назад
Родитель
Сommit
03b072f5c4

+ 20 - 4
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyPlatformPointsService.java

@@ -153,7 +153,11 @@ public class FamilyPlatformPointsService {
                 refType, refId, remark);
     }
 
-    /** 提现申请:可用 → 冻结 */
+    /**
+     * @deprecated F-5 起提现冻结改走统一账本 {@link CfSpendDispatchService#freezeForMember},
+     * 家庭池不再承担消费冻结;F-8 清理时下线。
+     */
+    @Deprecated
     @Transactional
     public void freeze(Long familyId, int amount, Long refId) {
         if (amount <= 0) {
@@ -172,7 +176,11 @@ public class FamilyPlatformPointsService {
                 "withdrawal", refId, "提现冻结");
     }
 
-    /** 提现审核拒绝:冻结 → 可用 */
+    /**
+     * @deprecated F-5 起提现驳回解冻改走统一账本 {@link CfSpendDispatchService#unfreezeForMember},
+     * 家庭池不再承担消费解冻;F-8 清理时下线。
+     */
+    @Deprecated
     @Transactional
     public void unfreeze(Long familyId, int amount, Long refId) {
         if (amount <= 0) {
@@ -191,7 +199,11 @@ public class FamilyPlatformPointsService {
                 "withdrawal", refId, "提现审核拒绝解冻");
     }
 
-    /** 提现审核通过:冻结 → 已提现 */
+    /**
+     * @deprecated F-5 起提现扣款改走统一账本 {@link CfSpendDispatchService#withdrawForMember},
+     * 家庭池不再承担消费提现;F-8 清理时下线。
+     */
+    @Deprecated
     @Transactional
     public void confirmWithdraw(Long familyId, int amount, Long refId) {
         if (amount <= 0) {
@@ -210,7 +222,11 @@ public class FamilyPlatformPointsService {
                 "withdrawal", refId, "提现成功");
     }
 
-    /** 管理端手动调整(正负均可) */
+    /**
+     * @deprecated F-5 起管理端调整改走统一账本 {@link CfSpendDispatchService#adjustForFamily}
+     * (落到家庭创建者成员账本),家庭池不再承担调整;F-8 清理时下线。
+     */
+    @Deprecated
     @Transactional
     public void adjust(Long familyId, int amount, String remark) {
         FamilyPlatformBalance b = getOrCreate(familyId);

+ 261 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/CfSpendDispatchServiceTest.java

@@ -0,0 +1,261 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.config.ConfigService;
+import com.etotem.cfc.constant.CfConfigKeys;
+import com.etotem.cfc.dto.CfBalanceResult;
+import com.etotem.cfc.entity.CfBalance;
+import com.etotem.cfc.entity.CfBalanceLog;
+import com.etotem.cfc.entity.CfEarnDailyLimit;
+import com.etotem.cfc.entity.Family;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.mapper.CfBalanceLogMapper;
+import com.etotem.cfc.mapper.CfBalanceMapper;
+import com.etotem.cfc.mapper.CfEarnDailyLimitMapper;
+import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.service.impl.CfBalanceServiceImpl;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+/**
+ * 统一消费分派服务(CfSpendDispatchService)单元测试(F-5):
+ *  - 用户 → 家庭成员解析(登录账号自己的成员记录);
+ *  - 扣减 / 冻结 / 解冻 / 提现全部落到统一账本 cf_balance(不再写家庭池 / system_points);
+ *  - 退还走「原路返还」:earn + 独立 ref_type,重复退被幂等键拦截;
+ *  - 管理员调整落到家庭创建者的成员账本(ref_type=admin_adjust,留操作人)。
+ */
+class CfSpendDispatchServiceTest {
+
+    private static final long MEMBER_ID = 1L;
+    private static final long USER_ID = 100L;
+    private static final long FAMILY_ID = 7L;
+
+    @Mock
+    private CfBalanceMapper cfBalanceMapper;
+    @Mock
+    private CfBalanceLogMapper cfBalanceLogMapper;
+    @Mock
+    private CfEarnDailyLimitMapper cfEarnDailyLimitMapper;
+    @Mock
+    private CfEarnRuleService cfEarnRuleService;
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+    @Mock
+    private FamilyMapper familyMapper;
+    @Mock
+    private MembershipService membershipService;
+    @Mock
+    private ConfigService configService;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+    private final List<CfBalanceLog> insertedLogs = new ArrayList<>();
+
+    private final CfBalanceServiceImpl cfBalanceService = new CfBalanceServiceImpl();
+    private final CfSpendDispatchService dispatchService = new CfSpendDispatchService();
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        // MyBatis-Plus LambdaQueryWrapper 需要实体元数据(参照 CfEarnDispatchServiceTest)
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), CfBalance.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), CfBalanceLog.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), CfEarnDailyLimit.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), FamilyMember.class);
+
+        CfBalance balance = balance(MEMBER_ID, 1000, 0, 1000, 0, 0);
+        when(cfBalanceMapper.selectForUpdate(MEMBER_ID)).thenReturn(balance);
+        when(cfBalanceMapper.selectOne(any())).thenReturn(balance);
+        // 默认:findLog 未命中(幂等键不存在),需要幂等行为的用例在方法内单独覆盖
+        when(cfBalanceLogMapper.selectOne(any())).thenReturn(null);
+        when(cfBalanceLogMapper.selectCount(any())).thenReturn(0L);
+        when(cfBalanceLogMapper.insert(any())).thenAnswer(inv -> {
+            insertedLogs.add(inv.getArgument(0));
+            return 1;
+        });
+        when(familyMemberMapper.selectById(MEMBER_ID)).thenReturn(member(MEMBER_ID, FAMILY_ID));
+        when(membershipService.getCurrentLevel(FAMILY_ID)).thenReturn(null);
+
+        ReflectionTestUtils.setField(cfBalanceService, "cfBalanceMapper", cfBalanceMapper);
+        ReflectionTestUtils.setField(cfBalanceService, "cfBalanceLogMapper", cfBalanceLogMapper);
+        ReflectionTestUtils.setField(cfBalanceService, "cfEarnDailyLimitMapper", cfEarnDailyLimitMapper);
+        ReflectionTestUtils.setField(cfBalanceService, "cfEarnRuleService", cfEarnRuleService);
+        ReflectionTestUtils.setField(cfBalanceService, "familyMemberMapper", familyMemberMapper);
+        ReflectionTestUtils.setField(cfBalanceService, "membershipService", membershipService);
+        ReflectionTestUtils.setField(cfBalanceService, "configService", configService);
+        ReflectionTestUtils.setField(cfBalanceService, "objectMapper", objectMapper);
+
+        ReflectionTestUtils.setField(dispatchService, "cfBalanceService", cfBalanceService);
+        ReflectionTestUtils.setField(dispatchService, "familyMemberMapper", familyMemberMapper);
+        ReflectionTestUtils.setField(dispatchService, "familyMapper", familyMapper);
+
+        // 默认:用户 100 → 成员 1(登录账号自己的家庭成员记录)
+        FamilyMember resolved = member(MEMBER_ID, FAMILY_ID);
+        resolved.setUserId(USER_ID);
+        when(familyMemberMapper.selectOne(any())).thenReturn(resolved);
+    }
+
+    // ---------- 成员解析 ----------
+
+    @Test
+    void resolveMemberId_用户解析到自己的家庭成员() {
+        assertEquals(MEMBER_ID, dispatchService.resolveMemberId(USER_ID));
+    }
+
+    @Test
+    void resolveMemberId_未找到成员返回null() {
+        when(familyMemberMapper.selectOne(any())).thenReturn(null);
+        assertEquals(null, dispatchService.resolveMemberId(999L));
+    }
+
+    // ---------- 扣减 ----------
+
+    @Test
+    void spendForUser_扣减落到统一账本并写流水() {
+        CfBalanceResult result = dispatchService.spendForUser(USER_ID, 200, "product_order_points", 5L, "商品购买");
+
+        assertEquals(800, result.getAvailable());
+        assertEquals(-200, result.getAmount());
+
+        CfBalanceLog log = insertedLogs.get(0);
+        assertEquals("spend", log.getType());
+        assertEquals("product_order_points", log.getRefType());
+        assertEquals(5L, log.getRefId());
+        assertEquals(MEMBER_ID, log.getMemberId());
+        assertEquals(800, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
+    }
+
+    @Test
+    void spendForMember_余额不足抛业务异常() {
+        IllegalStateException e = assertThrows(IllegalStateException.class,
+                () -> dispatchService.spendForMember(MEMBER_ID, 5000, "wish_exchange", 1L, "心愿兑换"));
+        assertTrue(e.getMessage().contains("余额不足"));
+        assertEquals(1000, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
+    }
+
+    // ---------- 冻结 / 解冻 / 提现 ----------
+
+    @Test
+    void freeze与unfreeze_冻结额在账本内部往返() {
+        dispatchService.freezeForMember(MEMBER_ID, 200, "withdrawal", 9L, "提现冻结");
+        assertEquals(800, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
+        assertEquals(200, cfBalanceService.getBalance(MEMBER_ID).getFrozen());
+
+        dispatchService.unfreezeForMember(MEMBER_ID, 200, "withdrawal", 9L, "驳回解冻");
+        assertEquals(1000, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
+        assertEquals(0, cfBalanceService.getBalance(MEMBER_ID).getFrozen());
+    }
+
+    @Test
+    void withdrawForMember_开关关闭时拒绝提现() {
+        when(configService.getBooleanConfig(CfConfigKeys.WITHDRAW_ENABLED, false)).thenReturn(false);
+        dispatchService.freezeForMember(MEMBER_ID, 200, "withdrawal", 9L, "提现冻结");
+
+        IllegalStateException e = assertThrows(IllegalStateException.class,
+                () -> dispatchService.withdrawForMember(MEMBER_ID, 200, "withdrawal", 9L, "提现成功"));
+        assertTrue(e.getMessage().contains("提现功能未开启"));
+    }
+
+    @Test
+    void withdrawForMember_开关开启时冻结转已提现() {
+        when(configService.getBooleanConfig(CfConfigKeys.WITHDRAW_ENABLED, false)).thenReturn(true);
+        dispatchService.freezeForMember(MEMBER_ID, 200, "withdrawal", 9L, "提现冻结");
+
+        dispatchService.withdrawForMember(MEMBER_ID, 200, "withdrawal", 9L, "提现成功");
+
+        assertEquals(800, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
+        assertEquals(0, cfBalanceService.getBalance(MEMBER_ID).getFrozen());
+        assertEquals(200, cfBalanceService.getBalance(MEMBER_ID).getTotalWithdrawn());
+    }
+
+    // ---------- 退还 ----------
+
+    @Test
+    void refundForUser_原路返还且重复退被幂等拦截() {
+        dispatchService.spendForUser(USER_ID, 200, "product_order_points", 5L, "商品购买");
+        assertEquals(800, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
+
+        CfBalanceResult first = dispatchService.refundForUser(USER_ID, 200, "product_order_points_refund", 5L, "退款");
+        assertEquals(1000, first.getAvailable());
+        assertEquals(Boolean.FALSE, first.getDuplicated());
+
+        // 第二次同参退款:findLog 命中首笔 refund 流水(幂等键拦截,不重复入账)
+        when(cfBalanceLogMapper.selectOne(any())).thenReturn(insertedLogs.get(insertedLogs.size() - 1));
+        CfBalanceResult second = dispatchService.refundForUser(USER_ID, 200, "product_order_points_refund", 5L, "退款");
+        assertEquals(Boolean.TRUE, second.getDuplicated());
+        assertEquals(1000, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
+    }
+
+    // ---------- 管理员调整 ----------
+
+    @Test
+    void adjustForFamily_调整落到家庭创建者成员账本() {
+        Family family = new Family();
+        family.setId(FAMILY_ID);
+        family.setCreatorId(USER_ID);
+        when(familyMapper.selectById(FAMILY_ID)).thenReturn(family);
+
+        dispatchService.adjustForFamily(FAMILY_ID, 300, "管理员补发", 66L);
+
+        assertEquals(1300, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
+        CfBalanceLog adjustLog = insertedLogs.get(0);
+        assertEquals("adjust", adjustLog.getType());
+        assertEquals("admin_adjust", adjustLog.getRefType());
+        assertEquals(66L, adjustLog.getOperatorUserId());
+    }
+
+    @Test
+    void adjustForFamily_找不到家庭创建者成员时抛错() {
+        when(familyMapper.selectById(FAMILY_ID)).thenReturn(null);
+        IllegalStateException e = assertThrows(IllegalStateException.class,
+                () -> dispatchService.adjustForFamily(FAMILY_ID, 300, "管理员补发", 66L));
+        assertTrue(e.getMessage().contains("家庭创建者"));
+    }
+
+    // ---------- 余额查询 ----------
+
+    @Test
+    void getAvailableForUser_未找到成员返回0() {
+        when(familyMemberMapper.selectOne(any())).thenReturn(null);
+        assertEquals(0, dispatchService.getAvailableForUser(999L));
+    }
+
+    // ---------- 构造 ----------
+
+    private CfBalance balance(long memberId, int available, int frozen, int totalEarned, int withdrawn, int exchanged) {
+        CfBalance b = new CfBalance();
+        b.setId(memberId);
+        b.setMemberId(memberId);
+        b.setUserId(USER_ID);
+        b.setFamilyId(FAMILY_ID);
+        b.setAvailable(available);
+        b.setFrozen(frozen);
+        b.setTotalEarned(totalEarned);
+        b.setWithdrawn(withdrawn);
+        b.setExchanged(exchanged);
+        return b;
+    }
+
+    private FamilyMember member(long memberId, long familyId) {
+        FamilyMember m = new FamilyMember();
+        m.setId(memberId);
+        m.setFamilyId(familyId);
+        m.setUserId(USER_ID);
+        return m;
+    }
+}

+ 166 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/FamilyPlatformPointsServiceCouponTest.java

@@ -0,0 +1,166 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.config.ConfigService;
+import com.etotem.cfc.constant.CfConfigKeys;
+import com.etotem.cfc.dto.CfBalanceResult;
+import com.etotem.cfc.entity.Coupon;
+import com.etotem.cfc.entity.FamilyCoupon;
+import com.etotem.cfc.entity.FamilyPlatformExchangeRecord;
+import com.etotem.cfc.mapper.CouponMapper;
+import com.etotem.cfc.mapper.FamilyCouponMapper;
+import com.etotem.cfc.mapper.FamilyPlatformExchangeRecordMapper;
+import com.etotem.cfc.mapper.FamilyPlatformBalanceLogMapper;
+import com.etotem.cfc.mapper.FamilyPlatformBalanceMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+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 值兑换优惠券(F-5 / N3:券挂家庭,个人扣统一账本 cf_balance)单元测试:
+ *  - 先发券(券实例 ID 作为扣减幂等 refId),再扣兑换人个人 cf_balance;
+ *  - 券模板家庭绑定校验(NULL = 全平台模板);
+ *  - 退款原路返还到兑换人本人(ref_type=coupon_refund),平台赠送券不退。
+ */
+class FamilyPlatformPointsServiceCouponTest {
+
+    private static final long FAMILY_ID = 7L;
+    private static final long OTHER_FAMILY_ID = 8L;
+    private static final long USER_ID = 100L;
+    private static final long COUPON_ID = 31L;
+    private static final long FAMILY_COUPON_ID = 77L;
+
+    @Mock
+    private FamilyPlatformBalanceMapper balanceMapper;
+    @Mock
+    private FamilyPlatformBalanceLogMapper logMapper;
+    @Mock
+    private CfSpendDispatchService cfSpendDispatchService;
+    @Mock
+    private ConfigService configService;
+    @Mock
+    private CouponService couponService;
+    @Mock
+    private CouponMapper couponMapper;
+    @Mock
+    private FamilyCouponMapper familyCouponMapper;
+    @Mock
+    private FamilyPlatformExchangeRecordMapper exchangeRecordMapper;
+
+    private final FamilyPlatformPointsService service = new FamilyPlatformPointsService();
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        ReflectionTestUtils.setField(service, "balanceMapper", balanceMapper);
+        ReflectionTestUtils.setField(service, "logMapper", logMapper);
+        ReflectionTestUtils.setField(service, "cfSpendDispatchService", cfSpendDispatchService);
+        ReflectionTestUtils.setField(service, "configService", configService);
+        ReflectionTestUtils.setField(service, "couponService", couponService);
+        ReflectionTestUtils.setField(service, "couponMapper", couponMapper);
+        ReflectionTestUtils.setField(service, "familyCouponMapper", familyCouponMapper);
+        ReflectionTestUtils.setField(service, "exchangeRecordMapper", exchangeRecordMapper);
+
+        when(configService.getIntConfig(CfConfigKeys.SPEND_COUPON_EXCHANGE_DAILY_LIMIT, 5)).thenReturn(5);
+        when(configService.getIntConfig(CfConfigKeys.SPEND_COUPON_COUPON_DAILY_LIMIT, 1)).thenReturn(1);
+        when(exchangeRecordMapper.selectCount(any())).thenReturn(0L);
+    }
+
+    @Test
+    void exchangeCouponByCf_先发券再扣兑换人个人统一账本() {
+        Coupon coupon = coupon(500, null);
+        when(couponMapper.selectById(COUPON_ID)).thenReturn(coupon);
+        when(couponService.grantFamilyOne(FAMILY_ID, COUPON_ID, "CF_EXCHANGE", "family:" + FAMILY_ID))
+                .thenReturn(FAMILY_COUPON_ID);
+
+        Long familyCouponId = service.exchangeCouponByCf(FAMILY_ID, COUPON_ID, USER_ID);
+
+        assertEquals(FAMILY_COUPON_ID, familyCouponId);
+        // 幂等 refId = 券实例 ID,扣兑换人本人
+        verify(cfSpendDispatchService).spendForUser(eq(USER_ID), eq(500), eq("coupon_exchange"),
+                eq(FAMILY_COUPON_ID), any());
+        verify(exchangeRecordMapper).insert(any(FamilyPlatformExchangeRecord.class));
+    }
+
+    @Test
+    void exchangeCouponByCf_模板绑定其他家庭时被拦截() {
+        Coupon coupon = coupon(500, 2L);
+        when(couponMapper.selectById(COUPON_ID)).thenReturn(coupon);
+
+        RuntimeException e = assertThrows(RuntimeException.class,
+                () -> service.exchangeCouponByCf(FAMILY_ID, COUPON_ID, USER_ID));
+        assertTrue(e.getMessage().contains("仅限指定家庭"));
+        verify(couponService, never()).grantFamilyOne(any(), any(), any(), any());
+        verify(cfSpendDispatchService, never()).spendForUser(any(), anyInt(), any(), any(), any());
+    }
+
+    @Test
+    void exchangeCouponByCf_余额不足时异常上抛不发券完成() {
+        Coupon coupon = coupon(500, null);
+        when(couponMapper.selectById(COUPON_ID)).thenReturn(coupon);
+        when(couponService.grantFamilyOne(FAMILY_ID, COUPON_ID, "CF_EXCHANGE", "family:" + FAMILY_ID))
+                .thenReturn(FAMILY_COUPON_ID);
+        when(cfSpendDispatchService.spendForUser(any(), anyInt(), any(), any(), any()))
+                .thenThrow(new IllegalStateException("CF值余额不足:可用 0,需要 500"));
+
+        IllegalStateException e = assertThrows(IllegalStateException.class,
+                () -> service.exchangeCouponByCf(FAMILY_ID, COUPON_ID, USER_ID));
+        assertTrue(e.getMessage().contains("余额不足"));
+    }
+
+    @Test
+    void refundCouponCf_持续换记录时原路返还到兑换人() {
+        FamilyPlatformExchangeRecord rec = new FamilyPlatformExchangeRecord();
+        rec.setUserId(USER_ID);
+        rec.setCouponId(COUPON_ID);
+        rec.setCfCost(500);
+        rec.setCouponName("测试优惠券");
+        when(exchangeRecordMapper.selectOne(any())).thenReturn(rec);
+
+        FamilyCoupon fc = new FamilyCoupon();
+        fc.setId(FAMILY_COUPON_ID);
+        fc.setCouponId(COUPON_ID);
+        when(familyCouponMapper.selectById(FAMILY_COUPON_ID)).thenReturn(fc);
+
+        CfBalanceResult result = new CfBalanceResult();
+        result.setDuplicated(false);
+        when(cfSpendDispatchService.refundForUser(any(), anyInt(), any(), any(), any())).thenReturn(result);
+
+        int refunded = service.refundCouponCf(FAMILY_COUPON_ID);
+
+        assertEquals(500, refunded);
+        verify(cfSpendDispatchService).refundForUser(eq(USER_ID), eq(500), eq("coupon_refund"),
+                eq(FAMILY_COUPON_ID), any());
+    }
+
+    @Test
+    void refundCouponCf_平台赠送券无兑换记录不退() {
+        when(exchangeRecordMapper.selectOne(any())).thenReturn(null);
+
+        int refunded = service.refundCouponCf(FAMILY_COUPON_ID);
+
+        assertEquals(0, refunded);
+        verify(cfSpendDispatchService, never()).refundForUser(any(), anyInt(), any(), any(), any());
+    }
+
+    private Coupon coupon(int pointsPrice, Long familyId) {
+        Coupon c = new Coupon();
+        c.setId(COUPON_ID);
+        c.setName("测试优惠券");
+        c.setStatus("ACTIVE");
+        c.setPointsPrice(pointsPrice);
+        c.setFamilyId(familyId);
+        return c;
+    }
+}

+ 175 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/PointsExchangeServiceTest.java

@@ -0,0 +1,175 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.config.ConfigService;
+import com.etotem.cfc.constant.CfConfigKeys;
+import com.etotem.cfc.entity.Coupon;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.PointsExchangeProduct;
+import com.etotem.cfc.entity.PointsExchangeRecord;
+import com.etotem.cfc.mapper.CouponMapper;
+import com.etotem.cfc.mapper.FamilyCouponGrantLogMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.PointsExchangeProductMapper;
+import com.etotem.cfc.mapper.PointsExchangeRecordMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 积分兑换(F-5:商品 / 优惠券扣减改投统一账本 cf_balance)单元测试:
+ *  - 先落兑换记录(记录 ID 作为扣减幂等 refId),再扣统一账本;
+ *  - 优惠券模板家庭绑定校验(N3);
+ *  - 日限 / 最低兑换数值读 service_config(不写死业务常量)。
+ */
+class PointsExchangeServiceTest {
+
+    private static final long USER_ID = 100L;
+    private static final long MEMBER_ID = 5L;
+    private static final long FAMILY_ID = 7L;
+    private static final long PRODUCT_ID = 21L;
+    private static final long COUPON_ID = 31L;
+
+    @Mock
+    private PointsExchangeProductMapper pointsExchangeProductMapper;
+    @Mock
+    private PointsExchangeRecordMapper pointsExchangeRecordMapper;
+    @Mock
+    private CfSpendDispatchService cfSpendDispatchService;
+    @Mock
+    private ConfigService configService;
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+    @Mock
+    private CouponMapper couponMapper;
+    @Mock
+    private CouponService couponService;
+    @Mock
+    private FamilyCouponGrantLogMapper familyCouponGrantLogMapper;
+
+    private final PointsExchangeService service = new PointsExchangeService();
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        ReflectionTestUtils.setField(service, "pointsExchangeProductMapper", pointsExchangeProductMapper);
+        ReflectionTestUtils.setField(service, "pointsExchangeRecordMapper", pointsExchangeRecordMapper);
+        ReflectionTestUtils.setField(service, "cfSpendDispatchService", cfSpendDispatchService);
+        ReflectionTestUtils.setField(service, "configService", configService);
+        ReflectionTestUtils.setField(service, "familyMemberMapper", familyMemberMapper);
+        ReflectionTestUtils.setField(service, "couponMapper", couponMapper);
+        ReflectionTestUtils.setField(service, "couponService", couponService);
+        ReflectionTestUtils.setField(service, "familyCouponGrantLogMapper", familyCouponGrantLogMapper);
+
+        when(configService.getIntConfig(CfConfigKeys.SPEND_EXCHANGE_DAILY_LIMIT, 5)).thenReturn(5);
+        when(configService.getIntConfig(CfConfigKeys.SPEND_EXCHANGE_MIN_POINTS, 100)).thenReturn(100);
+        when(configService.getIntConfig(CfConfigKeys.SPEND_COUPON_COUPON_DAILY_LIMIT, 1)).thenReturn(1);
+        when(pointsExchangeRecordMapper.selectCount(any())).thenReturn(0L);
+        when(familyCouponGrantLogMapper.selectCount(any())).thenReturn(0L);
+        // 插入时回填自增 ID(MyBatis-Plus 行为)
+        when(pointsExchangeRecordMapper.insert(any())).thenAnswer(inv -> {
+            PointsExchangeRecord record = inv.getArgument(0);
+            record.setId(88L);
+            return 1;
+        });
+    }
+
+    // ---------- 兑换商品 ----------
+
+    @Test
+    void exchangeProduct_先落记录再扣统一账本() {
+        PointsExchangeProduct product = product(100, 10);
+        when(pointsExchangeProductMapper.selectById(PRODUCT_ID)).thenReturn(product);
+
+        PointsExchangeRecord record = service.exchangeProduct(USER_ID, MEMBER_ID, PRODUCT_ID, 2);
+
+        assertEquals(88L, record.getId());
+        // refId = 兑换记录 ID(幂等键)
+        verify(cfSpendDispatchService).spendForMember(eq(MEMBER_ID), eq(200), eq("exchange_product"),
+                eq(88L), any());
+        // 库存扣减
+        verify(pointsExchangeProductMapper).updateById(product);
+        assertEquals(8, product.getStock());
+    }
+
+    @Test
+    void exchangeProduct_余额不足异常向上传播() {
+        when(pointsExchangeProductMapper.selectById(PRODUCT_ID)).thenReturn(product(100, 10));
+        when(cfSpendDispatchService.spendForMember(any(), anyInt(), any(), any(), any()))
+                .thenThrow(new IllegalStateException("CF值余额不足:可用 0,需要 200"));
+
+        IllegalStateException e = assertThrows(IllegalStateException.class,
+                () -> service.exchangeProduct(USER_ID, MEMBER_ID, PRODUCT_ID, 2));
+        assertTrue(e.getMessage().contains("余额不足"));
+    }
+
+    // ---------- 兑换优惠券 ----------
+
+    @Test
+    void exchangeCoupon_券模板绑定家庭被拦截() {
+        Coupon coupon = coupon(200, 2L); // 模板绑定家庭 2
+        when(couponMapper.selectById(COUPON_ID)).thenReturn(coupon);
+        when(familyMemberMapper.selectById(MEMBER_ID)).thenReturn(member());
+
+        RuntimeException e = assertThrows(RuntimeException.class,
+                () -> service.exchangeCoupon(USER_ID, MEMBER_ID, COUPON_ID));
+        assertTrue(e.getMessage().contains("仅限指定家庭"));
+        verify(cfSpendDispatchService, never()).spendForMember(any(), anyInt(), any(), any(), any());
+        verify(couponService, never()).grantFamily(any(), any(), any(), any(), any());
+    }
+
+    @Test
+    void exchangeCoupon_全平台模板_先记录后扣账本再发券到家庭() {
+        Coupon coupon = coupon(200, null); // NULL = 全平台模板
+        when(couponMapper.selectById(COUPON_ID)).thenReturn(coupon);
+        when(familyMemberMapper.selectById(MEMBER_ID)).thenReturn(member());
+
+        PointsExchangeRecord record = service.exchangeCoupon(USER_ID, MEMBER_ID, COUPON_ID);
+
+        assertEquals(88L, record.getId());
+        verify(cfSpendDispatchService).spendForMember(eq(MEMBER_ID), eq(200), eq("exchange_coupon"),
+                eq(88L), any());
+        verify(couponService).grantFamily(eq(FAMILY_ID), eq(COUPON_ID), eq("EXCHANGE"), eq(null), eq(null));
+    }
+
+    // ---------- 构造 ----------
+
+    private PointsExchangeProduct product(int pointsPrice, int stock) {
+        PointsExchangeProduct p = new PointsExchangeProduct();
+        p.setId(PRODUCT_ID);
+        p.setName("测试商品");
+        p.setStatus("enabled");
+        p.setPointsPrice(pointsPrice);
+        p.setStock(stock);
+        return p;
+    }
+
+    private Coupon coupon(int pointsPrice, Long familyId) {
+        Coupon c = new Coupon();
+        c.setId(COUPON_ID);
+        c.setName("测试优惠券");
+        c.setStatus("ACTIVE");
+        c.setPointsPrice(pointsPrice);
+        c.setFamilyId(familyId);
+        return c;
+    }
+
+    private FamilyMember member() {
+        FamilyMember m = new FamilyMember();
+        m.setId(MEMBER_ID);
+        m.setFamilyId(FAMILY_ID);
+        m.setUserId(USER_ID);
+        return m;
+    }
+}

+ 125 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/WishServiceTest.java

@@ -0,0 +1,125 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.entity.Wish;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.mapper.WishMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 心愿兑换(F-5:改投统一账本 cf_balance)单元测试:
+ *  - 申请兑换余额改读统一账本(不再看 family_members.total_points);
+ *  - 家长批准兑换扣个人 CF(ref_type=wish_exchange,ref_id=心愿 ID),不再写 total_points / PointsLog;
+ *  - 拒绝回退到 active 状态且不扣费。
+ */
+class WishServiceTest {
+
+    private static final long WISH_ID = 11L;
+    private static final long MEMBER_ID = 5L;
+    private static final long PARENT_ID = 100L;
+    private static final long FAMILY_ID = 7L;
+
+    @Mock
+    private WishMapper wishMapper;
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+    @Mock
+    private UserMapper userMapper;
+    @Mock
+    private CfSpendDispatchService cfSpendDispatchService;
+
+    private final WishService service = new WishService();
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        ReflectionTestUtils.setField(service, "wishMapper", wishMapper);
+        ReflectionTestUtils.setField(service, "familyMemberMapper", familyMemberMapper);
+        ReflectionTestUtils.setField(service, "userMapper", userMapper);
+        ReflectionTestUtils.setField(service, "cfSpendDispatchService", cfSpendDispatchService);
+    }
+
+    @Test
+    void requestExchange_统一账本余额不足时抛异常() {
+        Wish wish = wish("active", 200);
+        when(cfSpendDispatchService.getAvailableForMember(MEMBER_ID)).thenReturn(100);
+
+        RuntimeException e = assertThrows(RuntimeException.class,
+                () -> service.requestExchange(WISH_ID, MEMBER_ID));
+        assertTrue(e.getMessage().contains("积分不足"));
+        verify(wishMapper, never()).updateById(any());
+    }
+
+    @Test
+    void requestExchange_余额充足时置为pending_exchange() {
+        Wish wish = wish("active", 200);
+        when(cfSpendDispatchService.getAvailableForMember(MEMBER_ID)).thenReturn(300);
+
+        boolean ok = service.requestExchange(WISH_ID, MEMBER_ID);
+
+        assertTrue(ok);
+        assertEquals("pending_exchange", wish.getStatus());
+        verify(wishMapper).updateById(wish);
+    }
+
+    @Test
+    void approveExchange_批准时扣统一账本且不写旧账本() {
+        Wish wish = wish("pending_exchange", 200);
+        User parent = parent();
+
+        boolean ok = service.approveExchange(WISH_ID, PARENT_ID, true);
+
+        assertTrue(ok);
+        verify(cfSpendDispatchService).spendForMember(eq(MEMBER_ID), eq(200), eq("wish_exchange"),
+                eq(WISH_ID), any());
+        assertEquals("approved", wish.getStatus());
+    }
+
+    @Test
+    void approveExchange_拒绝时回退active且不扣费() {
+        Wish wish = wish("pending_exchange", 200);
+        User parent = parent();
+
+        boolean ok = service.approveExchange(WISH_ID, PARENT_ID, false);
+
+        assertTrue(ok);
+        verify(cfSpendDispatchService, never()).spendForMember(any(), anyInt(), any(), any(), any());
+        assertEquals("active", wish.getStatus());
+    }
+
+    private Wish wish(String status, int pointsRequired) {
+        Wish wish = new Wish();
+        wish.setId(WISH_ID);
+        wish.setFamilyId(FAMILY_ID);
+        wish.setChildId(MEMBER_ID);
+        wish.setPointsRequired(pointsRequired);
+        wish.setStatus(status);
+        wish.setRejectCount(0);
+        when(wishMapper.selectById(WISH_ID)).thenReturn(wish);
+        return wish;
+    }
+
+    private User parent() {
+        User user = new User();
+        user.setId(PARENT_ID);
+        user.setFamilyId(FAMILY_ID);
+        when(userMapper.selectById(PARENT_ID)).thenReturn(user);
+        return user;
+    }
+}

+ 141 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/WithdrawalServiceTest.java

@@ -0,0 +1,141 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.config.ConfigService;
+import com.etotem.cfc.constant.CfConfigKeys;
+import com.etotem.cfc.entity.WithdrawalRequest;
+import com.etotem.cfc.mapper.WithdrawalRequestMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 提现服务(F-5:个人 CF 值统一账本)单元测试:
+ *  - 提现开关读 service_config(默认关闭);
+ *  - 最低提现金额读 service_config(不写死业务常量);
+ *  - 申请 → 冻结个人账本(ref_type=withdrawal,ref_id=提现申请 ID);
+ *  - 审核通过 → 提现;审核拒绝 → 解冻。
+ */
+class WithdrawalServiceTest {
+
+    private static final long USER_ID = 100L;
+    private static final long MEMBER_ID = 5L;
+    private static final long REQUEST_ID = 88L;
+
+    @Mock
+    private WithdrawalRequestMapper withdrawalRequestMapper;
+    @Mock
+    private CfSpendDispatchService cfSpendDispatchService;
+    @Mock
+    private ConfigService configService;
+
+    private final WithdrawalService service = new WithdrawalService();
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        ReflectionTestUtils.setField(service, "withdrawalRequestMapper", withdrawalRequestMapper);
+        ReflectionTestUtils.setField(service, "cfSpendDispatchService", cfSpendDispatchService);
+        ReflectionTestUtils.setField(service, "configService", configService);
+
+        when(configService.getBooleanConfig(CfConfigKeys.WITHDRAW_ENABLED, false)).thenReturn(true);
+        when(configService.getIntConfig(CfConfigKeys.SPEND_WITHDRAW_MIN_AMOUNT_CENT, 10000)).thenReturn(10000);
+        when(cfSpendDispatchService.resolveMemberId(USER_ID)).thenReturn(MEMBER_ID);
+        when(cfSpendDispatchService.getAvailableForMember(MEMBER_ID)).thenReturn(50000);
+        // 插入时回填自增 ID(MyBatis-Plus 行为)
+        when(withdrawalRequestMapper.insert(any())).thenAnswer(inv -> {
+            WithdrawalRequest request = inv.getArgument(0);
+            request.setId(REQUEST_ID);
+            return 1;
+        });
+    }
+
+    @Test
+    void apply_开关关闭时拒绝提现() {
+        when(configService.getBooleanConfig(CfConfigKeys.WITHDRAW_ENABLED, false)).thenReturn(false);
+
+        Result<String> result = service.apply(USER_ID, 10000, "alipay:test@test.com");
+
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("提现功能未开启"));
+        verify(withdrawalRequestMapper, never()).insert(any());
+    }
+
+    @Test
+    void apply_低于配置最低金额时拒绝() {
+        when(configService.getIntConfig(CfConfigKeys.SPEND_WITHDRAW_MIN_AMOUNT_CENT, 10000)).thenReturn(20000);
+
+        Result<String> result = service.apply(USER_ID, 10000, "alipay:test@test.com");
+
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("200.00"));
+        verify(withdrawalRequestMapper, never()).insert(any());
+    }
+
+    @Test
+    void apply_余额不足时拒绝() {
+        when(cfSpendDispatchService.getAvailableForMember(MEMBER_ID)).thenReturn(5000);
+
+        Result<String> result = service.apply(USER_ID, 10000, "alipay:test@test.com");
+
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("CF值不足"));
+        verify(withdrawalRequestMapper, never()).insert(any());
+    }
+
+    @Test
+    void apply_成功时冻结个人统一账本() {
+        Result<String> result = service.apply(USER_ID, 10000, "alipay:test@test.com");
+
+        assertEquals(200, result.getCode());
+        verify(cfSpendDispatchService).freezeForMember(eq(MEMBER_ID), eq(10000), eq("withdrawal"),
+                eq(REQUEST_ID), any());
+    }
+
+    @Test
+    void audit_审核通过时提现并置为processed() {
+        WithdrawalRequest request = pendingRequest();
+
+        service.audit(REQUEST_ID, 66L, "approved", null);
+
+        verify(cfSpendDispatchService).withdrawForMember(eq(MEMBER_ID), eq(10000), eq("withdrawal"),
+                eq(REQUEST_ID), any());
+        verify(cfSpendDispatchService, never()).unfreezeForMember(any(), anyInt(), any(), any(), any());
+        assertEquals("processed", request.getStatus());
+        verify(withdrawalRequestMapper).updateById(request);
+    }
+
+    @Test
+    void audit_审核拒绝时解冻并记录原因() {
+        WithdrawalRequest request = pendingRequest();
+
+        service.audit(REQUEST_ID, 66L, "rejected", "账户信息有误");
+
+        verify(cfSpendDispatchService).unfreezeForMember(eq(MEMBER_ID), eq(10000), eq("withdrawal"),
+                eq(REQUEST_ID), any());
+        verify(cfSpendDispatchService, never()).withdrawForMember(any(), anyInt(), any(), any(), any());
+        assertEquals("rejected", request.getStatus());
+        assertEquals("账户信息有误", request.getRejectReason());
+    }
+
+    private WithdrawalRequest pendingRequest() {
+        WithdrawalRequest request = new WithdrawalRequest();
+        request.setId(REQUEST_ID);
+        request.setUserId(USER_ID);
+        request.setAmount(10000);
+        request.setStatus("pending");
+        when(withdrawalRequestMapper.selectById(REQUEST_ID)).thenReturn(request);
+        return request;
+    }
+}

+ 129 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/impl/PurchaseServiceImplRenewalTest.java

@@ -0,0 +1,129 @@
+package com.etotem.cfc.service.impl;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.config.ConfigService;
+import com.etotem.cfc.constant.CfConfigKeys;
+import com.etotem.cfc.entity.FamilyMembership;
+import com.etotem.cfc.entity.PurchaseOrder;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.FamilyMembershipMapper;
+import com.etotem.cfc.mapper.PurchaseOrderMapper;
+import com.etotem.cfc.service.CfGrowthPathwayEarnService;
+import com.etotem.cfc.service.CfSpendDispatchService;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 会员续费(F-5:CF 值抵扣改投统一账本 cf_balance)单元测试:
+ *  - 续费余额 / 抵扣上限 / 年度价格全部读统一账本与 service_config;
+ *  - 抵扣扣支付人个人 cf_balance(幂等 refId = 续费订单 ID);
+ *  - 抵扣超上限抛业务异常,不落扣减。
+ */
+class PurchaseServiceImplRenewalTest {
+
+    private static final long USER_ID = 100L;
+    private static final long FAMILY_ID = 7L;
+    private static final long RENEWAL_ORDER_ID = 123L;
+
+    @Mock
+    private PurchaseOrderMapper orderMapper;
+    @Mock
+    private FamilyMembershipMapper membershipMapper;
+    @Mock
+    private FamilyMemberMapper memberMapper;
+    @Mock
+    private CfGrowthPathwayEarnService cfGrowthPathwayEarnService;
+    @Mock
+    private CfSpendDispatchService cfSpendDispatchService;
+    @Mock
+    private ConfigService configService;
+
+    private final PurchaseServiceImpl service = new PurchaseServiceImpl();
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        // LambdaQueryWrapper 需要实体元数据(参照 CfEarnDispatchServiceTest)
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), PurchaseOrder.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), FamilyMembership.class);
+
+        ReflectionTestUtils.setField(service, "orderMapper", orderMapper);
+        ReflectionTestUtils.setField(service, "membershipMapper", membershipMapper);
+        ReflectionTestUtils.setField(service, "memberMapper", memberMapper);
+        ReflectionTestUtils.setField(service, "cfGrowthPathwayEarnService", cfGrowthPathwayEarnService);
+        ReflectionTestUtils.setField(service, "cfSpendDispatchService", cfSpendDispatchService);
+        ReflectionTestUtils.setField(service, "configService", configService);
+
+        when(configService.getIntConfig(CfConfigKeys.SPEND_MEMBERSHIP_MAX_DEDUCT, 19900)).thenReturn(19900);
+        when(configService.getIntConfig(CfConfigKeys.SPEND_MEMBERSHIP_PRICE_CENT, 199900)).thenReturn(199900);
+
+        PurchaseOrder existing = new PurchaseOrder();
+        existing.setId(50L);
+        existing.setUserId(USER_ID);
+        existing.setStatus("paid");
+        when(orderMapper.selectOne(any())).thenReturn(existing);
+        when(membershipMapper.selectOne(any())).thenReturn(null);
+        // 续费订单插入时回填自增 ID(MyBatis-Plus 行为)
+        when(orderMapper.insert(any())).thenAnswer(inv -> {
+            PurchaseOrder order = inv.getArgument(0);
+            order.setId(RENEWAL_ORDER_ID);
+            return 1;
+        });
+    }
+
+    @Test
+    void processRenewal_CF抵扣扣支付人统一账本() {
+        Map<String, Object> result = service.processRenewal(USER_ID, FAMILY_ID, 199900, 5000);
+
+        assertEquals(Boolean.TRUE, result.get("success"));
+        assertEquals(5000, result.get("cfUsed"));
+        verify(cfSpendDispatchService).spendForUser(eq(USER_ID), eq(5000), eq("membership_renewal_cf"),
+                eq(RENEWAL_ORDER_ID), any());
+    }
+
+    @Test
+    void processRenewal_cfUsed为空时不扣减() {
+        Map<String, Object> result = service.processRenewal(USER_ID, FAMILY_ID, 199900, null);
+
+        assertEquals(Boolean.TRUE, result.get("success"));
+        assertEquals(0, result.get("cfUsed"));
+        verify(cfSpendDispatchService, never()).spendForUser(any(), anyInt(), any(), any(), any());
+    }
+
+    @Test
+    void processRenewal_抵扣超过配置上限时抛异常() {
+        IllegalStateException e = assertThrows(IllegalStateException.class,
+                () -> service.processRenewal(USER_ID, FAMILY_ID, 199900, 30000));
+        assertTrue(e.getMessage().contains("超出上限"));
+        verify(cfSpendDispatchService, never()).spendForUser(any(), anyInt(), any(), any(), any());
+    }
+
+    @Test
+    void getRenewalInfo_余额与价格读统一账本与配置() {
+        when(cfSpendDispatchService.getAvailableForUser(USER_ID)).thenReturn(10000);
+
+        Map<String, Object> info = service.getRenewalInfo(USER_ID);
+
+        assertEquals(true, info.get("success"));
+        assertEquals(10000, info.get("cfBalance"));
+        assertEquals(10000, info.get("discount"));
+        assertEquals(189900, info.get("payAmount"));
+    }
+}