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

feat(cf): F-3c 发放接入 F20-F24 统一账本

E2E Test Bot 2 недель назад
Родитель
Сommit
29887b33b3

+ 7 - 51
cfc-backend/src/main/java/com/etotem/cfc/controller/CfTransferController.java

@@ -1,14 +1,9 @@
 package com.etotem.cfc.controller;
 
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.CfTransferRecord;
-import com.etotem.cfc.entity.User;
-import com.etotem.cfc.mapper.CfTransferRecordMapper;
-import com.etotem.cfc.mapper.UserMapper;
-import com.etotem.cfc.service.PlatformPointsService;
-import org.springframework.transaction.annotation.Transactional;
+import com.etotem.cfc.service.CfTransferService;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestAttribute;
 import org.springframework.web.bind.annotation.RequestBody;
@@ -16,24 +11,17 @@ import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 
 import javax.annotation.Resource;
-import java.util.Date;
 import java.util.Map;
-import java.util.UUID;
 
 @RestController
 @RequestMapping("/api/cf/transfer")
 public class CfTransferController {
 
     @Resource
-    private PlatformPointsService platformPointsService;
-    @Resource
-    private CfTransferRecordMapper cfTransferRecordMapper;
-    @Resource
-    private UserMapper userMapper;
+    private CfTransferService cfTransferService;
 
     /** 成员间转让 CF(需同家庭) */
     @PostMapping("/send")
-    @Transactional
     public Result<String> send(@RequestAttribute("userId") Long fromUserId,
                                @RequestBody Map<String, Object> params) {
         Object toObj = params.get("toUserId");
@@ -43,36 +31,11 @@ public class CfTransferController {
         }
         Long toUserId = ((Number) toObj).longValue();
         int amount = ((Number) amtObj).intValue();
-        if (amount <= 0) {
-            return Result.error("CF值必须为正数");
-        }
-        User from = userMapper.selectById(fromUserId);
-        User to = userMapper.selectById(toUserId);
-        if (from == null || to == null) {
-            return Result.error("用户不存在");
+        try {
+            return Result.success(cfTransferService.transfer(fromUserId, toUserId, amount));
+        } catch (IllegalArgumentException | IllegalStateException e) {
+            return Result.error(e.getMessage());
         }
-        if (from.getFamilyId() == null || !from.getFamilyId().equals(to.getFamilyId())) {
-            return Result.error("仅限同一家庭成员间转让");
-        }
-        // 每次转让使用唯一 refId(避免 earn 幂等键 (ref_type, ref_id) 碰撞),并保证非负
-        Long transferRefId = UUID.randomUUID().getMostSignificantBits() & Long.MAX_VALUE;
-        if (transferRefId == 0) transferRefId = System.currentTimeMillis();
-        // 转出扣减 + 转入增加(同一事务)
-        platformPointsService.spend(fromUserId, amount, "cf_transfer_out", transferRefId,
-                "转给成员: " + (to.getNickname() == null ? toUserId : to.getNickname()));
-        platformPointsService.earn(toUserId, amount, "cf_transfer_in", transferRefId,
-                "收到成员转让: " + (from.getNickname() == null ? fromUserId : from.getNickname()));
-
-        CfTransferRecord record = new CfTransferRecord();
-        record.setFromUserId(fromUserId);
-        record.setToUserId(toUserId);
-        record.setFamilyId(from.getFamilyId());
-        record.setAmount(amount);
-        record.setType("transfer");
-        record.setRemark("成员间转让");
-        record.setCreatedAt(new Date());
-        cfTransferRecordMapper.insert(record);
-        return Result.success("转让成功");
     }
 
     /** 转让记录(分页,按当前用户家庭) */
@@ -81,13 +44,6 @@ public class CfTransferController {
                                                @RequestBody Map<String, Integer> params) {
         int page = params.getOrDefault("page", 1);
         int size = params.getOrDefault("size", 20);
-        User user = userMapper.selectById(userId);
-        Long familyId = user == null ? null : user.getFamilyId();
-        LambdaQueryWrapper<CfTransferRecord> wrapper = new LambdaQueryWrapper<>();
-        if (familyId != null) {
-            wrapper.eq(CfTransferRecord::getFamilyId, familyId);
-        }
-        wrapper.orderByDesc(CfTransferRecord::getCreatedAt);
-        return Result.success(cfTransferRecordMapper.selectPage(new Page<>(page, size), wrapper));
+        return Result.success(cfTransferService.list(userId, page, size));
     }
 }

+ 148 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/CfPathwayEarnService.java

@@ -22,7 +22,8 @@ import java.util.HashMap;
 import java.util.Map;
 
 /**
- * CF 值发放途径接入服务(F-3b1:成长行为类 F07 / F08 / F09 / F10 / F11 / F12 / F13 + F19 管理员手动调整)。
+ * CF 值发放途径接入服务(F-3b1:成长行为类 F07 / F08 / F09 / F10 / F11 / F12 / F13 + F19 管理员手动调整;
+ * F-3c:问题域 / 关卡 / 任务 / 成员转让 / 分享注册 F20-F24)。
  *
  * <p>统一收口:所有途径一律改投 F-2 的统一入口
  * {@link CfBalanceService#earnByRule(Long, String, Long, Map)}(读规则 → 会员等级日限/冷却/累计校验 →
@@ -38,6 +39,8 @@ import java.util.Map;
  *   <li>F09 测评建档 → assessment_report;F10 家庭挑战 → family_challenge;</li>
  *   <li>F11 邀请里程碑 → invite_milestone;F12 成长任务 → growth_task;</li>
  *   <li>F13 分享阅读 → share_read;F19 管理员调整 → admin_adjust。</li>
+ *   <li>F20 问题域全部任务完成 → problem_domain;F21 关卡通关 → unlock_gate;</li>
+ *   <li>F22 任务完成 CF → task_complete;F23 成员间转让 → cf_transfer;F24 分享奖励(推荐注册)→ referral_register。</li>
  * </ul>
  *
  * <p>口径见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §3.2 / §三 / §七 F-3 行。</p>
@@ -64,11 +67,31 @@ public class CfPathwayEarnService {
     public static final String RULE_F13 = "F13";
     /** F19 管理员手动调整(正负任意,需家长密码校验,必须留痕 Q9) */
     public static final String RULE_F19 = "F19";
+    /** F20 问题域全部任务完成奖励(数量 = problem_domain.reward_cf_value,管理端配置) */
+    public static final String RULE_F20 = "F20";
+    /** F21 关卡通关奖励(数量 = unlock_gate.reward_points,管理端配置) */
+    public static final String RULE_F21 = "F21";
+    /** F22 任务完成 CF(数量 = 任务配置 CF 值;当前来源 tasks.points,日限按会员等级 N11) */
+    public static final String RULE_F22 = "F22";
+    /** F23 成员间转让(收款方;同一事务内含转出方 spend,见 CfTransferService) */
+    public static final String RULE_F23 = "F23";
+    /** F24 分享奖励(推荐他人注册即获 CF,数量 = 规则 amount 建议 20,日限 10 次 N8) */
+    public static final String RULE_F24 = "F24";
 
     /** F09 规则缺失时的兜底 refType(与迁移408 种子一致) */
     private static final String DEFAULT_REF_TYPE_F09 = "assessment_report";
     /** F19 规则缺失时的兜底 refType(与迁移408 种子一致) */
     private static final String DEFAULT_REF_TYPE_F19 = "admin_adjust";
+    /** F20 规则缺失时的兜底 refType(与迁移408 种子一致) */
+    private static final String DEFAULT_REF_TYPE_F20 = "problem_domain";
+    /** F21 规则缺失时的兜底 refType(与迁移408 种子一致) */
+    private static final String DEFAULT_REF_TYPE_F21 = "unlock_gate";
+    /** F22 规则缺失时的兜底 refType(与迁移408 种子一致) */
+    private static final String DEFAULT_REF_TYPE_F22 = "task_complete";
+    /** F23 规则缺失时的兜底 refType(与迁移408 种子一致) */
+    private static final String DEFAULT_REF_TYPE_F23 = "cf_transfer";
+    /** F24 规则缺失时的兜底 refType(与迁移408 种子一致) */
+    private static final String DEFAULT_REF_TYPE_F24 = "referral_register";
 
     @Resource
     private CfBalanceService cfBalanceService;
@@ -353,6 +376,130 @@ public class CfPathwayEarnService {
         }
     }
 
