Jelajahi Sumber

feat(cf): F-3b2 发放接入 F14-F18 改投统一账本

Sisyphus 2 minggu lalu
induk
melakukan
5f00aadac1

+ 6 - 5
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthMealController.java

@@ -10,7 +10,7 @@ import com.etotem.cfc.mapper.HealthMealRecordMapper;
 import com.etotem.cfc.mapper.FoodRecommendIndexMapper;
 import com.etotem.cfc.mapper.FoodRecommendIndexMapper;
 import com.etotem.cfc.mapper.TaskMapper;
 import com.etotem.cfc.mapper.TaskMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
-import com.etotem.cfc.service.PointsService;
+import com.etotem.cfc.service.CfGrowthPathwayEarnService;
 import com.etotem.cfc.service.TaskService;
 import com.etotem.cfc.service.TaskService;
 import com.etotem.cfc.service.ProfileComputeService;
 import com.etotem.cfc.service.ProfileComputeService;
 import com.etotem.cfc.util.ParamUtils;
 import com.etotem.cfc.util.ParamUtils;
@@ -39,7 +39,7 @@ public class HealthMealController {
     private FoodRecommendIndexMapper foodRecommendIndexMapper;
     private FoodRecommendIndexMapper foodRecommendIndexMapper;
 
 
     @Resource
     @Resource
-    private PointsService pointsService;
+    private CfGrowthPathwayEarnService cfGrowthPathwayEarnService;
 
 
     @Resource
     @Resource
     private TaskService taskService;
     private TaskService taskService;
@@ -288,12 +288,13 @@ public class HealthMealController {
             }
             }
         }
         }
 
 
-        // 只有非补打才发积分
+        // CF 值发放(F15 统一入口:数量取自 cf_earn_rule F15 固定值,日限按会员等级 N11;
+        // 幂等 ref_id = 饮食打卡记录ID,同一记录只发一次)。只有非补打才发放
         if (!isBackfill) {
         if (!isBackfill) {
             try {
             try {
-                pointsService.awardCheckinPoints(record.getMemberId(), 5, "饮食打卡");
+                cfGrowthPathwayEarnService.earnMealCheckinCf(record.getMemberId(), record.getId());
             } catch (Exception e) {
             } catch (Exception e) {
-                // 积分发放失败不影响打卡记录
+                log.warn("饮食打卡CF值发放失败: {}", e.getMessage());
             }
             }
         }
         }
         // 触发画像重算
         // 触发画像重算

+ 6 - 5
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthWaterController.java

@@ -15,7 +15,7 @@ import javax.annotation.Resource;
 import java.util.Date;
 import java.util.Date;
 import java.util.List;
 import java.util.List;
 import java.util.Map;
 import java.util.Map;
-import com.etotem.cfc.service.PointsService;
+import com.etotem.cfc.service.CfGrowthPathwayEarnService;
 import com.etotem.cfc.service.TaskService;
 import com.etotem.cfc.service.TaskService;
 import com.etotem.cfc.util.SortUtil;
 import com.etotem.cfc.util.SortUtil;
 import com.etotem.cfc.util.ParamUtils;
 import com.etotem.cfc.util.ParamUtils;
@@ -37,7 +37,7 @@ public class HealthWaterController {
     private com.etotem.cfc.mapper.FamilyMemberMapper familyMemberMapper;
     private com.etotem.cfc.mapper.FamilyMemberMapper familyMemberMapper;
 
 
     @Resource
     @Resource
-    private PointsService pointsService;
+    private CfGrowthPathwayEarnService cfGrowthPathwayEarnService;
 
 
     @Resource
     @Resource
     private TaskService taskService;
     private TaskService taskService;
@@ -98,11 +98,12 @@ public class HealthWaterController {
             }
             }
         }
         }
 
 
-        // 积分发放
+        // CF 值发放(F14 统一入口:数量取自 cf_earn_rule F14 固定值,日限按会员等级 N11;
+        // 幂等 ref_id = 喝水打卡记录ID,同一记录只发一次)
         try {
         try {
-            pointsService.awardCheckinPoints(record.getMemberId(), 5, "喝水打卡");
+            cfGrowthPathwayEarnService.earnWaterCheckinCf(record.getMemberId(), record.getId());
         } catch (Exception e) {
         } catch (Exception e) {
-            log.warn("喝水打卡积分发放失败: {}", e.getMessage());
+            log.warn("喝水打卡CF值发放失败: {}", e.getMessage());
         }
         }
         return Result.success(record);
         return Result.success(record);
     }
     }

+ 143 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CfGrowthPathwayEarnService.java