+    // ==================== F20 问题域全部任务完成 ====================
+
+    /**
+     * F20 问题域全部任务完成奖励:数量 = {@code problem_domain.reward_cf_value}。
+     *
+     * <p>统一落到完成任务所属家庭成员的个人 CF 账本;ref_id = 问题域ID,
+     * 同一成员同一问题域只发一次(由 F-2 幂等键拦截)。</p>
+     *
+     * @param memberId      家庭成员ID(family_members.id)
+     * @param domainId      问题域ID(problem_domains.id)
+     * @param rewardCfValue 问题域配置的 CF 奖励数量
+     * @param domainName    问题域名称(流水备注)
+     */
+    public CfEarnResult earnProblemDomainCf(Long memberId, Long domainId, int rewardCfValue, String domainName) {
+        if (memberId == null || memberId <= 0 || domainId == null || domainId <= 0 || rewardCfValue <= 0) {
+            return skipped(RULE_F20, refTypeOfRule(RULE_F20, DEFAULT_REF_TYPE_F20), domainId,
+                    "成员、问题域或奖励数量无效,未发放问题域完成奖励");
+        }
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("amount", rewardCfValue);
+        ctx.put("domain_id", domainId);
+        ctx.put("remark", "完成问题域「" + (domainName == null ? "" : domainName) + "」全部任务奖励");
+        return cfBalanceService.earnByRule(memberId, RULE_F20, domainId, ctx);
+    }
+
+    // ==================== F21 关卡通关 ====================
+
+    /**
+     * F21 关卡通关奖励:数量 = {@code unlock_gate.reward_points}。
+     *
+     * @param memberId    家庭成员ID(family_members.id)
+     * @param gateId      关卡ID(unlock_gate.id)
+     * @param rewardPoints 关卡配置的 CF 奖励数量
+     * @param gateName    关卡名称(流水备注)
+     */
+    public CfEarnResult earnUnlockGateCf(Long memberId, Long gateId, int rewardPoints, String gateName) {
+        if (memberId == null || memberId <= 0 || gateId == null || gateId <= 0 || rewardPoints <= 0) {
+            return skipped(RULE_F21, refTypeOfRule(RULE_F21, DEFAULT_REF_TYPE_F21), gateId,
+                    "成员、关卡或奖励数量无效,未发放关卡通关奖励");
+        }
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("amount", rewardPoints);
+        ctx.put("gate_id", gateId);
+        ctx.put("remark", "通关奖励: " + (gateName == null ? "" : gateName));
+        return cfBalanceService.earnByRule(memberId, RULE_F21, gateId, ctx);
+    }
+
+    // ==================== F22 任务完成 CF ====================
+
+    /**
+     * F22 任务完成 CF:数量取任务配置的 CF 值。
+     *
+     * <p>当前 tasks 表尚未拆分独立 {@code cf_amount} 列,且本批次禁止新增迁移,
+     * 因此沿用现有任务 CF 发放来源 {@code tasks.points};规则 F22 仍负责日限与幂等。</p>
+     *
+     * @param memberId 家庭成员ID(family_members.id)
+     * @param taskId   任务ID(tasks.id)
+     * @param cfAmount 任务 CF 奖励数量
+     * @param taskTitle 任务标题(流水备注)
+     */
+    public CfEarnResult earnTaskCompletionCf(Long memberId, Long taskId, int cfAmount, String taskTitle) {
+        if (memberId == null || memberId <= 0 || taskId == null || taskId <= 0 || cfAmount <= 0) {
+            return skipped(RULE_F22, refTypeOfRule(RULE_F22, DEFAULT_REF_TYPE_F22), taskId,
+                    "成员、任务或 CF 数量无效,未发放任务完成奖励");
+        }
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("amount", cfAmount);
+        ctx.put("task_id", taskId);
+        ctx.put("remark", "完成任务: " + (taskTitle == null ? "" : taskTitle));
+        return cfBalanceService.earnByRule(memberId, RULE_F22, taskId, ctx);
+    }
+
+    // ==================== F23 成员间转让(收款方) ====================
+
+    /**
+     * F23 成员间转让收款方发放:数量 = 实际转让金额。
+     *
+     * <p>转出方扣减由 {@link CfBalanceService#spend(Long, int, String, Long, String)} 完成;
+     * 两侧共用同一 transfer refId,并由 {@code CfTransferService} 的同一事务保证原子性。</p>
+     *
+     * @param toMemberId   收款方家庭成员ID
+     * @param transferRefId 转让流水ID(幂等标识)
+     * @param transferAmount 转让金额
+     */
+    public CfEarnResult earnMemberTransferCf(Long toMemberId, Long transferRefId, int transferAmount) {
+        if (toMemberId == null || toMemberId <= 0 || transferRefId == null || transferRefId <= 0 || transferAmount <= 0) {
+            return skipped(RULE_F23, refTypeOfRule(RULE_F23, DEFAULT_REF_TYPE_F23), transferRefId,
+                    "收款成员、转让标识或金额无效,未发放转让 CF 值");
+        }
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("amount", transferAmount);
+        ctx.put("transfer_ref_id", transferRefId);
+        ctx.put("remark", "收到成员转让 CF 值");
+        return cfBalanceService.earnByRule(toMemberId, RULE_F23, transferRefId, ctx);
+    }
+
+    /**
+     * F23 转出方 spend 流水使用的 refType;与规则 F23 的 dedup_ref_type 保持一致。
+     */
+    public String transferRefType() {
+        return refTypeOfRule(RULE_F23, DEFAULT_REF_TYPE_F23);
+    }
+
+    // ==================== F24 分享奖励(推荐注册) ====================
+
+    /**
+     * F24 分享奖励:推荐人成功绑定被推荐人后发放,数量由规则固定值决定(建议 20 CF/人,日限 10 次)。
+     *
+     * <p>ref_id = 被推荐用户ID,同一推荐人针对同一被推荐用户只发一次。</p>
+     *
+     * @param referrerMemberId 推荐人家庭成员ID
+     * @param referredUserId   被推荐用户ID(users.id)
+     */
+    public CfEarnResult earnReferralRegistrationCf(Long referrerMemberId, Long referredUserId) {
+        if (referrerMemberId == null || referrerMemberId <= 0 || referredUserId == null || referredUserId <= 0) {
+            return skipped(RULE_F24, refTypeOfRule(RULE_F24, DEFAULT_REF_TYPE_F24), referredUserId,
+                    "推荐人或被推荐用户无效,未发放分享奖励");
+        }
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("referred_user_id", referredUserId);
+        ctx.put("remark", "分享奖励:推荐新人注册");
+        return cfBalanceService.earnByRule(referrerMemberId, RULE_F24, referredUserId, ctx);
+    }
+
     // ==================== 工具方法 ====================
 
     /** 规则 refType:取 dedup_ref_type,规则缺失时用兜底值 */

+ 137 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CfTransferService.java

@@ -0,0 +1,137 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.dto.CfEarnResult;
+import com.etotem.cfc.entity.CfTransferRecord;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.CfTransferRecordMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.UUID;
+
+/**
+ * 家庭成员间 CF 值转让(F23)。
+ *
+ * <p>F-3c 统一账本口径:转出方走 {@link CfBalanceService#spend}、收款方走
+ * {@link CfPathwayEarnService#earnMemberTransferCf}(规则 F23),两侧共用同一转让 refId;
+ * 方法级事务保证转出与转入、业务流水记录同生同灭。</p>
+ */
+@Slf4j
+@Service
+public class CfTransferService {
+
+    private static final int DEFAULT_PAGE_SIZE = 20;
+    private static final int MAX_PAGE_SIZE = 100;
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Resource
+    private CfBalanceService cfBalanceService;
+
+    @Resource
+    private CfPathwayEarnService cfPathwayEarnService;
+
+    @Resource
+    private CfTransferRecordMapper cfTransferRecordMapper;
+
+    /**
+     * 同家庭成员间转让 CF 值。
+     *
+     * @return 成功提示
+     * @throws IllegalArgumentException  入参或用户不存在 / 不同家庭 / 缺成员档案
+     * @throws IllegalStateException     余额不足或收款方入账未成功
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public String transfer(Long fromUserId, Long toUserId, int amount) {
+        if (fromUserId == null || toUserId == null || fromUserId.equals(toUserId)) {
+            throw new IllegalArgumentException("转出和转入用户不能为空且不能相同");
+        }
+        if (amount <= 0) {
+            throw new IllegalArgumentException("CF值必须为正数");
+        }
+
+        User from = userMapper.selectById(fromUserId);
+        User to = userMapper.selectById(toUserId);
+        if (from == null || to == null) {
+            throw new IllegalArgumentException("用户不存在");
+        }
+        if (from.getFamilyId() == null || !from.getFamilyId().equals(to.getFamilyId())) {
+            throw new IllegalArgumentException("仅限同一家庭成员间转让");
+        }
+
+        FamilyMember fromMember = resolveMember(fromUserId, from.getFamilyId());
+        FamilyMember toMember = resolveMember(toUserId, to.getFamilyId());
+        if (fromMember == null || toMember == null) {
+            throw new IllegalArgumentException("转出或转入成员未建立家庭成员档案");
+        }
+
+        Long transferRefId = UUID.randomUUID().getMostSignificantBits() & Long.MAX_VALUE;
+        if (transferRefId == 0) {
+            transferRefId = System.currentTimeMillis();
+        }
+        String refType = cfPathwayEarnService.transferRefType();
+        String fromRemark = "转给成员: " + (to.getNickname() == null ? toUserId : to.getNickname());
+        String toRemark = "收到成员转让: " + (from.getNickname() == null ? fromUserId : from.getNickname());
+
+        cfBalanceService.spend(fromMember.getId(), amount, refType, transferRefId, fromRemark);
+        CfEarnResult earnResult = cfPathwayEarnService.earnMemberTransferCf(
+                toMember.getId(), transferRefId, amount);
+        if (earnResult == null || !Boolean.TRUE.equals(earnResult.getEarned())) {
+            throw new IllegalStateException("收款方 CF 值入账失败"
+                    + (earnResult != null && earnResult.getReason() != null ? ":" + earnResult.getReason() : ""));
+        }
+
+        CfTransferRecord record = new CfTransferRecord();
+        record.setFromUserId(fromUserId);
+        record.setToUserId(toUserId);
+        record.setFamilyId(from.getFamilyId());
+        record.setAmount(amount);
+        record.setType("transfer");
+        record.setRefType(refType);
+        record.setRefId(transferRefId);
+        record.setRemark("成员间转让");
+        record.setCreatedAt(new Date());
+        cfTransferRecordMapper.insert(record);
+
+        log.info("CF 值转让成功: fromUserId={}, toUserId={}, memberId={}→{}, amount={}, refId={}",
+                fromUserId, toUserId, fromMember.getId(), toMember.getId(), amount, transferRefId);
+        return "转让成功";
+    }
+
+    /** 转让记录分页(仅当前用户所属家庭) */
+    public Page<CfTransferRecord> list(Long userId, Integer page, Integer size) {
+        int safePage = page == null || page < 1 ? 1 : page;
+        int safeSize = size == null || size < 1 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE);
+        Page<CfTransferRecord> pageParam = new Page<>(safePage, safeSize);
+
+        User user = userMapper.selectById(userId);
+        Long familyId = user == null ? null : user.getFamilyId();
+        if (familyId == null) {
+            return pageParam;
+        }
+        LambdaQueryWrapper<CfTransferRecord> wrapper = new LambdaQueryWrapper<CfTransferRecord>()
+                .eq(CfTransferRecord::getFamilyId, familyId)
+                .orderByDesc(CfTransferRecord::getCreatedAt);
+        return cfTransferRecordMapper.selectPage(pageParam, wrapper);
+    }
+
+    private FamilyMember resolveMember(Long userId, Long familyId) {
+        return familyMemberMapper.selectOne(new LambdaQueryWrapper<FamilyMember>()
+                .eq(FamilyMember::getUserId, userId)
+                .eq(FamilyMember::getFamilyId, familyId)
+                .orderByAsc(FamilyMember::getId)
+                .last("LIMIT 1"));
+    }
+}

+ 28 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java

@@ -91,6 +91,9 @@ public class CommissionService {
     @Resource
     private CfReferralService cfReferralService;
 
+    @Resource
+    private CfPathwayEarnService cfPathwayEarnService;
+
     // ==================== 工具方法 ====================
 
     /**
@@ -188,6 +191,18 @@ public class CommissionService {
         referrer.setDirectCount(referrer.getDirectCount() != null ? referrer.getDirectCount() + 1 : 1);
         userMapper.updateById(referrer);
 
+        // F24 分享奖励:推荐人绑定被推荐人成功即发 CF(规则固定数量,日限 10 次),失败不阻断绑定主流程
+        try {
+            Long referrerMemberId = resolveMemberIdByUserId(referrer.getId());
+            if (referrerMemberId == null) {
+                log.warn("推荐人未绑定家庭成员档案,跳过 F24 分享奖励: referrerId={}", referrer.getId());
+            } else {
+                cfPathwayEarnService.earnReferralRegistrationCf(referrerMemberId, userId);
+            }
+        } catch (Exception e) {
+            log.warn("F24 分享奖励发放失败: referrerId={}, referredUserId={}", referrer.getId(), userId, e);
+        }
+
         try { onboardingService.completeTask(userId, "BIND_REFERRAL"); } catch (Exception e) { /* onboarding trigger failure should not block referral bind */ }
 
         try { inviteMilestoneService.checkMilestones(referrer.getId()); } catch (Exception e) { log.warn("Milestone check failed for referrer={}", referrer.getId(), e); }
@@ -195,6 +210,19 @@ public class CommissionService {
         try { referralLeaderboardService.incrementCount(referrer.getId()); } catch (Exception e) { log.warn("Leaderboard increment failed for referrer={}", referrer.getId(), e); }
     }
 
+    /** 用户ID → 家庭成员ID(family_members.user_id;无档案返回 null) */
+    private Long resolveMemberIdByUserId(Long userId) {
+        if (userId == null) {
+            return null;
+        }
+        FamilyMember member = familyMemberMapper.selectOne(
+                new LambdaQueryWrapper<FamilyMember>()
+                        .eq(FamilyMember::getUserId, userId)
+                        .orderByAsc(FamilyMember::getId)
+                        .last("LIMIT 1"));
+        return member == null ? null : member.getId();
+    }
+
     public Page<User> getMyReferrals(Long userId, int page, int size) {
         Page<User> pageParam = new Page<>(page, size);
         return userMapper.selectPage(pageParam,

+ 11 - 11
cfc-backend/src/main/java/com/etotem/cfc/service/ProblemCompletionService.java

@@ -25,8 +25,8 @@ import java.util.Map;
 /**
  * 问题域完成奖励 — 改版v1(B17)
  * <p>
- * 任务全部完成后发放 CF 值(家庭维度),金额=问题域配置 reward_cf_value;
- * 幂等:(familyId, problemDomainCode, 'completion') 查唯一发放记录(earn 内部幂等);
+ * 任务全部完成后发放 CF 值(F20 统一入口 → 个人成员账本),金额=问题域配置 reward_cf_value;
+ * 幂等:按 (memberId, problem_domain, domainId) 唯一流水拦截;
  * 写通知(reward_granted) + 更新旅程 stage=7。
  */
 @Slf4j
@@ -43,7 +43,7 @@ public class ProblemCompletionService {
     private TaskPlanInstanceMapper planInstanceMapper;
 
     @Resource
-    private FamilyPlatformPointsService familyPlatformPointsService;
+    private CfPathwayEarnService cfPathwayEarnService;
 
     @Resource
     private UserNotificationMapper userNotificationMapper;
@@ -83,11 +83,12 @@ public class ProblemCompletionService {
             if (packageIds.isEmpty()) {
                 continue;
             }
-            rewardIfDomainCompleted(familyId, domain, packageIds);
+            rewardIfDomainCompleted(familyId, domain, packageIds, task.getFamilyMemberId());
         }
     }
 
-    private void rewardIfDomainCompleted(Long familyId, ProblemDomain domain, List<Long> packageIds) {
+    private void rewardIfDomainCompleted(Long familyId, ProblemDomain domain, List<Long> packageIds,
+                                         Long rewardMemberId) {
         // 1. 找该家庭应用了这些套餐的方案实例
         List<TaskPlanInstance> instances = planInstanceMapper.selectList(
                 new LambdaQueryWrapper<TaskPlanInstance>()
@@ -119,13 +120,12 @@ public class ProblemCompletionService {
             }
         }
 
-        // 4. 全部完成 → 发放 CF(earn 内部按 familyId+refType+refId 幂等)
+        // 4. 全部完成 → 发放个人 CF(F20 统一入口,按 member_id + ref_type + ref_id 幂等)
         int reward = domain.getRewardCfValue() != null ? domain.getRewardCfValue() : 0;
         if (reward <= 0) {
             return;
         }
-        familyPlatformPointsService.earn(familyId, reward, "completion", domain.getId(),
-                "完成问题域「" + domain.getName() + "」全部任务奖励");
+        cfPathwayEarnService.earnProblemDomainCf(rewardMemberId, domain.getId(), reward, domain.getName());
 
         // 5. 写通知(防重复:type=reward_granted + refType=completion + refId=domainId)
         List<UserIntent> intents = userIntentMapper.selectList(
@@ -146,7 +146,7 @@ public class ProblemCompletionService {
                 notificationDispatchService.sendByTemplate("reward_granted", intent.getUserId(), vars,
                         "completion", domain.getId(),
                         "问题域完成奖励已到账",
-                        "恭喜完成「" + domain.getName() + "」全部任务,奖励 " + reward + " CF 已发放至家庭账户。");
+                        "恭喜完成「" + domain.getName() + "」全部任务,奖励 " + reward + " CF 已发放至个人CF账户。");
             }
         }
 
@@ -160,7 +160,7 @@ public class ProblemCompletionService {
             }
         }
 
-        log.info("问题域完成奖励已发放: familyId={}, domainCode={}, reward={}",
-                familyId, domain.getCode(), reward);
+        log.info("问题域完成奖励已发放: familyId={}, memberId={}, domainCode={}, reward={}",
+                familyId, rewardMemberId, domain.getCode(), reward);
     }
 }