@@ -0,0 +1,143 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.CfEarnResult;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * CF 值发放途径接入服务(F-3b2:成长行为类剩余 5 条 F14 / F15 / F16 / F17 / F18)。
+ *
+ * <p>与 F-3b1 {@link CfPathwayEarnService} 同一收口模式:本服务只组装各途径的 refId(幂等标识)与
+ * ctx(公式变量/流水留痕),<b>不计算 CF 数量</b>——数量一律由 {@code cf_earn_rule} 决定
+ * (运营可在管理端调整),统一改投 F-2 唯一入口
+ * {@link CfBalanceService#earnByRule(Long, String, Long, Map)},不再直接写旧三套账本
+ * (family_platform_balance / family_members.system_points / user_platform_balance)。</p>
+ *
+ * <p>各途径 refType(取自规则 dedup_ref_type)互不相同,流水可追溯:</p>
+ * <ul>
+ *   <li>F14 喝水打卡 → health_water(fixed 5 CF/次,日限按会员等级 N11);</li>
+ *   <li>F15 饮食打卡 → health_meal(fixed 5 CF/次,日限按会员等级 N11);</li>
+ *   <li>F16 每日健康打卡 → daily_health_checkin(fixed 10 CF/次,日限按会员等级 N11);</li>
+ *   <li>F17 挑战赛子任务完成 → challenge_sub_task(per_item,数量取挑战任务配置 reward_cf);</li>
+ *   <li>F18 健康启航计划付费 → health_start_plan(formula floor(amt),与 F02 同口径 1 元 = 100 CF)。</li>
+ * </ul>
+ *
+ * <p>口径见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §3.2 / §3.4 / §七 F-3 行。</p>
+ */
+@Service
+public class CfGrowthPathwayEarnService {
+
+    // ==================== 规则编码(cf_earn_rule.code,迁移408 已入库 24 行种子) ====================
+
+    /** F14 喝水打卡(固定 5 CF/次,日限按会员等级 N11) */
+    public static final String RULE_F14 = "F14";
+    /** F15 饮食打卡(固定 5 CF/次,日限按会员等级 N11) */
+    public static final String RULE_F15 = "F15";
+    /** F16 每日健康打卡(固定 10 CF/次,日限按会员等级 N11) */
+    public static final String RULE_F16 = "F16";
+    /** F17 挑战赛子任务完成(per_item,数量取 progress.reward_cf) */
+    public static final String RULE_F17 = "F17";
+    /** F18 健康启航计划付费(formula floor(amt),与 F02 同口径 1 元 = 100 CF) */
+    public static final String RULE_F18 = "F18";
+
+    @Resource
+    private CfBalanceService cfBalanceService;
+
+    // ==================== F14 喝水打卡 ====================
+
+    /**
+     * F14 喝水打卡:固定 5 CF/次,日限按会员等级(N11)。
+     *
+     * <p>数量由规则 F14 决定(rule_type=fixed, amount=5),代码不写死;日限由 F-2 按
+     * {@code cf_earn_daily_limit}(FREE=1 / BASIC=3 / FAMILY=10 / LIFETIME=10 / PREMIUM=20)拦截。</p>
+     *
+     * @param memberId 家庭成员ID(family_members.id)
+     * @param recordId 喝水打卡记录ID(water_record.id,幂等标识:同一记录只发一次)
+     */
+    public CfEarnResult earnWaterCheckinCf(Long memberId, Long recordId) {
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("checkin_id", recordId);
+        ctx.put("remark", "喝水打卡");
+        return cfBalanceService.earnByRule(memberId, RULE_F14, recordId, ctx);
+    }
+
+    // ==================== F15 饮食打卡 ====================
+
+    /**
+     * F15 饮食打卡:固定 5 CF/次,日限按会员等级(N11),口径同 F14。
+     *
+     * <p>补打(isBackfill=1)不触发本方法(由调用方在打卡创建处控制,维持现有业务口径)。</p>
+     *
+     * @param memberId 家庭成员ID(family_members.id)
+     * @param recordId 饮食打卡记录ID(health_meal_record.id,幂等标识:同一记录只发一次)
+     */
+    public CfEarnResult earnMealCheckinCf(Long memberId, Long recordId) {
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("checkin_id", recordId);
+        ctx.put("remark", "饮食打卡");
+        return cfBalanceService.earnByRule(memberId, RULE_F15, recordId, ctx);
+    }
+
+    // ==================== F16 每日健康打卡 ====================
+
+    /**
+     * F16 每日健康打卡:固定 10 CF/次,日限按会员等级(N11)。
+     *
+     * <p>每日幂等(同一天同一成员只打卡一次)由调用方 {@link DailyCheckinService} 的 IdempotencyUtil
+     * 控制,F-2 幂等键 {@code (member_id, type, ref_type, ref_id)} 兜底防重复发放。</p>
+     *
+     * @param memberId  家庭成员ID(family_members.id)
+     * @param checkinId 每日健康打卡记录ID(daily_checkin.id,幂等标识)
+     */
+    public CfEarnResult earnDailyHealthCheckinCf(Long memberId, Long checkinId) {
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("checkin_id", checkinId);
+        ctx.put("remark", "每日健康打卡");
+        return cfBalanceService.earnByRule(memberId, RULE_F16, checkinId, ctx);
+    }
+
+    // ==================== F17 挑战赛子任务完成 ====================
+
+    /**
+     * F17 挑战赛子任务完成:数量 = 挑战任务配置 reward_cf(per_item,由 ctx.amount 提供)。
+     *
+     * @param memberId   家庭成员ID(family_members.id)
+     * @param progressId 挑战进度记录ID(challenge_progress.id,幂等标识:同一子任务只发一次)
+     * @param rewardCf   挑战任务配置奖励数量(challenge_progress.reward_cf,非代码硬编码值)
+     * @param taskKey    任务编码(写流水备查)
+     * @param taskTitle  任务标题(写流水备注)
+     */
+    public CfEarnResult earnChallengeSubTaskCf(Long memberId, Long progressId, Integer rewardCf,
+                                               String taskKey, String taskTitle) {
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("amount", rewardCf == null ? 0 : rewardCf);
+        ctx.put("task_key", taskKey);
+        ctx.put("remark", "挑战赛子任务完成奖励: " + (taskTitle == null ? "" : taskTitle));
+        return cfBalanceService.earnByRule(memberId, RULE_F17, progressId, ctx);
+    }
+
+    // ==================== F18 健康启航计划付费 ====================
+
+    /**
+     * F18 健康启航计划付费:数量 = {@code floor(实付分/100) × 100}(与 F02 同口径,1 元 = 100 CF)。
+     *
+     * <p>实付金额(分)由 ctx.amt 提供,规则公式 {@code floor(amt)} + {@code amt_unit=cent, cf_per_yuan=100}
+     * 换算得到 CF 数量(199 元 = 19900 分 → 19900 CF),代码不再硬编码(原 300 已删除)。
+     * refType 取规则 F18 的 dedup_ref_type(health_start_plan),幂等 ref_id = 购买订单ID,同一订单只发一次。</p>
+     *
+     * @param memberId       家庭成员ID(family_members.id)
+     * @param orderId        健康启航计划购买订单ID(purchase_orders.id,幂等标识)
+     * @param paidAmountCent 实付金额(分,purchase_orders.amount)
+     * @param orderNo        订单号(写流水备查)
+     */
+    public CfEarnResult earnHealthStartPlanCf(Long memberId, Long orderId, Integer paidAmountCent, String orderNo) {
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("amt", paidAmountCent == null ? 0 : paidAmountCent);
+        ctx.put("order_no", orderNo);
+        ctx.put("remark", "健康启航计划付费奖励");
+        return cfBalanceService.earnByRule(memberId, RULE_F18, orderId, ctx);
+    }
+}

+ 10 - 5
cfc-backend/src/main/java/com/etotem/cfc/service/DailyCheckinService.java

@@ -2,6 +2,7 @@ package com.etotem.cfc.service;
 
 
 import com.etotem.cfc.entity.DailyHealthCheckin;
 import com.etotem.cfc.entity.DailyHealthCheckin;
 import com.etotem.cfc.entity.EmotionCheckin;
 import com.etotem.cfc.entity.EmotionCheckin;
+import com.etotem.cfc.dto.CfEarnResult;
 import com.etotem.cfc.mapper.DailyCheckinMapper;
 import com.etotem.cfc.mapper.DailyCheckinMapper;
 import com.etotem.cfc.mapper.EmotionCheckinMapper;
 import com.etotem.cfc.mapper.EmotionCheckinMapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
@@ -24,7 +25,7 @@ public class DailyCheckinService {
     private DailyCheckinMapper dailyCheckinMapper;
     private DailyCheckinMapper dailyCheckinMapper;
 
 
     @Resource
     @Resource
-    private PointsService pointsService;
+    private CfGrowthPathwayEarnService cfGrowthPathwayEarnService;
 
 
     @Resource
     @Resource
     private EmotionCheckinMapper emotionCheckinMapper;
     private EmotionCheckinMapper emotionCheckinMapper;
@@ -73,12 +74,16 @@ public class DailyCheckinService {
         checkin.setUpdatedAt(new Date());
         checkin.setUpdatedAt(new Date());
         dailyCheckinMapper.insert(checkin);
         dailyCheckinMapper.insert(checkin);
 
 
-        // 打卡返积分:每日健康打卡奖励 10 积分(不影响打卡主流程)
+        // CF 值发放(F16 统一入口:数量取自 cf_earn_rule F16 固定值,日限按会员等级 N11;
+        // 幂等 ref_id = 打卡记录ID,同一记录只发一次,不影响打卡主流程)
         try {
         try {
-            pointsService.awardCheckinPoints(checkin.getMemberId(), 10, "每日健康打卡");
-            log.info("成员{}完成每日打卡,奖励10积分", checkin.getMemberId());
+            CfEarnResult earnResult = cfGrowthPathwayEarnService.earnDailyHealthCheckinCf(
+                    checkin.getMemberId(), checkin.getId());
+            if (earnResult != null && Boolean.TRUE.equals(earnResult.getEarned())) {
+                log.info("成员{}完成每日打卡,按规则 F16 奖励{}CF", checkin.getMemberId(), earnResult.getAmount());
+            }
         } catch (Exception e) {
         } catch (Exception e) {
-            log.warn("打卡积分发放失败,不影响打卡记录: {}", e.getMessage());
+            log.warn("打卡CF值发放失败,不影响打卡记录: {}", e.getMessage());
         }
         }
 
 
         // 心情打卡同步到情绪日记(如有 moodScore)
         // 心情打卡同步到情绪日记(如有 moodScore)

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

@@ -3,13 +3,17 @@ package com.etotem.cfc.service.impl;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.entity.*;
 import com.etotem.cfc.entity.*;
 import com.etotem.cfc.mapper.*;
 import com.etotem.cfc.mapper.*;
+import com.etotem.cfc.service.CfGrowthPathwayEarnService;
 import com.etotem.cfc.service.ChallengeService;
 import com.etotem.cfc.service.ChallengeService;
 import com.etotem.cfc.service.PurchaseService;
 import com.etotem.cfc.service.PurchaseService;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 
 
+import javax.annotation.Resource;
 import java.util.*;
 import java.util.*;
 
 
+@Slf4j
 @Service
 @Service
 public class ChallengeServiceImpl implements ChallengeService {
 public class ChallengeServiceImpl implements ChallengeService {
 
 
@@ -19,6 +23,7 @@ public class ChallengeServiceImpl implements ChallengeService {
     @Autowired private PurchaseOrderMapper purchaseOrderMapper;
     @Autowired private PurchaseOrderMapper purchaseOrderMapper;
     @Autowired private FamilyMembershipMapper membershipMapper;
     @Autowired private FamilyMembershipMapper membershipMapper;
     @Autowired private FamilyMapper familyMapper;
     @Autowired private FamilyMapper familyMapper;
+    @Resource private CfGrowthPathwayEarnService cfGrowthPathwayEarnService;
 
 
     @Override
     @Override
     public void initChallengeTasks(Long purchaseId, Long memberId) {
     public void initChallengeTasks(Long purchaseId, Long memberId) {
@@ -135,12 +140,15 @@ public class ChallengeServiceImpl implements ChallengeService {
         progress.setStatus("completed");
         progress.setStatus("completed");
         progress.setCompletedAt(new java.util.Date());
         progress.setCompletedAt(new java.util.Date());
         progressMapper.updateById(progress);
         progressMapper.updateById(progress);
-        
-        FamilyMember member = memberMapper.selectById(progress.getMemberId());
-        if (member != null) {
-            int currentPoints = member.getSystemPoints() != null ? member.getSystemPoints() : 0;
-            member.setSystemPoints(currentPoints + progress.getRewardCf());
-            memberMapper.updateById(member);
+
+        // CF 值发放(F17 统一入口:数量取挑战赛任务配置 progress.reward_cf;
+        // 幂等 ref_id = 挑战进度记录ID,同一子任务只发一次;不再直接写旧 system_points 账本)
+        try {
+            cfGrowthPathwayEarnService.earnChallengeSubTaskCf(
+                    progress.getMemberId(), progress.getId(), progress.getRewardCf(),
+                    progress.getTaskKey(), progress.getTaskTitle());
+        } catch (Exception e) {
+            log.warn("挑战赛子任务CF值发放失败, progressId={}: {}", progress.getId(), e.getMessage());
         }
         }
     }
     }
 
 

+ 27 - 8
cfc-backend/src/main/java/com/etotem/cfc/service/impl/PurchaseServiceImpl.java

@@ -1,20 +1,26 @@
 package com.etotem.cfc.service.impl;
 package com.etotem.cfc.service.impl;
 
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.dto.CfEarnResult;
 import com.etotem.cfc.entity.*;
 import com.etotem.cfc.entity.*;
 import com.etotem.cfc.mapper.*;
 import com.etotem.cfc.mapper.*;
+import com.etotem.cfc.service.CfGrowthPathwayEarnService;
 import com.etotem.cfc.service.PurchaseService;
 import com.etotem.cfc.service.PurchaseService;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 
 
+import javax.annotation.Resource;
 import java.util.*;
 import java.util.*;
 
 
+@Slf4j
 @Service
 @Service
 public class PurchaseServiceImpl implements PurchaseService {
 public class PurchaseServiceImpl implements PurchaseService {
 
 
     @Autowired private PurchaseOrderMapper orderMapper;
     @Autowired private PurchaseOrderMapper orderMapper;
     @Autowired private FamilyMembershipMapper membershipMapper;
     @Autowired private FamilyMembershipMapper membershipMapper;
     @Autowired private FamilyMemberMapper memberMapper;
     @Autowired private FamilyMemberMapper memberMapper;
+    @Resource private CfGrowthPathwayEarnService cfGrowthPathwayEarnService;
 
 
     @Override
     @Override
     public Map<String, Object> checkPurchase(Long userId) {
     public Map<String, Object> checkPurchase(Long userId) {
@@ -106,21 +112,34 @@ public class PurchaseServiceImpl implements PurchaseService {
         order.setPaidAt(new Date());
         order.setPaidAt(new Date());
         orderMapper.updateById(order);
         orderMapper.updateById(order);
         
         
-        int cfReward = 300;
-        order.setCfRewardGiven(cfReward);
-        
+        // CF 值发放(F18 统一入口:数量按规则公式 floor(实付分/100)×100,与 F02 同口径 1 元=100CF;
+        // ref_type 取规则 dedup_ref_type,硬编码数量(原 300)已删除;幂等 ref_id = 购买订单ID)
+        int cfReward = 0;
         LambdaQueryWrapper<FamilyMember> mqw = new LambdaQueryWrapper<>();
         LambdaQueryWrapper<FamilyMember> mqw = new LambdaQueryWrapper<>();
         mqw.eq(FamilyMember::getFamilyId, order.getFamilyId())
         mqw.eq(FamilyMember::getFamilyId, order.getFamilyId())
            .eq(FamilyMember::getUserId, order.getUserId())
            .eq(FamilyMember::getUserId, order.getUserId())
            .last("LIMIT 1");
            .last("LIMIT 1");
         FamilyMember member = memberMapper.selectOne(mqw);
         FamilyMember member = memberMapper.selectOne(mqw);
-        
+
         if (member != null) {
         if (member != null) {
-            int currentPoints = member.getSystemPoints() != null ? member.getSystemPoints() : 0;
-            member.setSystemPoints(currentPoints + cfReward);
-            memberMapper.updateById(member);
+            try {
+                CfEarnResult earnResult = cfGrowthPathwayEarnService.earnHealthStartPlanCf(
+                        member.getId(), order.getId(), order.getAmount(), order.getOrderNo());
+                if (earnResult != null && Boolean.TRUE.equals(earnResult.getEarned())
+                        && earnResult.getAmount() != null) {
+                    cfReward = earnResult.getAmount();
+                }
+            } catch (Exception e) {
+                log.warn("健康启航计划CF值发放失败, orderId={}: {}", order.getId(), e.getMessage());
+            }
+        } else {
+            log.warn("健康启航计划CF值发放跳过:未找到购买人对应的家庭成员, orderId={}", order.getId());
         }
         }
-        
+
+        // 记录本次实际发放数量(规则计算结果,未发放记 0)
+        order.setCfRewardGiven(cfReward);
+        orderMapper.updateById(order);
+
         result.put("success", true);
         result.put("success", true);
         result.put("orderId", order.getId());
         result.put("orderId", order.getId());
         result.put("cfReward", cfReward);
         result.put("cfReward", cfReward);

+ 231 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/HealthCheckinCfEarnCallSiteTest.java

@@ -0,0 +1,231 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.DailyHealthCheckin;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.HealthMealRecord;
+import com.etotem.cfc.entity.HealthWaterRecord;
+import com.etotem.cfc.mapper.DailyCheckinMapper;
+import com.etotem.cfc.mapper.EmotionCheckinMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.FoodRecommendIndexMapper;
+import com.etotem.cfc.mapper.HealthMealRecordMapper;
+import com.etotem.cfc.mapper.HealthWaterRecordMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.service.CfGrowthPathwayEarnService;
+import com.etotem.cfc.service.ChallengeService;
+import com.etotem.cfc.service.DailyCheckinService;
+import com.etotem.cfc.service.ProfileComputeService;
+import com.etotem.cfc.service.TaskService;
+import com.etotem.cfc.util.IdempotencyUtil;
+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.sql.Date;
+import java.time.LocalDate;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * F-3b2 健康打卡类调用点单测(F14 喝水 / F15 饮食 / F16 每日健康打卡):
+ *  - 打卡创建成功后改投统一入口 earnByRule(经 CfGrowthPathwayEarnService 收口),
+ *    数量不再由调用点传入(旧 PointsService.awardCheckinPoints(memberId, 5/10) 直写旧账本已移除);
+ *  - 幂等 ref_id 统一取打卡记录 ID(health_water_records / health_meal_records / daily_health_checkin);
+ *  - 饮食打卡补打(isBackfill=1)不发放,维持既有业务口径。
+ */
+class HealthCheckinCfEarnCallSiteTest {
+
+    private static final long MEMBER_ID = 1L;
+    private static final long USER_ID = 100L;
+
+    @Mock
+    private HealthWaterRecordMapper healthWaterRecordMapper;
+    @Mock
+    private HealthMealRecordMapper healthMealRecordMapper;
+    @Mock
+    private FoodRecommendIndexMapper foodRecommendIndexMapper;
+    @Mock
+    private TaskMapper taskMapper;
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+    @Mock
+    private TaskService taskService;
+    @Mock
+    private CfGrowthPathwayEarnService cfGrowthPathwayEarnService;
+    @Mock
+    private ProfileComputeService profileComputeService;
+    @Mock
+    private DailyCheckinMapper dailyCheckinMapper;
+    @Mock
+    private EmotionCheckinMapper emotionCheckinMapper;
+    @Mock
+    private ChallengeService challengeService;
+    @Mock
+    private IdempotencyUtil idempotencyUtil;
+
+    private HealthWaterController waterController;
+    private HealthMealController mealController;
+    private DailyCheckinService dailyCheckinService;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+
+        waterController = new HealthWaterController();
+        ReflectionTestUtils.setField(waterController, "healthWaterRecordMapper", healthWaterRecordMapper);
+        ReflectionTestUtils.setField(waterController, "taskMapper", taskMapper);
+        ReflectionTestUtils.setField(waterController, "familyMemberMapper", familyMemberMapper);
+        ReflectionTestUtils.setField(waterController, "cfGrowthPathwayEarnService", cfGrowthPathwayEarnService);
+        ReflectionTestUtils.setField(waterController, "taskService", taskService);
+
+        mealController = new HealthMealController();
+        ReflectionTestUtils.setField(mealController, "healthMealRecordMapper", healthMealRecordMapper);
+        ReflectionTestUtils.setField(mealController, "foodRecommendIndexMapper", foodRecommendIndexMapper);
+        ReflectionTestUtils.setField(mealController, "cfGrowthPathwayEarnService", cfGrowthPathwayEarnService);
+        ReflectionTestUtils.setField(mealController, "taskService", taskService);
+        ReflectionTestUtils.setField(mealController, "taskMapper", taskMapper);
+        ReflectionTestUtils.setField(mealController, "familyMemberMapper", familyMemberMapper);
+        ReflectionTestUtils.setField(mealController, "profileComputeService", profileComputeService);
+
+        dailyCheckinService = new DailyCheckinService();
+        ReflectionTestUtils.setField(dailyCheckinService, "dailyCheckinMapper", dailyCheckinMapper);
+        ReflectionTestUtils.setField(dailyCheckinService, "cfGrowthPathwayEarnService", cfGrowthPathwayEarnService);
+        ReflectionTestUtils.setField(dailyCheckinService, "emotionCheckinMapper", emotionCheckinMapper);
+        ReflectionTestUtils.setField(dailyCheckinService, "challengeService", challengeService);
+        ReflectionTestUtils.setField(dailyCheckinService, "idempotencyUtil", idempotencyUtil);
+    }
+
+    // ---------- F14 喝水打卡 ----------
+
+    @Test
+    void F14_创建喝水打卡_改投统一入口_refId为记录ID() {
+        HealthWaterRecord record = new HealthWaterRecord();
+        record.setMemberId(MEMBER_ID);
+        record.setAmountMl(500);
+        when(healthWaterRecordMapper.insert(any())).thenAnswer(inv -> {
+            HealthWaterRecord r = inv.getArgument(0);
+            r.setId(1001L);
+            return 1;
+        });
+        when(familyMemberMapper.selectById(MEMBER_ID)).thenReturn(member(MEMBER_ID, USER_ID));
+
+        Result<HealthWaterRecord> result = waterController.create(record, MEMBER_ID);
+
+        assertEquals(200, result.getCode());
+        verify(cfGrowthPathwayEarnService).earnWaterCheckinCf(MEMBER_ID, 1001L);
+    }
+
+    @Test
+    void F14_发放失败不影响打卡主流程() {
+        HealthWaterRecord record = new HealthWaterRecord();
+        record.setMemberId(MEMBER_ID);
+        when(healthWaterRecordMapper.insert(any())).thenAnswer(inv -> {
+            HealthWaterRecord r = inv.getArgument(0);
+            r.setId(1002L);
+            return 1;
+        });
+        when(familyMemberMapper.selectById(MEMBER_ID)).thenReturn(null);
+        org.mockito.Mockito.doThrow(new RuntimeException("db down"))
+                .when(cfGrowthPathwayEarnService).earnWaterCheckinCf(anyLong(), anyLong());
+
+        Result<HealthWaterRecord> result = waterController.create(record, MEMBER_ID);
+
+        assertEquals(200, result.getCode());
+        assertEquals(1002L, result.getData().getId());
+    }
+
+    // ---------- F15 饮食打卡 ----------
+
+    @Test
+    void F15_创建饮食打卡_改投统一入口_refId为记录ID() {
+        HealthMealRecord record = new HealthMealRecord();
+        record.setMemberId(MEMBER_ID);
+        record.setMealType("breakfast");
+        record.setFoodItems("苹果,牛奶");
+        when(healthMealRecordMapper.insert(any())).thenAnswer(inv -> {
+            HealthMealRecord r = inv.getArgument(0);
+            r.setId(2001L);
+            return 1;
+        });
+        when(familyMemberMapper.selectById(MEMBER_ID)).thenReturn(member(MEMBER_ID, USER_ID));
+
+        Result<HealthMealRecord> result = mealController.create(record, MEMBER_ID);
+
+        assertEquals(200, result.getCode());
+        verify(cfGrowthPathwayEarnService).earnMealCheckinCf(MEMBER_ID, 2001L);
+    }
+
+    @Test
+    void F15_补打不打积分_不发CF() {
+        HealthMealRecord record = new HealthMealRecord();
+        record.setMemberId(MEMBER_ID);
+        record.setMealType("breakfast");
+        record.setFoodItems("鸡蛋");
+        record.setIsBackfill(1);
+        record.setRecordDate(Date.valueOf(LocalDate.now()));
+        when(healthMealRecordMapper.insert(any())).thenAnswer(inv -> {
+            HealthMealRecord r = inv.getArgument(0);
+            r.setId(2002L);
+            return 1;
+        });
+
+        Result<HealthMealRecord> result = mealController.create(record, MEMBER_ID);
+
+        assertEquals(200, result.getCode());
+        verify(cfGrowthPathwayEarnService, never()).earnMealCheckinCf(any(), any());
+    }
+
+    // ---------- F16 每日健康打卡 ----------
+
+    @Test
+    void F16_每日健康打卡_改投统一入口_refId为打卡记录ID() {
+        DailyHealthCheckin checkin = new DailyHealthCheckin();
+        checkin.setMemberId(MEMBER_ID);
+        checkin.setDietScore(8);
+        when(idempotencyUtil.tryAcquire(anyString(), anyLong())).thenReturn(true);
+        when(dailyCheckinMapper.insert(any())).thenAnswer(inv -> {
+            DailyHealthCheckin c = inv.getArgument(0);
+            c.setId(3001L);
+            return 1;
+        });
+
+        DailyHealthCheckin created = dailyCheckinService.createCheckin(checkin);
+
+        assertEquals(3001L, created.getId());
+        verify(cfGrowthPathwayEarnService).earnDailyHealthCheckinCf(MEMBER_ID, 3001L);
+    }
+
+    @Test
+    void F16_发放失败不影响打卡主流程() {
+        DailyHealthCheckin checkin = new DailyHealthCheckin();
+        checkin.setMemberId(MEMBER_ID);
+        when(idempotencyUtil.tryAcquire(anyString(), anyLong())).thenReturn(true);
+        when(dailyCheckinMapper.insert(any())).thenAnswer(inv -> {
+            DailyHealthCheckin c = inv.getArgument(0);
+            c.setId(3002L);
+            return 1;
+        });
+        org.mockito.Mockito.doThrow(new RuntimeException("cf service down"))
+                .when(cfGrowthPathwayEarnService).earnDailyHealthCheckinCf(anyLong(), anyLong());
+
+        DailyHealthCheckin created = dailyCheckinService.createCheckin(checkin);
+
+        assertEquals(3002L, created.getId());
+    }
+
+    private FamilyMember member(long memberId, long userId) {
+        FamilyMember m = new FamilyMember();
+        m.setId(memberId);
+        m.setUserId(userId);
+        return m;
+    }
+}

+ 447 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/CfGrowthPathwayEarnServiceTest.java

@@ -0,0 +1,447 @@
+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.dto.CfEarnResult;
+import com.etotem.cfc.dto.MembershipLevelDTO;
+import com.etotem.cfc.entity.CfBalance;
+import com.etotem.cfc.entity.CfBalanceLog;
+import com.etotem.cfc.entity.CfEarnDailyLimit;
+import com.etotem.cfc.entity.CfEarnRule;
+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.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.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * F-3b2 成长行为类发放接入服务(CfGrowthPathwayEarnService)单元测试:
+ *  - F14 喝水打卡 / F15 饮食打卡 / F16 每日健康打卡 固定数量(5/5/10)全部来自 cf_earn_rule,
+ *    代码 ctx 不携带 amount(规则改数量即改发放值);
+ *  - F14-F16 日限按会员等级(N11):达上限跳过并写 skip 流水;
+ *  - F17 挑战赛子任务完成:数量取任务配置 progress.reward_cf,ref_id = 挑战进度记录ID;
+ *  - F18 健康启航计划付费:公式 floor(实付分/100)×100(与 F02 同口径 1 元=100CF),硬编码 300 已删除;
+ *  - 五条途径 refType(health_water / health_meal / daily_health_checkin / challenge_sub_task /
+ *    health_start_plan)互不相同、幂等键(member_id, type, ref_type, ref_id)生效。
+ */
+class CfGrowthPathwayEarnServiceTest {
+
+    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 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 CfGrowthPathwayEarnService service = new CfGrowthPathwayEarnService();
+
+    @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, 0);
+        when(cfBalanceMapper.selectForUpdate(MEMBER_ID)).thenReturn(balance);
+        when(cfBalanceMapper.selectOne(any())).thenReturn(balance);
+        when(cfBalanceLogMapper.selectOne(any())).thenAnswer(inv ->
+                insertedLogs.isEmpty() ? null : insertedLogs.get(insertedLogs.size() - 1));
+        when(cfBalanceLogMapper.selectCount(any())).thenReturn(0L);
+        when(cfBalanceLogMapper.insert(any())).thenAnswer(inv -> {
+            insertedLogs.add(inv.getArgument(0));
+            return 1;
+        });
+
+        FamilyMember member = new FamilyMember();
+        member.setId(MEMBER_ID);
+        member.setUserId(USER_ID);
+        member.setFamilyId(FAMILY_ID);
+        when(familyMemberMapper.selectById(MEMBER_ID)).thenReturn(member);
+        // 会员等级:BASIC(N11 日限 3 次),规则未配 daily_limit_per_member 时按等级表取值
+        MembershipLevelDTO level = new MembershipLevelDTO();
+        level.setLevelCode("BASIC");
+        when(membershipService.getCurrentLevel(FAMILY_ID)).thenReturn(level);
+        when(configService.getConfig("cf.rate.cf_per_yuan")).thenReturn("100");
+
+        // 规则种子(与迁移408 口径一致)
+        when(cfEarnRuleService.getByCode("F14")).thenReturn(rule("F14", "behavior", "fixed", 5, null,
+                "{\"daily_limit_by_tier\":\"cf_earn_daily_limit\"}", null, null, "health_water"));
+        when(cfEarnRuleService.getByCode("F15")).thenReturn(rule("F15", "behavior", "fixed", 5, null,
+                "{\"daily_limit_by_tier\":\"cf_earn_daily_limit\"}", null, null, "health_meal"));
+        when(cfEarnRuleService.getByCode("F16")).thenReturn(rule("F16", "behavior", "fixed", 10, null,
+                "{\"daily_limit_by_tier\":\"cf_earn_daily_limit\"}", null, null, "daily_health_checkin"));
+        when(cfEarnRuleService.getByCode("F17")).thenReturn(rule("F17", "behavior", "per_item", null, null,
+                "{\"source\":\"challenge_task.reward_cf\"}", null, null, "challenge_sub_task"));
+        when(cfEarnRuleService.getByCode("F18")).thenReturn(rule("F18", "consume", "formula", null, "floor(amt)",
+                "{\"cf_per_yuan\":100,\"amt_unit\":\"cent\"}", 1, 1, "health_start_plan"));
+
+        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(service, "cfBalanceService", cfBalanceService);
+    }
+
+    // ---------- F14 喝水打卡 ----------
+
+    @Test
+    void F14_喝水打卡_数量取规则固定5_refType为health_water() {
+        CfEarnResult r = service.earnWaterCheckinCf(MEMBER_ID, 1001L);
+
+        assertTrue(r.getEarned());
+        assertEquals("F14", r.getRuleCode());
+        assertEquals("health_water", r.getRefType());
+        assertEquals(1001L, r.getRefId());
+        // 5 来自 cf_earn_rule F14(fixed),代码不写死
+        assertEquals(5, r.getAmount());
+        assertEquals(5, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
+
+        CfBalanceLog log = insertedLogs.get(0);
+        assertEquals("earn", log.getType());
+        assertEquals("health_water", log.getRefType());
+        assertEquals(1001L, log.getRefId());
+        assertEquals(5, log.getAmount());
+        assertEquals(MEMBER_ID, log.getMemberId());
+        assertEquals("喝水打卡", log.getRemark());
+    }
+
+    @Test
+    void F14_会员等级日限达上限_跳过发放并写skip流水() {
+        // N11:BASIC 会员日限 3 次/天(cf_earn_daily_limit)
+        CfEarnDailyLimit limit = new CfEarnDailyLimit();
+        limit.setRuleCode("F14");
+        limit.setLevelCode("BASIC");
+        limit.setDailyLimit(3);
+        when(cfEarnDailyLimitMapper.selectOne(any())).thenReturn(limit);
+        when(cfBalanceLogMapper.selectCount(any())).thenReturn(3L);
+
+        CfEarnResult r = service.earnWaterCheckinCf(MEMBER_ID, 1002L);
+
+        assertFalse(r.getEarned());
+        assertTrue(r.getReason().contains("日限"));
+        assertEquals(1002L, r.getRefId());
+        assertTrue(earnLogs().isEmpty(), "日限命中不得入账");
+        assertEquals(1, insertedLogs.size());
+        assertEquals("skip", insertedLogs.get(0).getType());
+    }
+
+    @Test
+    void F14_规则数量调整_发放数量随规则变化_代码无硬编码() {
+        when(cfEarnRuleService.getByCode("F14")).thenReturn(
+                rule("F14", "behavior", "fixed", 8, null, "{}", null, null, "health_water"));
+
+        CfEarnResult r = service.earnWaterCheckinCf(MEMBER_ID, 1003L);
+
+        assertEquals(8, r.getAmount());
+        assertEquals(8, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
+    }
+
+    @Test
+    void F14_同一打卡记录重复提交_幂等只发一次() {
+        service.earnWaterCheckinCf(MEMBER_ID, 1004L);
+        CfEarnResult second = service.earnWaterCheckinCf(MEMBER_ID, 1004L);
+
+        assertTrue(second.getDuplicated());
+        assertEquals(1, insertedLogs.size());
+        assertEquals(5, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
+    }
+
+    // ---------- F15 饮食打卡 ----------
+
+    @Test
+    void F15_饮食打卡_数量取规则固定5_refType为health_meal() {
+        CfEarnResult r = service.earnMealCheckinCf(MEMBER_ID, 2001L);
+
+        assertTrue(r.getEarned());
+        assertEquals("F15", r.getRuleCode());
+        assertEquals("health_meal", r.getRefType());
+        assertEquals(2001L, r.getRefId());
+        assertEquals(5, r.getAmount());
+        assertEquals(5, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
+
+        CfBalanceLog log = insertedLogs.get(0);
+        assertEquals("health_meal", log.getRefType());
+        assertEquals(5, log.getAmount());
+        assertEquals("饮食打卡", log.getRemark());
+    }
+
+    @Test
+    void F15_会员等级日限达上限_跳过发放() {
+        CfEarnDailyLimit limit = new CfEarnDailyLimit();
+        limit.setRuleCode("F15");
+        limit.setLevelCode("BASIC");
+        limit.setDailyLimit(3);
+        when(cfEarnDailyLimitMapper.selectOne(any())).thenReturn(limit);
+        when(cfBalanceLogMapper.selectCount(any())).thenReturn(3L);
+
+        CfEarnResult r = service.earnMealCheckinCf(MEMBER_ID, 2002L);
+
+        assertFalse(r.getEarned());
+        assertTrue(r.getReason().contains("日限"));
+        assertTrue(earnLogs().isEmpty(), "日限命中不得入账");
+    }
+
+    // ---------- F16 每日健康打卡 ----------
+
+    @Test
+    void F16_每日健康打卡_数量取规则固定10_refType为daily_health_checkin() {
+        CfEarnResult r = service.earnDailyHealthCheckinCf(MEMBER_ID, 3001L);
+
+        assertTrue(r.getEarned());
+        assertEquals("F16", r.getRuleCode());
+        assertEquals("daily_health_checkin", r.getRefType());
+        assertEquals(3001L, r.getRefId());
+        // 10 来自 cf_earn_rule F16(fixed)
+        assertEquals(10, r.getAmount());
+        assertEquals(10, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
+
+        CfBalanceLog log = insertedLogs.get(0);
+        assertEquals("daily_health_checkin", log.getRefType());
+        assertEquals(10, log.getAmount());
+        assertEquals("每日健康打卡", log.getRemark());
+    }
+
+    @Test
+    void F16_同一打卡记录重复提交_幂等只发一次() {
+        service.earnDailyHealthCheckinCf(MEMBER_ID, 3002L);
+        CfEarnResult second = service.earnDailyHealthCheckinCf(MEMBER_ID, 3002L);
+
+        assertTrue(second.getDuplicated());
+        assertEquals(10, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
+    }
+
+    // ---------- F17 挑战赛子任务完成 ----------
+
+    @Test
+    void F17_挑战赛子任务_数量取任务配置rewardCf_refType为challenge_sub_task() {
+        CfEarnResult r = service.earnChallengeSubTaskCf(MEMBER_ID, 500L, 100, "streak_7days", "连续7天打卡");
+
+        assertTrue(r.getEarned());
+        assertEquals("F17", r.getRuleCode());
+        assertEquals("challenge_sub_task", r.getRefType());
+        assertEquals(500L, r.getRefId());
+        assertEquals(100, r.getAmount());
+        assertEquals(100, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
+
+        CfBalanceLog log = insertedLogs.get(0);
+        assertEquals("challenge_sub_task", log.getRefType());
+        assertEquals(500L, log.getRefId());
+        assertEquals(100, log.getAmount());
+        assertEquals("挑战赛子任务完成奖励: 连续7天打卡", log.getRemark());
+    }
+
+    @Test
+    void F17_同一子任务重复触发_幂等只发一次() {
+        service.earnChallengeSubTaskCf(MEMBER_ID, 500L, 100, "streak_7days", "连续7天打卡");
+        CfEarnResult second = service.earnChallengeSubTaskCf(MEMBER_ID, 500L, 100, "streak_7days", "连续7天打卡");
+
+        assertTrue(second.getDuplicated());
+        assertEquals(1, insertedLogs.size());
+        assertEquals(100, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
+    }
+
+    // ---------- F18 健康启航计划付费 ----------
+
+    @Test
+    void F18_健康启航付费_199元得19900CF_与F02同口径() {
+        // 199 元实付 = 19900 分 → 19900 CF(1 元 = 100 CF)
+        CfEarnResult r = service.earnHealthStartPlanCf(MEMBER_ID, 900L, 19900, "HQ20260001");
+
+        assertTrue(r.getEarned());
+        assertEquals("F18", r.getRuleCode());
+        assertEquals("health_start_plan", r.getRefType());
+        assertEquals(900L, r.getRefId());
+        assertEquals(19900, r.getAmount());
+        assertEquals(19900, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
+
+        CfBalanceLog log = insertedLogs.get(0);
+        assertEquals("health_start_plan", log.getRefType());
+        assertEquals(900L, log.getRefId());
+        assertEquals(19900, log.getAmount());
+        assertEquals("健康启航计划付费奖励", log.getRemark());
+    }
+
+    @Test
+    void F18_同一订单重复触发_幂等不重复发放() {
+        service.earnHealthStartPlanCf(MEMBER_ID, 900L, 19900, "HQ20260001");
+        CfEarnResult second = service.earnHealthStartPlanCf(MEMBER_ID, 900L, 19900, "HQ20260001");
+
+        assertTrue(second.getDuplicated());
+        assertEquals(1, insertedLogs.size());
+        assertEquals(19900, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
+    }
+
+    @Test
+    void F18_规则累计上限1次_第二笔按规则跳过() {
+        when(cfEarnRuleService.getByCode("F18")).thenReturn(rule("F18", "consume", "formula", null, "floor(amt)",
+                "{\"cf_per_yuan\":100,\"amt_unit\":\"cent\"}", null, 1, "health_start_plan"));
+        when(cfBalanceLogMapper.selectCount(any())).thenReturn(0L, 1L);
+
+        service.earnHealthStartPlanCf(MEMBER_ID, 900L, 19900, "HQ1");
+        CfEarnResult second = service.earnHealthStartPlanCf(MEMBER_ID, 901L, 19900, "HQ2");
+
+        assertFalse(second.getEarned());
+        assertTrue(second.getReason().contains("累计上限"));
+        assertEquals(1, earnLogs().size(), "第一笔入账,第二笔命中累计上限只写 skip 流水");
+        assertEquals(19900, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
+    }
+
+    // ---------- ctx 组装校验(数量一律由规则决定,代码不硬编码) ----------
+
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> captureCtx(CfGrowthPathwayEarnService s, long memberId, String ruleCode,
+                                           long refId, Runnable call) {
+        CfBalanceService stub = mock(CfBalanceService.class);
+        ReflectionTestUtils.setField(s, "cfBalanceService", stub);
+        call.run();
+        ArgumentCaptor<String> ruleCaptor = ArgumentCaptor.forClass(String.class);
+        ArgumentCaptor<Long> refIdCaptor = ArgumentCaptor.forClass(Long.class);
+        ArgumentCaptor<Map<String, Object>> ctxCaptor = ArgumentCaptor.forClass(Map.class);
+        verify(stub).earnByRule(eq(memberId), ruleCaptor.capture(), refIdCaptor.capture(), ctxCaptor.capture());
+        assertEquals(ruleCode, ruleCaptor.getValue(), "规则编码必须与途径一致");
+        assertEquals(Long.valueOf(refId), refIdCaptor.getValue(), "幂等 refId 必须用业务记录ID");
+        return ctxCaptor.getValue();
+    }
+
+    @Test
+    void F14_F15_F16_ctx不携带数量_数量全由规则决定() {
+        CfGrowthPathwayEarnService s = new CfGrowthPathwayEarnService();
+
+        Map<String, Object> ctxF14 = captureCtx(s, MEMBER_ID, "F14", 11L,
+                () -> s.earnWaterCheckinCf(MEMBER_ID, 11L));
+        assertNull(ctxF14.get("amount"), "固定规则 F14 的数量必须来自 cf_earn_rule,代码不得传 amount");
+        assertNull(ctxF14.get("amt"));
+        assertEquals(11L, ctxF14.get("checkin_id"));
+        assertEquals("喝水打卡", ctxF14.get("remark"));
+
+        Map<String, Object> ctxF15 = captureCtx(s, MEMBER_ID, "F15", 22L,
+                () -> s.earnMealCheckinCf(MEMBER_ID, 22L));
+        assertNull(ctxF15.get("amount"), "固定规则 F15 的数量必须来自 cf_earn_rule,代码不得传 amount");
+        assertEquals(22L, ctxF15.get("checkin_id"));
+        assertEquals("饮食打卡", ctxF15.get("remark"));
+
+        Map<String, Object> ctxF16 = captureCtx(s, MEMBER_ID, "F16", 33L,
+                () -> s.earnDailyHealthCheckinCf(MEMBER_ID, 33L));
+        assertNull(ctxF16.get("amount"), "固定规则 F16 的数量必须来自 cf_earn_rule,代码不得传 amount");
+        assertEquals(33L, ctxF16.get("checkin_id"));
+        assertEquals("每日健康打卡", ctxF16.get("remark"));
+    }
+
+    @Test
+    void F17_ctx携带任务配置数量与任务标识() {
+        CfGrowthPathwayEarnService s = new CfGrowthPathwayEarnService();
+
+        Map<String, Object> ctx = captureCtx(s, MEMBER_ID, "F17", 500L,
+                () -> s.earnChallengeSubTaskCf(MEMBER_ID, 500L, 100, "streak_7days", "连续7天打卡"));
+
+        assertEquals(100, ctx.get("amount"), "F17 数量必须来自任务配置 progress.reward_cf");
+        assertEquals("streak_7days", ctx.get("task_key"));
+        assertEquals("挑战赛子任务完成奖励: 连续7天打卡", ctx.get("remark"));
+    }
+
+    @Test
+    void F18_ctx携带实付金额分_数量由规则公式计算() {
+        CfGrowthPathwayEarnService s = new CfGrowthPathwayEarnService();
+
+        Map<String, Object> ctx = captureCtx(s, MEMBER_ID, "F18", 900L,
+                () -> s.earnHealthStartPlanCf(MEMBER_ID, 900L, 19900, "HQ20260001"));
+
+        assertEquals(19900, ctx.get("amt"), "F18 只传实付金额(分),数量由规则公式 floor(amt) 计算");
+        assertNull(ctx.get("amount"), "F18 不得在代码中写死 CF 数量");
+        assertEquals("HQ20260001", ctx.get("order_no"));
+        assertEquals("健康启航计划付费奖励", ctx.get("remark"));
+    }
+
+    // ---------- 构造 ----------
+
+    /** 已入账的 earn 流水(排除 skip 流水) */
+    private List<CfBalanceLog> earnLogs() {
+        List<CfBalanceLog> earn = new ArrayList<>();
+        for (CfBalanceLog logEntry : insertedLogs) {
+            if ("earn".equals(logEntry.getType())) {
+                earn.add(logEntry);
+            }
+        }
+        return earn;
+    }
+
+    private CfBalance balance(long memberId, int available) {
+        CfBalance b = new CfBalance();
+        b.setId(memberId);
+        b.setMemberId(memberId);
+        b.setUserId(USER_ID);
+        b.setFamilyId(FAMILY_ID);
+        b.setAvailable(available);
+        b.setFrozen(0);
+        b.setTotalEarned(available);
+        b.setWithdrawn(0);
+        b.setExchanged(0);
+        return b;
+    }
+
+    private CfEarnRule rule(String code, String category, String ruleType, Integer amount, String formula,
+                            String paramsJson, Integer dailyLimit, Integer totalLimit, String dedupRefType) {
+        CfEarnRule r = new CfEarnRule();
+        r.setCode(code);
+        r.setName(code + "-规则");
+        r.setCategory(category);
+        r.setEnabled(1);
+        r.setRuleType(ruleType);
+        r.setAmount(amount);
+        r.setFormula(formula);
+        r.setParamsJson(paramsJson);
+        r.setDailyLimitPerMember(dailyLimit);
+        r.setTotalLimitPerMember(totalLimit);
+        r.setCooldownSeconds(null);
+        r.setDedupRefType(dedupRefType);
+        return r;
+    }
+}

+ 174 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/impl/CfGrowthEarnCallSiteTest.java

@@ -0,0 +1,174 @@
+package com.etotem.cfc.service.impl;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.dto.CfEarnResult;
+import com.etotem.cfc.entity.ChallengeProgress;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.PurchaseOrder;
+import com.etotem.cfc.mapper.ChallengeProgressMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.PurchaseOrderMapper;
+import com.etotem.cfc.service.CfGrowthPathwayEarnService;
+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.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * F-3b2 发放接入调用点单测:
+ *  - F17 挑战赛子任务达标 → 改投统一入口 earnChallengeSubTaskCf,不再直接写旧 system_points 账本;
+ *  - F18 健康启航计划付费确认 → 改投统一入口 earnHealthStartPlanCf(实付分入 ctx),
+ *    硬编码数量 300 已删除,且不再直接写旧 system_points 账本。
+ */
+class CfGrowthEarnCallSiteTest {
+
+    private static final long MEMBER_ID = 1L;
+    private static final long USER_ID = 100L;
+    private static final long FAMILY_ID = 7L;
+
+    @Mock
+    private ChallengeProgressMapper progressMapper;
+    @Mock
+    private FamilyMemberMapper memberMapper;
+    @Mock
+    private PurchaseOrderMapper orderMapper;
+    @Mock
+    private CfGrowthPathwayEarnService cfGrowthPathwayEarnService;
+
+    private ChallengeServiceImpl challengeService;
+    private PurchaseServiceImpl purchaseService;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
+                ChallengeProgress.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
+                FamilyMember.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
+                PurchaseOrder.class);
+
+        challengeService = new ChallengeServiceImpl();
+        ReflectionTestUtils.setField(challengeService, "progressMapper", progressMapper);
+        ReflectionTestUtils.setField(challengeService, "memberMapper", memberMapper);
+        ReflectionTestUtils.setField(challengeService, "cfGrowthPathwayEarnService", cfGrowthPathwayEarnService);
+
+        purchaseService = new PurchaseServiceImpl();
+        ReflectionTestUtils.setField(purchaseService, "orderMapper", orderMapper);
+        ReflectionTestUtils.setField(purchaseService, "memberMapper", memberMapper);
+        ReflectionTestUtils.setField(purchaseService, "cfGrowthPathwayEarnService", cfGrowthPathwayEarnService);
+    }
+
+    // ---------- F17 挑战赛子任务完成 ----------
+
+    @Test
+    void F17_子任务达标_改投统一入口_不再直接写旧账本() {
+        ChallengeProgress progress = new ChallengeProgress();
+        progress.setId(500L);
+        progress.setMemberId(MEMBER_ID);
+        progress.setTaskKey("streak_7days");
+        progress.setTaskTitle("连续7天打卡");
+        progress.setTargetValue(3);
+        progress.setCurrentValue(3);
+        progress.setRewardCf(100);
+        progress.setStatus("pending");
+        when(progressMapper.selectOne(any())).thenReturn(progress);
+        when(progressMapper.updateById(any())).thenReturn(1);
+
+        challengeService.updateTaskProgress(MEMBER_ID, "streak_7days", 3);
+
+        verify(cfGrowthPathwayEarnService).earnChallengeSubTaskCf(
+                MEMBER_ID, 500L, 100, "streak_7days", "连续7天打卡");
+        verify(memberMapper, never()).updateById(any());
+        assertEquals("completed", progress.getStatus());
+    }
+
+    // ---------- F18 健康启航计划付费 ----------
+
+    @Test
+    void F18_付费确认_按规则发放CF_不再直接写旧账本() {
+        PurchaseOrder order = pendingOrder();
+        when(orderMapper.selectOne(any())).thenReturn(order);
+        when(orderMapper.updateById(any())).thenReturn(1);
+        when(memberMapper.selectOne(any())).thenReturn(member());
+
+        CfEarnResult earnResult = new CfEarnResult();
+        earnResult.setEarned(true);
+        earnResult.setAmount(19900);
+        when(cfGrowthPathwayEarnService.earnHealthStartPlanCf(MEMBER_ID, 900L, 19900, "HQ20260001"))
+                .thenReturn(earnResult);
+
+        Map<String, Object> result = purchaseService.confirmPayment("HQ20260001", "wechat", "tx-1");
+
+        // 实付分入 ctx,数量由规则公式计算
+        verify(cfGrowthPathwayEarnService).earnHealthStartPlanCf(MEMBER_ID, 900L, 19900, "HQ20260001");
+        verify(memberMapper, never()).updateById(any());
+        assertEquals(19900, result.get("cfReward"));
+        assertEquals(19900, order.getCfRewardGiven());
+    }
+
+    @Test
+    void F18_规则未发放_记录0且不写旧账本() {
+        PurchaseOrder order = pendingOrder();
+        when(orderMapper.selectOne(any())).thenReturn(order);
+        when(orderMapper.updateById(any())).thenReturn(1);
+        when(memberMapper.selectOne(any())).thenReturn(member());
+
+        CfEarnResult earnResult = new CfEarnResult();
+        earnResult.setEarned(false);
+        earnResult.setReason("会员等级 FREE 日限 1 次已达");
+        when(cfGrowthPathwayEarnService.earnHealthStartPlanCf(MEMBER_ID, 900L, 19900, "HQ20260001"))
+                .thenReturn(earnResult);
+
+        Map<String, Object> result = purchaseService.confirmPayment("HQ20260001", "wechat", "tx-1");
+
+        verify(cfGrowthPathwayEarnService).earnHealthStartPlanCf(MEMBER_ID, 900L, 19900, "HQ20260001");
+        verify(memberMapper, never()).updateById(any());
+        assertEquals(0, result.get("cfReward"));
+        assertEquals(0, order.getCfRewardGiven());
+    }
+
+    @Test
+    void F18_未找到购买人家庭成员_不发放CF() {
+        PurchaseOrder order = pendingOrder();
+        when(orderMapper.selectOne(any())).thenReturn(order);
+        when(orderMapper.updateById(any())).thenReturn(1);
+        when(memberMapper.selectOne(any())).thenReturn(null);
+
+        Map<String, Object> result = purchaseService.confirmPayment("HQ20260001", "wechat", "tx-1");
+
+        verify(cfGrowthPathwayEarnService, never()).earnHealthStartPlanCf(any(), any(), any(), any());
+        assertEquals(0, result.get("cfReward"));
+    }
+
+    private PurchaseOrder pendingOrder() {
+        PurchaseOrder order = new PurchaseOrder();
+        order.setId(900L);
+        order.setOrderNo("HQ20260001");
+        order.setUserId(USER_ID);
+        order.setFamilyId(FAMILY_ID);
+        order.setAmount(19900);
+        order.setStatus("pending");
+        return order;
+    }
+
+    private FamilyMember member() {
+        FamilyMember m = new FamilyMember();
+        m.setId(MEMBER_ID);
+        m.setUserId(USER_ID);
+        m.setFamilyId(FAMILY_ID);
+        m.setSystemPoints(0);
+        return m;
+    }
+}