+ 6 - 14
cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java

@@ -91,10 +91,7 @@ public class TaskService implements TaskServiceInterface {
     private TaskTemplateMapper taskTemplateMapper;
 
     @Resource
-    private PlatformPointsService platformPointsService;
-
-    @Resource
-    private FamilyPlatformPointsService familyPlatformPointsService;
+    private CfPathwayEarnService cfPathwayEarnService;
 
     private static final int EARLY_BONUS = 1; // 提前完成奖励
     private static final int PENALTY_MAX_DAILY = 5; // 每日最多扣分
@@ -983,9 +980,10 @@ public List<Task> getTodayTasks(Long memberId, String dimensionCode) {
             log.warn("成长任务进度更新失败: memberId={}, error={}", memberId, e.getMessage());
         }
 
-        // D5: 打卡完成发放 CF 值(用户级 + 家庭级)。
-        // earn() 按 (id, ref_type, ref_id) 幂等去重,审核重复触发不会双记。
-        // CF 额度取任务基础积分,不含连胜里程碑加成与迟到惩罚,避免奖励被放大/倒挂。
+        // D5: 打卡完成发放个人 CF 值(F22 统一入口)。
+        // earnByRule 按 (member_id, task_complete, task_id) 幂等去重,审核重复触发不会双记。
+        // CF 额度取任务配置(当前 tasks.points),不含连胜里程碑加成与迟到惩罚,避免奖励被放大/倒挂。
+        // 家庭池不再承接 CF 值:原个人 + 家庭池双发已收敛为个人成员账本单发。
         try {
             int cfAmount = task.getPoints() != null && task.getPoints() > 0 ? task.getPoints() : 0;
             // 注册/绑定推荐人为引流类任务,不发可提现的CF值(积分照发,防止刷CF)
@@ -993,13 +991,7 @@ public List<Task> getTodayTasks(Long memberId, String dimensionCode) {
                 cfAmount = 0;
             }
             if (cfAmount > 0) {
-                String remark = "完成任务: " + task.getTitle();
-                if (child != null && child.getUserId() != null) {
-                    platformPointsService.earn(child.getUserId(), cfAmount, "task_complete", taskId, remark);
-                }
-                if (task.getFamilyId() != null) {
-                    familyPlatformPointsService.earn(task.getFamilyId(), cfAmount, "task_complete", taskId, remark);
-                }
+                cfPathwayEarnService.earnTaskCompletionCf(memberId, taskId, cfAmount, task.getTitle());
             }
         } catch (Exception e) {
             log.warn("CF值发放失败(不影响积分): taskId={}, error={}", taskId, e.getMessage());

+ 31 - 19
cfc-backend/src/main/java/com/etotem/cfc/service/UnlockGateService.java

@@ -55,7 +55,7 @@ public class UnlockGateService {
     private EnergyService energyService;
 
     @Resource
-    private PlatformPointsService platformPointsService;
+    private CfPathwayEarnService cfPathwayEarnService;
 
     // ========== 核心判定 ==========
 
@@ -294,31 +294,22 @@ public class UnlockGateService {
             log.info("关卡[{}]通关无触发用户,跳过奖励发放: familyId={}", gate.getName(), familyId);
             return;
         }
-        // CF值发放(平台积分,走 PlatformPointsService)
-        if (gate.getRewardPoints() != null && gate.getRewardPoints() > 0) {
+        // 触发用户 → 家庭成员(CF 值记账维度),F21 统一入口发放:refId=关卡ID,数量取自关卡配置
+        Long rewardMemberId = resolveMemberId(triggerUserId, familyId);
+        // CF值发放(F21 统一入口:unlock_gate → 成员个人账本)
+        if (gate.getRewardPoints() != null && gate.getRewardPoints() > 0 && rewardMemberId != null) {
             try {
-                platformPointsService.earn(triggerUserId, gate.getRewardPoints(),
-                        "unlock_gate", gate.getId(), "通关奖励: " + gate.getName());
-                log.info("关卡[{}]通关奖励CF值+{}: userId={}", gate.getName(), gate.getRewardPoints(), triggerUserId);
+                cfPathwayEarnService.earnUnlockGateCf(rewardMemberId, gate.getId(), gate.getRewardPoints(), gate.getName());
+                log.info("关卡[{}]通关奖励CF值+{}: memberId={}", gate.getName(), gate.getRewardPoints(), rewardMemberId);
             } catch (Exception e) {
-                log.warn("关卡CF值奖励发放失败: userId={}, gateId={}, error={}",
-                        triggerUserId, gate.getId(), e.getMessage());
+                log.warn("关卡CF值奖励发放失败: memberId={}, gateId={}, error={}",
+                        rewardMemberId, gate.getId(), e.getMessage());
             }
         }
         // 能量发放:家长直接用 userId,孩子映射到 FamilyMember.id(EnergyService 的 memberId 语义)
         if (gate.getRewardEnergy() != null && gate.getRewardEnergy() > 0) {
-            Long energyMemberId = triggerUserId;
+            Long energyMemberId = rewardMemberId != null ? rewardMemberId : triggerUserId;
             try {
-                if (familyId != null) {
-                    FamilyMember fm = familyMemberMapper.selectOne(
-                            new LambdaQueryWrapper<FamilyMember>()
-                                    .eq(FamilyMember::getFamilyId, familyId)
-                                    .eq(FamilyMember::getUserId, triggerUserId)
-                                    .last("LIMIT 1"));
-                    if (fm != null) {
-                        energyMemberId = fm.getId();
-                    }
-                }
                 energyService.awardEnergy(energyMemberId, "unlock_gate", gate.getId(),
                         gate.getRewardEnergy(), "通关奖励: " + gate.getName(), null);
                 log.info("关卡[{}]通关奖励能量+{}: memberId={}", gate.getName(), gate.getRewardEnergy(), energyMemberId);
@@ -329,6 +320,27 @@ public class UnlockGateService {
         }
     }
 
+    /** triggerUserId(users.id)→ 家庭成员ID;优先取家庭内档案,无家庭时按用户取第一份档案 */
+    private Long resolveMemberId(Long triggerUserId, Long familyId) {
+        if (triggerUserId == null) {
+            return null;
+        }
+        try {
+            LambdaQueryWrapper<FamilyMember> wrapper = new LambdaQueryWrapper<FamilyMember>()
+                    .eq(FamilyMember::getUserId, triggerUserId)
+                    .orderByAsc(FamilyMember::getId);
+            if (familyId != null) {
+                wrapper.eq(FamilyMember::getFamilyId, familyId);
+            }
+            FamilyMember member = familyMemberMapper.selectOne(wrapper.last("LIMIT 1"));
+            return member == null ? null : member.getId();
+        } catch (Exception e) {
+            log.warn("解析关卡奖励成员失败: userId={}, familyId={}, error={}",
+                    triggerUserId, familyId, e.getMessage());
+            return null;
+        }
+    }
+
     // ========== 状态查询 ==========
 
     /**

+ 161 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/CfLegacyLedgerWiringTest.java

@@ -0,0 +1,161 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.controller.CfTransferController;
+import com.etotem.cfc.entity.ProblemDomain;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.entity.TaskPlanInstance;
+import com.etotem.cfc.entity.UserIntent;
+import com.etotem.cfc.entity.UserNotification;
+import com.etotem.cfc.mapper.ProblemDomainMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.mapper.TaskPlanInstanceMapper;
+import com.etotem.cfc.mapper.UserIntentMapper;
+import com.etotem.cfc.mapper.UserNotificationMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+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.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * F-3c 旧账本移除的结构性守卫 + F20 行为测试:
+ * <ul>
+ *   <li>F20 问题域完成:改投个人成员账本,不再写家庭池(字段级守卫 + checkAndRewardByTask 行为);</li>
+ *   <li>F21 关卡通关:改投个人成员账本,不再写平台积分;</li>
+ *   <li>F23 转让控制器只依赖 CfTransferService,不再内联操作旧 PlatformPointsService。</li>
+ * </ul>
+ */
+class CfLegacyLedgerWiringTest {
+
+    private static Set<String> fieldNames(Class<?> type) {
+        return Arrays.stream(type.getDeclaredFields()).map(Field::getName).collect(Collectors.toSet());
+    }
+
+    // ---------- F20 问题域完成 ----------
+
+    @Mock
+    private ProblemDomainMapper problemDomainMapper;
+    @Mock
+    private TaskMapper taskMapper;
+    @Mock
+    private TaskPlanInstanceMapper planInstanceMapper;
+    @Mock
+    private CfPathwayEarnService cfPathwayEarnService;
+    @Mock
+    private UserNotificationMapper userNotificationMapper;
+    @Mock
+    private NotificationDispatchService notificationDispatchService;
+    @Mock
+    private UserIntentMapper userIntentMapper;
+
+    @InjectMocks
+    private ProblemCompletionService problemCompletionService;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        for (Class<?> entity : new Class<?>[]{Task.class, ProblemDomain.class, TaskPlanInstance.class,
+                UserIntent.class, UserNotification.class}) {
+            TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), entity);
+        }
+    }
+
+    @Test
+    void F20_问题域服务_改投个人成员账本_不再持有家庭池依赖() {
+        Set<String> fields = fieldNames(ProblemCompletionService.class);
+        assertFalse(fields.contains("familyPlatformPointsService"),
+                "ProblemCompletionService 不应再持有家庭池账本字段 familyPlatformPointsService(F20 改投个人账本)");
+        assertTrue(fields.contains("cfPathwayEarnService"), "ProblemCompletionService 应持有 CfPathwayEarnService");
+    }
+
+    @Test
+    void F20_契约任务全部完成_发奖到任务所属成员账本() {
+        Task task = new Task();
+        task.setId(880L);
+        task.setFamilyId(10L);
+        task.setFamilyMemberId(7L);
+
+        ProblemDomain domain = new ProblemDomain();
+        domain.setId(500L);
+        domain.setCode("NUM");
+        domain.setName("数感启蒙");
+        domain.setStatus(1);
+        domain.setRewardCfValue(66);
+        domain.setTaskTemplatePackageIds("1,2");
+
+        TaskPlanInstance instance = new TaskPlanInstance();
+        instance.setId(31L);
+        instance.setFamilyId(10L);
+        instance.setPackageId(1L);
+
+        Task planTask = new Task();
+        planTask.setStatus("completed");
+
+        when(taskMapper.selectById(880L)).thenReturn(task);
+        when(problemDomainMapper.selectList(any())).thenReturn(new ArrayList<>(Arrays.asList(domain)));
+        when(planInstanceMapper.selectList(any())).thenReturn(new ArrayList<>(Arrays.asList(instance)));
+        when(taskMapper.selectList(any())).thenReturn(new ArrayList<>(Arrays.asList(planTask)));
+        when(userIntentMapper.selectList(any())).thenReturn(new ArrayList<>());
+        when(userNotificationMapper.selectCount(any())).thenReturn(0L);
+
+        problemCompletionService.checkAndRewardByTask(880L);
+
+        // 奖励发到完成任务所属 family_members.id,而非家庭池
+        verify(cfPathwayEarnService).earnProblemDomainCf(eq(7L), eq(500L), eq(66), eq("数感启蒙"));
+    }
+
+    /// ---------- F21 关卡通关 ----------
+
+    @Test
+    void F21_关卡服务_改投个人成员账本_不再持有平台积分依赖() {
+        Set<String> fields = fieldNames(UnlockGateService.class);
+        assertFalse(fields.contains("platformPointsService"),
+                "UnlockGateService 不应再持有平台积分字段 platformPointsService(F21 改投个人账本)");
+        assertTrue(fields.contains("cfPathwayEarnService"), "UnlockGateService 应持有 CfPathwayEarnService");
+    }
+
+    // ---------- F23 转让控制器 ----------
+
+    @Test
+    void F23_转让控制器_只依赖CfTransferService_不内联操作旧账本() {
+        Set<String> fields = fieldNames(CfTransferController.class);
+        assertTrue(fields.contains("cfTransferService"));
+        assertFalse(fields.contains("platformPointsService"),
+                "CfTransferController 不应直接持有 PlatformPointsService(转账改为 CfTransferService 同事务)");
+        assertFalse(fields.contains("cfTransferRecordMapper"),
+                "CfTransferController 不应直接操作 cf_transfer_record(流水由 CfTransferService 维护)");
+    }
+
+    // ---------- 通用:无遗留引用 ----------
+
+    @Test
+    void 本批次接入服务_不再引用旧三套账本() {
+        Set<String> legacyFields = new HashSet<>(
+                Arrays.asList("platformPointsService", "familyPlatformPointsService", "userPlatformPointsService"));
+        for (Class<?> type : new Class<?>[]{ProblemCompletionService.class, UnlockGateService.class,
+                TaskService.class, CfPathwayEarnService.class, CfTransferController.class}) {
+            Set<String> fields = fieldNames(type);
+            for (String legacy : legacyFields) {
+                assertFalse(fields.contains(legacy),
+                        type.getSimpleName() + " 不应持有旧账本字段 " + legacy);
+            }
+        }
+    }
+}

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

@@ -428,4 +428,145 @@ class CfPathwayEarnServiceTest {
 
         assertNull(r.getRefId());
     }
+
+    // ---------- F20 问题域全部任务完成 ----------
+
+    @Test
+    void F20_问题域完成_走统一入口_refId为问题域ID() {
+        when(cfBalanceService.earnByRule(eq(7L), eq("F20"), eq(500L), any()))
+                .thenReturn(earned("F20", "problem_domain", 66, false));
+
+        CfEarnResult r = service.earnProblemDomainCf(7L, 500L, 66, "数感启蒙");
+
+        assertTrue(Boolean.TRUE.equals(r.getEarned()));
+        assertEquals("problem_domain", r.getRefType());
+        Map<String, Object> ctx = capturedCtx();
+        assertEquals(66, ctx.get("amount"));
+        assertEquals(500L, ctx.get("domain_id"));
+        assertEquals("完成问题域「数感启蒙」全部任务奖励", ctx.get("remark"));
+    }
+
+    @Test
+    void F20_成员或问题域无效_不发() {
+        CfEarnResult r = service.earnProblemDomainCf(null, 500L, 66, "数感启蒙");
+
+        assertFalse(Boolean.TRUE.equals(r.getEarned()));
+        assertNotNull(r.getReason());
+        verify(cfBalanceService, never()).earnByRule(any(Long.class), any(String.class), any(Long.class), any());
+    }
+
+    @Test
+    void F20_奖励数量非正_不发() {
+        CfEarnResult r = service.earnProblemDomainCf(7L, 500L, 0, "数感启蒙");
+
+        assertFalse(Boolean.TRUE.equals(r.getEarned()));
+        verify(cfBalanceService, never()).earnByRule(any(Long.class), any(String.class), any(Long.class), any());
+    }
+
+    // ---------- F21 关卡通关 ----------
+
+    @Test
+    void F21_关卡通关_走统一入口_refId为关卡ID() {
+        when(cfBalanceService.earnByRule(eq(7L), eq("F21"), eq(900L), any()))
+                .thenReturn(earned("F21", "unlock_gate", 12, false));
+
+        CfEarnResult r = service.earnUnlockGateCf(7L, 900L, 12, "第一章");
+
+        assertTrue(Boolean.TRUE.equals(r.getEarned()));
+        assertEquals("unlock_gate", r.getRefType());
+        Map<String, Object> ctx = capturedCtx();
+        assertEquals(12, ctx.get("amount"));
+        assertEquals(900L, ctx.get("gate_id"));
+        assertEquals("通关奖励: 第一章", ctx.get("remark"));
+    }
+
+    @Test
+    void F21_奖励数量非正_不发() {
+        CfEarnResult r = service.earnUnlockGateCf(7L, 900L, 0, "第一章");
+
+        assertFalse(Boolean.TRUE.equals(r.getEarned()));
+        verify(cfBalanceService, never()).earnByRule(any(Long.class), any(String.class), any(Long.class), any());
+    }
+
+    // ---------- F22 任务完成 CF ----------
+
+    @Test
+    void F22_任务完成_走统一入口_refId为任务ID() {
+        when(cfBalanceService.earnByRule(eq(7L), eq("F22"), eq(880L), any()))
+                .thenReturn(earned("F22", "task_complete", 3, false));
+
+        CfEarnResult r = service.earnTaskCompletionCf(7L, 880L, 3, "晨读打卡");
+
+        assertTrue(Boolean.TRUE.equals(r.getEarned()));
+        assertEquals("task_complete", r.getRefType());
+        Map<String, Object> ctx = capturedCtx();
+        assertEquals(3, ctx.get("amount"));
+        assertEquals(880L, ctx.get("task_id"));
+        assertEquals("完成任务: 晨读打卡", ctx.get("remark"));
+    }
+
+    @Test
+    void F22_任务或数量无效_不发() {
+        CfEarnResult r = service.earnTaskCompletionCf(7L, null, 3, "晨读打卡");
+
+        assertFalse(Boolean.TRUE.equals(r.getEarned()));
+        verify(cfBalanceService, never()).earnByRule(any(Long.class), any(String.class), any(Long.class), any());
+    }
+
+    // ---------- F23 成员间转让(收款方) ----------
+
+    @Test
+    void F23_收款方入账_refId为转让流水ID_数量取转让金额() {
+        when(cfEarnRuleService.getByCode("F23"))
+                .thenReturn(rule("F23", "per_item", null, null,
+                        "{\"direction\":\"transfer_in\",\"pair_type\":\"transfer_out\"}", "cf_transfer"));
+        when(cfBalanceService.earnByRule(eq(8L), eq("F23"), eq(777L), any()))
+                .thenReturn(earned("F23", "cf_transfer", 50, false));
+
+        CfEarnResult r = service.earnMemberTransferCf(8L, 777L, 50);
+
+        assertTrue(Boolean.TRUE.equals(r.getEarned()));
+        assertEquals(50, r.getAmount());
+        // 转出方 spend 与收款方 earn 共用同一 refType,保证两侧流水同口径
+        assertEquals("cf_transfer", service.transferRefType());
+        Map<String, Object> ctx = capturedCtx();
+        assertEquals(50, ctx.get("amount"));
+        assertEquals(777L, ctx.get("transfer_ref_id"));
+    }
+
+    @Test
+    void F23_转让参数无效_不发() {
+        CfEarnResult r = service.earnMemberTransferCf(null, 777L, 50);
+
+        assertFalse(Boolean.TRUE.equals(r.getEarned()));
+        verify(cfBalanceService, never()).earnByRule(any(Long.class), any(String.class), any(Long.class), any());
+    }
+
+    // ---------- F24 分享奖励(推荐注册) ----------
+
+    @Test
+    void F24_推荐绑定成功_refId为被推荐用户ID() {
+        when(cfEarnRuleService.getByCode("F24"))
+                .thenReturn(rule("F24", "fixed", 20, null, "{\"trigger\":\"referral_register\"}", "referral_register"));
+        when(cfBalanceService.earnByRule(eq(6L), eq("F24"), eq(99L), any()))
+                .thenReturn(earned("F24", "referral_register", 20, false));
+
+        CfEarnResult r = service.earnReferralRegistrationCf(6L, 99L);
+
+        assertTrue(Boolean.TRUE.equals(r.getEarned()));
+        assertEquals(20, r.getAmount());
+        assertEquals("referral_register", r.getRefType());
+        Map<String, Object> ctx = capturedCtx();
+        // 数量由 fixed 规则 amount 决定(建议 20 CF/人),途径代码不得传 amount
+        assertNull(ctx.get("amount"));
+        assertEquals(99L, ctx.get("referred_user_id"));
+    }
+
+    @Test
+    void F24_推荐人或被推荐用户无效_不发() {
+        CfEarnResult r = service.earnReferralRegistrationCf(6L, null);
+
+        assertFalse(Boolean.TRUE.equals(r.getEarned()));
+        verify(cfBalanceService, never()).earnByRule(any(Long.class), any(String.class), any(Long.class), any());
+    }
 }

+ 191 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/CfTransferServiceTest.java

@@ -0,0 +1,191 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.dto.CfEarnResult;
+import com.etotem.cfc.entity.CfTransferRecord;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.CfTransferRecordMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+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.transaction.annotation.Transactional;
+
+import java.lang.reflect.Method;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+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.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;
+
+/**
+ * F-3c 成员间转让(F23)单元测试:
+ * <ul>
+ *   <li>同家庭校验 + 用户/成员档案解析;</li>
+ *   <li>转出方走 {@link CfBalanceService#spend}、收款方走
+ *       {@link CfPathwayEarnService#earnMemberTransferCf},两侧共用同一转让 refId 与同一 refType;</li>
+ *   <li>收款方入账失败 → 抛异常且不写转让流水(方法级事务回滚转/入两侧账本);</li>
+ *   <li>{@code transfer} 方法级 {@code @Transactional(rollbackFor=Exception.class)} 静态断言。</li>
+ * </ul>
+ */
+class CfTransferServiceTest {
+
+    @Mock
+    private UserMapper userMapper;
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+    @Mock
+    private CfBalanceService cfBalanceService;
+    @Mock
+    private CfPathwayEarnService cfPathwayEarnService;
+    @Mock
+    private CfTransferRecordMapper cfTransferRecordMapper;
+
+    @InjectMocks
+    private CfTransferService service;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), User.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), FamilyMember.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), CfTransferRecord.class);
+        when(cfPathwayEarnService.transferRefType()).thenReturn("cf_transfer");
+    }
+
+    private User user(Long id, Long familyId) {
+        User u = new User();
+        u.setId(id);
+        u.setFamilyId(familyId);
+        u.setNickname("成员" + id);
+        return u;
+    }
+
+    private FamilyMember member(Long id, Long userId, Long familyId) {
+        FamilyMember m = new FamilyMember();
+        m.setId(id);
+        m.setUserId(userId);
+        m.setFamilyId(familyId);
+        return m;
+    }
+
+    private CfEarnResult earned(boolean earned, int amount) {
+        CfEarnResult r = new CfEarnResult();
+        r.setRuleCode("F23");
+        r.setRefType("cf_transfer");
+        r.setEarned(earned);
+        r.setAmount(amount);
+        return r;
+    }
+
+    @Test
+    void F23_同家庭转让_转出入账共用同一refId与refType_并写流水() {
+        when(userMapper.selectById(1L)).thenReturn(user(1L, 10L));
+        when(userMapper.selectById(2L)).thenReturn(user(2L, 10L));
+        when(familyMemberMapper.selectOne(any())).thenReturn(member(11L, 1L, 10L), member(22L, 2L, 10L));
+        when(cfPathwayEarnService.earnMemberTransferCf(eq(22L), anyLong(), eq(50)))
+                .thenReturn(earned(true, 50));
+
+        String result = service.transfer(1L, 2L, 50);
+
+        assertEquals("转让成功", result);
+        // 转出方走 spend:成员账本扣减,refType 与收款方一致
+        ArgumentCaptor<Long> spendRefId = ArgumentCaptor.forClass(Long.class);
+        verify(cfBalanceService).spend(eq(11L), eq(50), eq("cf_transfer"), spendRefId.capture(), anyString());
+        Long refId = spendRefId.getValue();
+        assertNotNull(refId);
+        assertTrue(refId > 0);
+        // 收款方走统一入口 F23,与转出方共用同一 refId
+        verify(cfPathwayEarnService).earnMemberTransferCf(eq(22L), eq(refId), eq(50));
+        // 业务流水记录同事务写入
+        ArgumentCaptor<CfTransferRecord> recordCaptor = ArgumentCaptor.forClass(CfTransferRecord.class);
+        verify(cfTransferRecordMapper).insert(recordCaptor.capture());
+        CfTransferRecord record = recordCaptor.getValue();
+        assertEquals(1L, record.getFromUserId());
+        assertEquals(2L, record.getToUserId());
+        assertEquals(10L, record.getFamilyId());
+        assertEquals(Integer.valueOf(50), record.getAmount());
+        assertEquals("transfer", record.getType());
+        assertEquals("cf_transfer", record.getRefType());
+        assertEquals(refId, record.getRefId());
+    }
+
+    @Test
+    void F23_金额非正_拒绝且不记账() {
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+                () -> service.transfer(1L, 2L, 0));
+        assertTrue(e.getMessage().contains("正数"));
+        verify(cfBalanceService, never()).spend(anyLong(), any(Integer.class), anyString(), anyLong(), anyString());
+        verify(cfPathwayEarnService, never()).earnMemberTransferCf(anyLong(), anyLong(), any(Integer.class));
+        verify(cfTransferRecordMapper, never()).insert(any());
+    }
+
+    @Test
+    void F23_跨家庭转让_拒绝() {
+        when(userMapper.selectById(1L)).thenReturn(user(1L, 10L));
+        when(userMapper.selectById(2L)).thenReturn(user(2L, 20L));
+
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+                () -> service.transfer(1L, 2L, 50));
+        assertTrue(e.getMessage().contains("同一家庭"));
+        verify(cfBalanceService, never()).spend(anyLong(), any(Integer.class), anyString(), anyLong(), anyString());
+        verify(cfPathwayEarnService, never()).earnMemberTransferCf(anyLong(), anyLong(), any(Integer.class));
+    }
+
+    @Test
+    void F23_收款方入账失败_抛异常且不写转让流水() {
+        when(userMapper.selectById(1L)).thenReturn(user(1L, 10L));
+        when(userMapper.selectById(2L)).thenReturn(user(2L, 10L));
+        when(familyMemberMapper.selectOne(any())).thenReturn(member(11L, 1L, 10L), member(22L, 2L, 10L));
+        when(cfPathwayEarnService.earnMemberTransferCf(eq(22L), anyLong(), eq(50)))
+                .thenReturn(earned(false, 50));
+
+        IllegalStateException e = assertThrows(IllegalStateException.class,
+                () -> service.transfer(1L, 2L, 50));
+        assertTrue(e.getMessage().contains("收款方"));
+        // 记账回滚 → 业务流水记录不落库
+        verify(cfTransferRecordMapper, never()).insert(any());
+    }
+
+    @Test
+    void F23_缺成员档案_拒绝() {
+        when(userMapper.selectById(1L)).thenReturn(user(1L, 10L));
+        when(userMapper.selectById(2L)).thenReturn(user(2L, 10L));
+        when(familyMemberMapper.selectOne(any())).thenReturn(member(11L, 1L, 10L), null);
+
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+                () -> service.transfer(1L, 2L, 50));
+        assertTrue(e.getMessage().contains("档案"));
+        verify(cfBalanceService, never()).spend(anyLong(), any(Integer.class), anyString(), anyLong(), anyString());
+        verify(cfTransferRecordMapper, never()).insert(any());
+    }
+
+    @Test
+    void F23_方法级事务_转出入账同生同灭() throws Exception {
+        Method transfer = CfTransferService.class.getDeclaredMethod("transfer", Long.class, Long.class, int.class);
+        Transactional tx = transfer.getAnnotation(Transactional.class);
+        assertNotNull(tx, "transfer 必须声明 @Transactional,保证转出扣减/收款入账/流水记录原子性");
+        assertFalse(tx.rollbackFor().length == 0, "rollbackFor 必须显式覆盖异常类型");
+        for (Class<? extends Throwable> t : tx.rollbackFor()) {
+            if (t == Exception.class) {
+                return;
+            }
+        }
+        throw new AssertionError("rollbackFor 未覆盖 Exception.class");
+    }
+}

+ 173 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/CommissionServiceReferralF24Test.java

@@ -0,0 +1,173 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.dto.CfEarnResult;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+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 java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+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-3c F24 分享奖励(推荐注册)单元测试:
+ * <ul>
+ *   <li>推荐绑定成功处发放:refId = 被推荐用户ID,发放对象 = 推荐人的家庭成员档案;</li>
+ *   <li>推荐人无家庭成员档案 → 跳过发放,不阻断绑定;</li>
+ *   <li>发放异常 → 不阻断推荐绑定主流程(其余副作用照常执行)。</li>
+ * </ul>
+ */
+class CommissionServiceReferralF24Test {
+
+    @Mock
+    private UserMapper userMapper;
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+    @Mock
+    private CfReferralService cfReferralService;
+    @Mock
+    private CfPathwayEarnService cfPathwayEarnService;
+    @Mock
+    private OnboardingService onboardingService;
+    @Mock
+    private InviteMilestoneService inviteMilestoneService;
+    @Mock
+    private ReferralLeaderboardService referralLeaderboardService;
+
+    @InjectMocks
+    private CommissionService commissionService;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), User.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), FamilyMember.class);
+    }
+
+    private User user(Long id, String referralCode) {
+        User u = new User();
+        u.setId(id);
+        u.setNickname("用户" + id);
+        u.setReferralCode(referralCode);
+        u.setDirectCount(0);
+        return u;
+    }
+
+    private FamilyMember member(Long id, Long userId) {
+        FamilyMember m = new FamilyMember();
+        m.setId(id);
+        m.setUserId(userId);
+        return m;
+    }
+
+    private CfEarnResult earned() {
+        CfEarnResult r = new CfEarnResult();
+        r.setRuleCode("F24");
+        r.setRefType("referral_register");
+        r.setEarned(true);
+        r.setAmount(20);
+        return r;
+    }
+
+    @Test
+    void F24_按用户ID绑定成功_给推荐人成员发放分享奖励() {
+        User referred = user(3L, "C3");
+        User referrer = user(9L, "C9");
+        when(userMapper.selectById(3L)).thenReturn(referred);
+        when(userMapper.selectById(9L)).thenReturn(referrer);
+        when(familyMemberMapper.selectOne(any())).thenReturn(member(91L, 9L));
+        when(cfPathwayEarnService.earnReferralRegistrationCf(eq(91L), eq(3L))).thenReturn(earned());
+
+        commissionService.bindReferrerByUserId(3L, 9L);
+
+        verify(cfPathwayEarnService).earnReferralRegistrationCf(91L, 3L);
+        // 绑定关系已落库
+        ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
+        verify(userMapper, org.mockito.Mockito.atLeastOnce()).updateById(userCaptor.capture());
+        List<User> updated = userCaptor.getAllValues();
+        assertTrue(updated.stream().anyMatch(u -> Long.valueOf(3L).equals(u.getId())
+                && Long.valueOf(9L).equals(u.getReferrerId())), "被推荐人应写入推荐人ID");
+    }
+
+    @Test
+    void F24_邀请码绑定成功_同样发放分享奖励() {
+        User referred = user(3L, "C3");
+        User referrer = user(9L, "C9");
+        when(userMapper.selectById(3L)).thenReturn(referred);
+        when(userMapper.selectOne(any())).thenReturn(referrer);
+        when(familyMemberMapper.selectOne(any())).thenReturn(member(91L, 9L));
+        when(cfPathwayEarnService.earnReferralRegistrationCf(eq(91L), eq(3L))).thenReturn(earned());
+
+        commissionService.bindReferral(3L, "C9");
+
+        verify(cfPathwayEarnService).earnReferralRegistrationCf(91L, 3L);
+    }
+
+    @Test
+    void F24_推荐人无成员档案_跳过发放但绑定成功() {
+        User referred = user(3L, "C3");
+        User referrer = user(9L, "C9");
+        when(userMapper.selectById(3L)).thenReturn(referred);
+        when(userMapper.selectById(9L)).thenReturn(referrer);
+        when(familyMemberMapper.selectOne(any())).thenReturn(null);
+
+        commissionService.bindReferrerByUserId(3L, 9L);
+
+        verify(cfPathwayEarnService, never()).earnReferralRegistrationCf(any(), any());
+        ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
+        verify(userMapper, org.mockito.Mockito.atLeastOnce()).updateById(userCaptor.capture());
+        assertTrue(userCaptor.getAllValues().stream()
+                .anyMatch(u -> Long.valueOf(3L).equals(u.getId()) && Long.valueOf(9L).equals(u.getReferrerId())));
+    }
+
+    @Test
+    void F24_发放异常_不阻断推荐绑定主流程() {
+        User referred = user(3L, "C3");
+        User referrer = user(9L, "C9");
+        when(userMapper.selectById(3L)).thenReturn(referred);
+        when(userMapper.selectById(9L)).thenReturn(referrer);
+        when(familyMemberMapper.selectOne(any())).thenReturn(member(91L, 9L));
+        when(cfPathwayEarnService.earnReferralRegistrationCf(eq(91L), eq(3L)))
+                .thenThrow(new IllegalStateException("CF 账本不可用"));
+
+        assertDoesNotThrow(() -> commissionService.bindReferrerByUserId(3L, 9L));
+
+        // 绑定后的其他副作用照常执行
+        verify(onboardingService).completeTask(3L, "BIND_REFERRAL");
+        verify(inviteMilestoneService).checkMilestones(9L);
+        verify(referralLeaderboardService).incrementCount(9L);
+        ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
+        verify(userMapper, org.mockito.Mockito.atLeastOnce()).updateById(userCaptor.capture());
+        assertTrue(userCaptor.getAllValues().stream()
+                .anyMatch(u -> Long.valueOf(3L).equals(u.getId()) && Long.valueOf(9L).equals(u.getReferrerId())));
+    }
+
+    @Test
+    void F24_已绑定推荐人_幂等不动不发() {
+        User referred = user(3L, "C3");
+        referred.setReferrerId(77L);
+        when(userMapper.selectById(3L)).thenReturn(referred);
+
+        commissionService.bindReferrerByUserId(3L, 9L);
+
+        verify(cfPathwayEarnService, never()).earnReferralRegistrationCf(any(), any());
+        verify(userMapper, never()).updateById(any());
+    }
+}

+ 147 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/TaskServiceCfEarnTest.java

@@ -0,0 +1,147 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.PointsLogMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+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.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+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.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * F-3c F22 任务完成 CF 单元测试:
+ * <ul>
+ *   <li>打卡完成后 CF 走 {@link CfPathwayEarnService#earnTaskCompletionCf} 统一入口(成员个人账本单发);</li>
+ *   <li>CF 数量取任务配置(当前 tasks.points),不含连胜里程碑加成与迟到惩罚;</li>
+ *   <li>注册/绑定推荐人等引流类任务不发 CF;</li>
+ *   <li>CF 发放失败不影响积分发放主流程;</li>
+ *   <li>家庭池双发已移除:TaskService 不再持有 PlatformPointsService / FamilyPlatformPointsService。</li>
+ * </ul>
+ */
+class TaskServiceCfEarnTest {
+
+    @Mock
+    private TaskMapper taskMapper;
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+    @Mock
+    private PointsLogMapper pointsLogMapper;
+    @Mock
+    private StreakService streakService;
+    @Mock
+    private EnergyService energyService;
+    @Mock
+    private CfPathwayEarnService cfPathwayEarnService;
+
+    @InjectMocks
+    private TaskService taskService;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), Task.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), FamilyMember.class);
+        when(taskMapper.selectList(any())).thenReturn(new ArrayList<>());
+    }
+
+    /** 提前完成的普通任务(deadline 在未来,CF 数量 = tasks.points) */
+    private Task task(Long id, int points, String title) {
+        Task t = new Task();
+        t.setId(id);
+        t.setPoints(points);
+        t.setTitle(title);
+        t.setDeadline(new Date(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1)));
+        return t;
+    }
+
+    private FamilyMember child(Long id, Long userId) {
+        FamilyMember m = new FamilyMember();
+        m.setId(id);
+        m.setUserId(userId); // null:未绑定登录账号的家庭成员,跳过成长任务进度更新
+        m.setTotalPoints(0);
+        m.setStreakDays(0);
+        return m;
+    }
+
+    /** 调用 TaskService 内部的发奖方法(打卡完成后发积分 + F22 CF) */
+    private int awardRewards(Task task, Long memberId) throws Exception {
+        Method method = TaskService.class.getDeclaredMethod("awardTaskCompletionRewards",
+                Task.class, Long.class, Date.class);
+        method.setAccessible(true);
+        return (int) method.invoke(taskService, task, memberId, new Date());
+    }
+
+    private static Set<String> fieldNames(Class<?> type) {
+        return Arrays.stream(type.getDeclaredFields()).map(Field::getName).collect(Collectors.toSet());
+    }
+
+    @Test
+    void F22_任务完成_只发个人成员账本一次_数量取任务配置() throws Exception {
+        when(familyMemberMapper.selectById(7L)).thenReturn(child(7L, null));
+
+        int pointsEarned = awardRewards(task(880L, 3, "晨读打卡"), 7L);
+
+        // 积分照发(含提前完成加成),CF 数量单独取任务配置
+        assertEquals(4, pointsEarned);
+        verify(cfPathwayEarnService, times(1)).earnTaskCompletionCf(eq(7L), eq(880L), eq(3), eq("晨读打卡"));
+    }
+
+    @Test
+    void F22_引流任务不发CF_避免刷可提现CF值() throws Exception {
+        when(familyMemberMapper.selectById(7L)).thenReturn(child(7L, null));
+
+        awardRewards(task(881L, 5, "REGISTER"), 7L);
+        awardRewards(task(882L, 5, "BIND_REFERRAL"), 7L);
+
+        verify(cfPathwayEarnService, never()).earnTaskCompletionCf(anyLong(), anyLong(), anyInt(), anyString());
+    }
+
+    @Test
+    void F22_CF发放失败_不影响积分发放() throws Exception {
+        when(familyMemberMapper.selectById(7L)).thenReturn(child(7L, null));
+        when(cfPathwayEarnService.earnTaskCompletionCf(anyLong(), anyLong(), anyInt(), anyString()))
+                .thenThrow(new IllegalStateException("CF 账本不可用"));
+
+        int pointsEarned = awardRewards(task(883L, 3, "晨读打卡"), 7L);
+
+        assertEquals(4, pointsEarned);
+    }
+
+    @Test
+    void F22_家庭池双发已移除_TaskService不再持有旧账本依赖() {
+        Set<String> fields = fieldNames(TaskService.class);
+        assertFalse(fields.contains("platformPointsService"),
+                "TaskService 不应再持有旧个人账本字段 platformPointsService(F22 改投 earnByRule)");
+        assertFalse(fields.contains("familyPlatformPointsService"),
+                "TaskService 不应再持有家庭池账本字段 familyPlatformPointsService(F22 取消家庭池双发)");
+        assertTrue(fields.contains("cfPathwayEarnService"), "TaskService 应持有 CfPathwayEarnService 走 F22 统一入口");
+    }
+}