瀏覽代碼

merge: 合并 origin/cfclub(F-3a 消费返佣 F02-F06 接入 / 迁移419)与 F-4 分佣收口

远端合入:
- F-3a 消费返佣四条途径接入 earnByRule(CfEarnDispatchService,F02/F04/F05/F06),当前人返 CF 改走 F02 统一账本
- 迁移419 membership_levels.max_daily_analyze(ReportAnalyzeQuota 修复)

冲突解决(CfCommissionService 双轨共存):
- 买家 F02 走 F-3a 的 CfEarnDispatchService.earnConsumeCf(7参 settle 重载保留,实付与分润基数分离)
- 推荐人走 F-4 两级销售奖(F03a/F03b via earnByRule),替换旧团队规模阶梯分润
- 移除旧平台积分直写(platformPointsService.earn referral_dist / cf_transfer_record record)——两条链路均只写 cf_balance + cf_balance_log

迁移编号顺延:ppoint_consumption_log 由 419 顺延为 420(419 已被 membership_levels.max_daily_analyze 占用),DatabaseInitializer/schema.sql/文档同步更新

测试:CfCommissionServiceSettleTest(12, 含 F-3a N6 会员返F02 与 实付/分润基数分离用例) + CfReferralServiceSalesTierTest(11) + CommissionDistService410Test(4) + DatabaseInitializerSeedTest(6) + F-3a 的 CfEarnDispatchServiceTest(8) / CfBalanceServiceTest(20) / CfFormulaEvaluatorTest(8) / CfCommissionServiceTest(2) = 71 用例全绿;mvn clean compile BUILD SUCCESS
Sisyphus 2 周之前
父節點
當前提交
baaf3fafaf

+ 14 - 6
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3313,7 +3313,15 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         seedCfSalesTiers();
         alignSalesRuleRefTypes();
 
-        // 迁移419: 创建 ppoint_consumption_log(滚动一年销售奖档位聚合数据源;F-4)
+        // 迁移419: membership_levels 补 max_daily_analyze(会员等级每日报告解析次数上限)
+        // 背景:ReportAnalyzeQuotaService(报告解析配额)已入库,但 MembershipLevel 实体字段与
+        //       membership_levels 表列在历史提交中丢失,导致编译失败 + 运行时 Unknown column。
+        //       此处补列(幂等);0/NULL 表示不限次。
+        ensureColumn("membership_levels", "max_daily_analyze",
+                "INT DEFAULT NULL COMMENT '每日报告解析次数上限(0/NULL=不限)'");
+
+        // 迁移420: 创建 ppoint_consumption_log(滚动一年销售奖档位聚合数据源;F-4)
+        // 注:迁移419 已被 membership_levels.max_daily_analyze 占用,本表顺延为 420。
         createPpointConsumptionLogTable();
     }
 
@@ -14067,10 +14075,10 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
     }
 
-    // ==================== 迁移419:P 点消费日志(F-4 滚动一年档位数据源) ====================
+    // ==================== 迁移420:P 点消费日志(F-4 滚动一年档位数据源) ====================
 
-    // 迁移419: 创建 ppoint_consumption_log(消费支付成功时落一行,按消费时间滚动一年聚合)
-    // 迁移419(F-4):建表幂等,包内可见以便单测直接调用
+    // 迁移420: 创建 ppoint_consumption_log(消费支付成功时落一行,按消费时间滚动一年聚合)
+    // 迁移420(F-4):建表幂等,包内可见以便单测直接调用
     void createPpointConsumptionLogTable() {
         try {
             jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `ppoint_consumption_log` ("
@@ -14088,9 +14096,9 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
                     + "UNIQUE KEY `uk_order` (`order_type`, `order_id`), "
                     + "KEY `idx_user_time` (`user_id`, `created_at`) "
                     + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='P点消费日志(滚动一年销售奖档位聚合数据源,F-4)'");
-            log.info("迁移419: 已创建 ppoint_consumption_log 表");
+            log.info("迁移420: 已创建 ppoint_consumption_log 表");
         } catch (Exception e) {
-            log.warn("迁移419: 创建 ppoint_consumption_log 表失败(可能已存在): {}", e.getMessage());
+            log.warn("迁移420: 创建 ppoint_consumption_log 表失败(可能已存在): {}", e.getMessage());
         }
     }
 }

+ 60 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ReportAnalyzeQuotaDTO.java

@@ -0,0 +1,60 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 报告快速分析配额状态。
+ *
+ * <p>同一 DTO 承载两种语义,按入口区分:
+ * <ul>
+ *   <li>只读查询({@code POST /api/ai/report/analyze/quota}):仅
+ *       {@code total/used/remaining/unlimited/exhausted} 有含义,{@code allowed} 恒为 true;</li>
+ *   <li>扣减结果({@code POST /api/ai/report/analyze}):额外携带
+ *       {@code allowed/message/actionUrl}。放行时作为 data 内 {@code quota} 字段回给前端;
+ *       超限时 {@code allowed=false},控制器以 {@code message} 作为 Result.message 返回
+ *       (仍带 code=500),但 data 依然保留本对象供前端渲染剩余次数与升级引导。</li>
+ * </ul>
+ *
+ * <p>口径见 {@code docs/superpowers/specs/2026-09-10-report-quick-analyze-design.md} §3.3:
+ * 免费版每日 1 次;付费版按 membership_levels.max_daily_analyze 配置,0/NULL = 不限次。
+ */
+@Data
+public class ReportAnalyzeQuotaDTO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 配额类型(固定值,便于前端按类型复用进度条组件) */
+    private String quotaType = "REPORT_ANALYZE";
+
+    /** 当前用户有效会员等级编码:FREE/BASIC/FAMILY/PREMIUM/LIFETIME */
+    private String levelCode;
+
+    /** 每日上限次数;0 表示不限次 */
+    private int total;
+
+    /** 今日已用次数 */
+    private int used;
+
+    /** 今日剩余次数;-1 表示不限次 */
+    private int remaining;
+
+    /** 是否不限次(付费等级未配置上限,或上限配置为 0) */
+    private boolean unlimited;
+
+    /** 是否已用完(unlimited=false 且 remaining<=0) */
+    private boolean exhausted;
+
+    /** 本次调用是否放行;只读查询入口恒为 true */
+    private boolean allowed = true;
+
+    /** allowed=false 时的提示文案(免费版与付费版文案不同) */
+    private String message;
+
+    /** 升级引导路径(exhausted 时有值) */
+    private String actionUrl;
+
+    /** 计数周期键(yyyyMMdd),与 Redis 计数器 key 后缀一致 */
+    private String periodKey;
+}

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/MembershipLevel.java

@@ -39,6 +39,8 @@ public class MembershipLevel implements Serializable {
 
     private Integer maxTasksPerDay;  // 每日任务上限
 
+    private Integer maxDailyAnalyze;  // 每日报告解析上限(他人未完成功能缺字段,临时补丁,验证后还原)
+
     private Integer aiReviewEnabled;  // AI审核
 
     private Integer prioritySupport;  // 优先客服

+ 5 - 6
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityService.java

@@ -65,7 +65,7 @@ public class ActivityService extends ServiceImpl<ActivityMapper, Activity> {
     private UserMapper userMapper;
 
     @Resource
-    private PlatformPointsService platformPointsService;
+    private CfEarnDispatchService cfEarnDispatchService;
 
     public Result<Map<String, Object>> list(String dimensionCode, Integer page, Integer size, Long userId) {
         autoEndExpiredActivities();
@@ -413,14 +413,13 @@ public class ActivityService extends ServiceImpl<ActivityMapper, Activity> {
             log.warn("能量发放失败(不影响积分): childId={}, activityId={}, error={}",
                 task.getChildId(), activity.getId(), e.getMessage());
         }
-        // 活动下发 CF 值(平台积分)
+        // 活动下发 CF 值(F05 统一账本:数量取活动配置 activities.platform_points,幂等键:签到记录 ID)
         if (activity.getPlatformPoints() != null && activity.getPlatformPoints() > 0 && userId != null) {
             try {
-                platformPointsService.earn(userId, activity.getPlatformPoints(),
-                        "activity", activity.getId(), "活动签到: " + activity.getTitle());
+                cfEarnDispatchService.earnActivityCheckinCf(userId, task.getId(), activity.getPlatformPoints());
             } catch (Exception e) {
-                log.warn("活动CF值发放失败: userId={}, activityId={}, error={}",
-                        userId, activity.getId(), e.getMessage());
+                log.warn("活动CF值发放失败: userId={}, checkinId={}, error={}",
+                        userId, task.getId(), e.getMessage());
             }
         }
     }

+ 46 - 74
cfc-backend/src/main/java/com/etotem/cfc/service/CfCommissionService.java

@@ -7,7 +7,6 @@ import com.etotem.cfc.dto.CfEarnResult;
 import com.etotem.cfc.entity.CfDirectSalesTier;
 import com.etotem.cfc.entity.CfEarnRule;
 import com.etotem.cfc.entity.CfIndirectSalesTier;
-import com.etotem.cfc.entity.CfTransferRecord;
 import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.OctopusEffectKeyPerson;
 import com.etotem.cfc.entity.OctopusEffectRecord;
@@ -16,7 +15,6 @@ import com.etotem.cfc.entity.PpointConsumptionLog;
 import com.etotem.cfc.entity.Product;
 import com.etotem.cfc.entity.ReferralTree;
 import com.etotem.cfc.entity.User;
-import com.etotem.cfc.mapper.CfTransferRecordMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.OctopusEffectKeyPersonMapper;
 import com.etotem.cfc.mapper.OctopusEffectRecordMapper;
@@ -42,16 +40,17 @@ import java.util.Map;
 import java.util.Set;
 
 /**
- * 统一 CF 值分佣服务(F-4 分佣收口)。
+ * 统一 CF 值分佣服务(F-3a 买家返 + F-4 两级销售奖收口)。
  *
  * <p>口径见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §四:
  * <ul>
- *   <li>普通订单(商品/套餐/测评):当前消费人返 CF + 推荐人两级销售奖;</li>
- *   <li>会员/订阅订单:只返推荐人;</li>
+ *   <li>当前消费人返 CF 统一走 F02 规则({@link CfEarnDispatchService#earnConsumeCf},
+ *       按实付金额,1 元 = 100 CF;商品订单实付与分润基数分离);</li>
+ *   <li>会员/订阅订单:当前消费人同样返 F02(N6 所有消费订单都返),推荐人只返两级销售奖;</li>
  *   <li>同家庭互推:跳过本人,上溯到第一个非同家庭引荐人。</li>
  * </ul>
  *
- * <p>两级销售奖(取代原"团队规模阶梯 cf_rate_tier"单级分佣):
+ * <p>推荐人两级销售奖(F-4,取代原"团队规模阶梯 cf_rate_tier"单级分佣):
  * <ul>
  *   <li><b>F03a 直接销售奖</b>:基数 = 本单消费产生的 P 点;比例按推荐人
  *       <b>团队 8 层内滚动一年累计 P 点</b>在 {@code cf_direct_sales_tier} 的档位(最高 70%);</li>
@@ -87,14 +86,10 @@ public class CfCommissionService {
     @Resource
     private CfReferralService cfReferralService;
     @Resource
-    private PlatformPointsService platformPointsService;
-    @Resource
-    private CfTransferRecordMapper cfTransferRecordMapper;
+    private CfEarnDispatchService cfEarnDispatchService;
     @Resource
     private PpointConfigService ppointConfigService;
     @Resource
-    private SysConfigService sysConfigService;
-    @Resource
     private ProductMapper productMapper;
     @Resource
     private OctopusKeyPersonMapper octopusKeyPersonMapper;
@@ -116,58 +111,64 @@ public class CfCommissionService {
     private ObjectMapper objectMapper;
 
     /**
-     * 通用分佣(双返):当前人 + 推荐人
+     * 通用分佣(双返):当前人 + 推荐人。
+     *
+     * <p>兼容入口:当前人 F02 实付金额取分润基数(订单金额)。</p>
      */
     @Transactional
     public void settle(Long orderId, String orderType, Long buyerUserId, Long buyerFamilyId,
                        Integer orderAmountCent, Long productId) {
-        // 当前消费人返 CF(个人钱包)
-        int buyerReturn = calcBuyerReturn(orderType, orderAmountCent, productId, buyerUserId);
-        if (buyerReturn > 0) {
-            try {
-                platformPointsService.earn(buyerUserId, buyerReturn, "order_consume", orderId,
-                        "消费返CF:" + orderType);
-                record(buyerUserId, null, null, buyerReturn, "allocate", "order_consume", orderId, "消费返CF");
-            } catch (Exception e) {
-                log.error("当前人返CF失败: buyer={}, orderId={}, err={}", buyerUserId, orderId, e.getMessage());
-            }
-        }
-        // 推荐人分润
+        settle(orderId, orderType, buyerUserId, buyerFamilyId, orderAmountCent, productId, orderAmountCent);
+    }
+
+    /**
+     * 通用分佣(双返):当前人 + 推荐人。
+     *
+     * @param orderAmountCent 推荐人分润基数(分)
+     * @param paidAmountCent  当前人 F02 实付金额(分);商品订单需传实付(扣优惠/积分后),与分润基数分离
+     */
+    @Transactional
+    public void settle(Long orderId, String orderType, Long buyerUserId, Long buyerFamilyId,
+                       Integer orderAmountCent, Long productId, Integer paidAmountCent) {
+        // 当前消费人返 CF(F02 统一账本:1 元 = 100 CF,按实付金额)
+        earnBuyerCf(orderId, orderType, buyerUserId, paidAmountCent);
+        // 推荐人分润(两级销售奖)
         distributeToReferrers(orderId, orderType, buyerUserId, buyerFamilyId, orderAmountCent, productId);
     }
 
     /**
-     * 会员/订阅专用:只返推荐人
+     * 会员/订阅专用:只返推荐人分润。
+     *
+     * <p>当前消费人仍按 F02 返 CF(N6 所有消费订单都返),仅推荐人分润逻辑走会员/订阅口径。</p>
      */
     @Transactional
     public void settleReferrerOnly(Long orderId, String orderType, Long buyerUserId, Long buyerFamilyId,
                                    Integer orderAmountCent) {
+        settleReferrerOnly(orderId, orderType, buyerUserId, buyerFamilyId, orderAmountCent, orderAmountCent);
+    }
+
+    /**
+     * 会员/订阅专用:只返推荐人分润。
+     *
+     * @param orderAmountCent 推荐人分润基数(分)
+     * @param paidAmountCent  当前人 F02 实付金额(分)
+     */
+    @Transactional
+    public void settleReferrerOnly(Long orderId, String orderType, Long buyerUserId, Long buyerFamilyId,
+                                   Integer orderAmountCent, Integer paidAmountCent) {
+        earnBuyerCf(orderId, orderType, buyerUserId, paidAmountCent);
         distributeToReferrers(orderId, orderType, buyerUserId, buyerFamilyId, orderAmountCent, null);
     }
 
     /**
-     * 计算当前消费人返 CF。
-     * 商品:floor(orderAmount/100) × P点 × shareBps/10000
-     * 非商品:floor(orderAmount/100) × serviceRateBps/10000
+     * 当前消费人返 CF(F02):统一走 {@link CfEarnDispatchService},数量/幂等由 cf_earn_rule 决定。
      */
-    private int calcBuyerReturn(String orderType, Integer orderAmountCent, Long productId, Long buyerUserId) {
-        if (orderAmountCent == null || orderAmountCent <= 0) return 0;
-        int amountYuan = orderAmountCent / 100;
-        if ("product".equals(orderType) && productId != null) {
-            try {
-                Product p = productMapper.selectById(productId);
-                if (p == null) return 0;
-                int effectivePpoint = ppointConfigService.getEffectivePpoint(productId, p.getCategoryId());
-                if (effectivePpoint <= 0) return 0;
-                int shareBps = getSysBps("product_platform_points_share", 1000);
-                return amountYuan * effectivePpoint / 100 * shareBps / 10000;
-            } catch (Exception e) {
-                log.warn("计算当前人返CF失败: productId={}, err={}", productId, e.getMessage());
-                return 0;
-            }
+    private void earnBuyerCf(Long orderId, String orderType, Long buyerUserId, Integer paidAmountCent) {
+        try {
+            cfEarnDispatchService.earnConsumeCf(buyerUserId, orderType, orderId, paidAmountCent);
+        } catch (Exception e) {
+            log.error("当前人返CF失败(F02): buyer={}, orderId={}, err={}", buyerUserId, orderId, e.getMessage());
         }
-        int serviceRateBps = getSysBps("commission_service_rate", 1000);
-        return amountYuan * serviceRateBps / 10000;
     }
 
     /**
@@ -379,35 +380,6 @@ public class CfCommissionService {
         }
     }
 
-    private int getSysBps(String key, int def) {
-        try {
-            String v = sysConfigService.getValue(key);
-            if (v != null && !v.isEmpty()) return Integer.parseInt(v);
-        } catch (Exception e) {
-            log.warn("读取系统配置失败: key={}, err={}", key, e.getMessage());
-        }
-        return def;
-    }
-
-    private void record(Long fromUserId, Long toUserId, Long familyId, int amount, String type,
-                        String refType, Long refId, String remark) {
-        CfTransferRecord r = new CfTransferRecord();
-        r.setFromUserId(fromUserId);
-        r.setToUserId(toUserId);
-        r.setFamilyId(familyId);
-        r.setAmount(amount);
-        r.setType(type);
-        r.setRefType(refType);
-        r.setRefId(refId);
-        r.setRemark(remark);
-        r.setCreatedAt(new Date());
-        try {
-            cfTransferRecordMapper.insert(r);
-        } catch (Exception e) {
-            log.warn("写流转记录失败: {}", e.getMessage());
-        }
-    }
-
     /**
      * 当邀请人的被邀请人(系统用户)消费产生 CF 时,自动在邀请人侧创建感恩记录。
      * 将买家作为关键人,CF 金额作为成效金额,角色固定为"其他"。

+ 206 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CfEarnDispatchService.java

@@ -0,0 +1,206 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.dto.CfEarnResult;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * CF 值统一发放分派服务(F-3a:消费返佣类四条途径 F02 / F04 / F05 / F06)。
+ *
+ * <p>职责:</p>
+ * <ol>
+ *   <li>把「用户维度」触发点换算成「家庭成员维度」(family_members.id),统一账本
+ *       {@code cf_balance} / {@code cf_balance_log} 均以 member_id 为主键;</li>
+ *   <li>按规则编码调用唯一发放入口 {@link CfBalanceService#earnByRule(Long, String, Long, Map)},
+ *       规则数量、比例、上限、幂等 refType 全部来自 {@code cf_earn_rule},代码内不写死业务数值;</li>
+ *   <li>统一组装 ctx(公式变量)与 refId(幂等键的业务单据标识)。</li>
+ * </ol>
+ *
+ * <p>口径见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §三(F02/F04/F05/F06)、
+ * §七 F-3 发放接入。</p>
+ *
+ * <p>四条途径与幂等 refType(取自规则种子 dedup_ref_type):</p>
+ * <table border="1">
+ *   <tr><th>规则</th><th>途径</th><th>refType</th><th>refId</th><th>ctx</th></tr>
+ *   <tr><td>F02</td><td>消费返 CF(商品/套餐/测评/会员/订阅/调研)</td><td>cf_consume_order</td>
+ *       <td>订单 ID(按订单类型加命名空间,见 {@link #composeConsumeRefId})</td><td>amt=实付分</td></tr>
+ *   <tr><td>F04</td><td>商品赠品 CF</td><td>product_order_gift</td><td>赠品 ID</td><td>amount=赠品 CF 数</td></tr>
+ *   <tr><td>F05</td><td>活动签到 CF</td><td>activity_checkin</td><td>签到记录 ID</td><td>amount=活动 CF 数</td></tr>
+ *   <tr><td>F06</td><td>菌群健康调研打卡返还</td><td>gut_health_refund</td><td>项目 ID</td><td>amt=返还金额(分)</td></tr>
+ * </table>
+ */
+@Service
+public class CfEarnDispatchService {
+
+    private static final Logger log = LoggerFactory.getLogger(CfEarnDispatchService.class);
+
+    /** F02 消费返 CF(所有消费订单) */
+    public static final String RULE_F02 = "F02";
+    /** F04 商品赠品 CF */
+    public static final String RULE_F04 = "F04";
+    /** F05 活动签到 CF */
+    public static final String RULE_F05 = "F05";
+    /** F06 菌群健康调研打卡返还 */
+    public static final String RULE_F06 = "F06";
+
+    /**
+     * F02 幂等 refId 的订单类型命名空间。
+     *
+     * <p>F02 规则种子的 dedup_ref_type 固定为 {@code cf_consume_order},而商品订单、套餐订单、测评订单、
+     * 会员订单、订阅订单、调研订单分属不同业务表,各自的自增 ID 会重复(例如商品订单 #1 与套餐订单 #1)。
+     * 若直接用业务表 ID 作为 ref_id,同一成员的第二笔跨表消费会被幂等键误判为重复而漏发。
+     * 故 ref_id = 业务 ID × 100 + 订单类型编码,保证跨表唯一,同时可用 {@code ref_id / 100} 还原业务 ID。</p>
+     */
+    private static final Map<String, Integer> ORDER_TYPE_NAMESPACE;
+
+    static {
+        Map<String, Integer> ns = new HashMap<>();
+        ns.put("product", 1);
+        ns.put("package", 2);
+        ns.put("assessment", 3);
+        ns.put("membership", 4);
+        ns.put("subscription", 5);
+        ns.put("gut_health", 6);
+        ORDER_TYPE_NAMESPACE = Collections.unmodifiableMap(ns);
+    }
+
+    /** 未知订单类型的命名空间编码(保留 99,避免与已知类型冲突) */
+    private static final int UNKNOWN_ORDER_TYPE_NAMESPACE = 99;
+
+    @Resource
+    private CfBalanceService cfBalanceService;
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    // ==================== F02 消费返 CF ====================
+
+    /**
+     * F02 消费返 CF:所有消费订单(商品/套餐/测评/会员/订阅/调研)均返,1 元 = 100 CF。
+     *
+     * @param userId         消费人(users.id)
+     * @param orderType      订单类型(product/package/assessment/membership/subscription/gut_health)
+     * @param orderId        业务订单 ID(幂等键的业务单据标识)
+     * @param paidAmountCent 实付金额(分);规则种子按 amt_unit=cent + cf_per_yuan 换算,代码不再乘系数
+     * @return 发放结果(未发放时 earned=false 且带原因)
+     */
+    public CfEarnResult earnConsumeCf(Long userId, String orderType, Long orderId, Integer paidAmountCent) {
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("amt", paidAmountCent == null ? 0 : paidAmountCent);
+        ctx.put("order_type", orderType);
+        ctx.put("order_id", orderId);
+        return earnForUser(userId, RULE_F02, composeConsumeRefId(orderType, orderId), ctx);
+    }
+
+    /**
+     * F02 幂等 refId:业务订单 ID × 100 + 订单类型编码(跨业务表隔离,避免误判重复)。
+     */
+    public static Long composeConsumeRefId(String orderType, Long orderId) {
+        if (orderId == null || orderId <= 0) {
+            return null;
+        }
+        int namespace = ORDER_TYPE_NAMESPACE.getOrDefault(orderType == null ? "" : orderType,
+                UNKNOWN_ORDER_TYPE_NAMESPACE);
+        return orderId * 100 + namespace;
+    }
+
+    // ==================== F04 商品赠品 CF ====================
+
+    /**
+     * F04 商品赠品 CF:数量取赠品配置 product_order_gift.cf_value。
+     */
+    public CfEarnResult earnGiftCf(Long userId, Long giftId, Integer cfValue) {
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("amount", cfValue == null ? 0 : cfValue);
+        ctx.put("gift_id", giftId);
+        return earnForUser(userId, RULE_F04, giftId, ctx);
+    }
+
+    // ==================== F05 活动签到 CF ====================
+
+    /**
+     * F05 活动签到 CF:数量取活动配置 activities.platform_points,refId 为签到记录 ID。
+     */
+    public CfEarnResult earnActivityCheckinCf(Long userId, Long checkinId, Integer cfValue) {
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("amount", cfValue == null ? 0 : cfValue);
+        ctx.put("checkin_id", checkinId);
+        return earnForUser(userId, RULE_F05, checkinId, ctx);
+    }
+
+    // ==================== F06 菌群健康调研打卡返还 ====================
+
+    /**
+     * F06 菌群健康调研打卡返还:数量取项目返还金额(分),为空时由调用方按项目价(分)兜底。
+     */
+    public CfEarnResult earnGutHealthRefundCf(Long userId, Long projectId, Integer amountCent) {
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("amt", amountCent == null ? 0 : amountCent);
+        ctx.put("project_id", projectId);
+        return earnForUser(userId, RULE_F06, projectId, ctx);
+    }
+
+    // ==================== 通用发放 ====================
+
+    /**
+     * 按规则发放(用户维度 → 成员维度)。
+     *
+     * <p>找不到对应家庭成员时不发放(返回 earned=false + 原因),由调用方决定是否重试,
+     * 避免把 CF 记到错误成员账上。</p>
+     */
+    public CfEarnResult earnForUser(Long userId, String ruleCode, Long refId, Map<String, Object> ctx) {
+        if (refId == null) {
+            CfEarnResult skipped = skippedResult(ruleCode, null,
+                    "缺少业务单据 ID,无法保证幂等(userId=" + userId + ")");
+            log.warn("CF 发放跳过:userId={}, rule={}, reason={}", userId, ruleCode, skipped.getReason());
+            return skipped;
+        }
+        Long memberId = resolveMemberId(userId);
+        if (memberId == null) {
+            CfEarnResult skipped = skippedResult(ruleCode, refId,
+                    "未找到用户对应的家庭成员,无法发放 CF(userId=" + userId + ")");
+            log.warn("CF 发放跳过:userId={}, rule={}, refId={}, reason={}", userId, ruleCode, refId,
+                    skipped.getReason());
+            return skipped;
+        }
+        CfEarnResult result = cfBalanceService.earnByRule(memberId, ruleCode, refId, ctx);
+        if (result != null && Boolean.TRUE.equals(result.getEarned())) {
+            log.info("CF 发放完成:rule={}, memberId={}, refId={}, amount={}, duplicated={}",
+                    ruleCode, memberId, refId, result.getAmount(), result.getDuplicated());
+        } else if (result != null) {
+            log.info("CF 未发放:rule={}, memberId={}, refId={}, reason={}",
+                    ruleCode, memberId, refId, result.getReason());
+        }
+        return result;
+    }
+
+    /** 用户 → 家庭成员(family_members.user_id → id),登录账号自己的成员记录 */
+    private Long resolveMemberId(Long userId) {
+        if (userId == null || userId <= 0) {
+            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();
+    }
+
+    private CfEarnResult skippedResult(String ruleCode, Long refId, String reason) {
+        CfEarnResult result = new CfEarnResult();
+        result.setRuleCode(ruleCode);
+        result.setRefId(refId);
+        result.setEarned(false);
+        result.setDuplicated(false);
+        result.setReason(reason);
+        return result;
+    }
+}

+ 41 - 10
cfc-backend/src/main/java/com/etotem/cfc/service/GutHealthProjectService.java

@@ -2,6 +2,9 @@ package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.common.OrderNoGenerator;
+import com.etotem.cfc.config.ConfigService;
+import com.etotem.cfc.constant.CfConfigKeys;
+import com.etotem.cfc.dto.CfEarnResult;
 import com.etotem.cfc.dto.QuotaStatusDTO;
 import com.etotem.cfc.entity.*;
 import com.etotem.cfc.mapper.*;
@@ -30,8 +33,6 @@ import java.util.stream.Collectors;
 @Service
 public class GutHealthProjectService {
 
-    /** 默认项目价格(分) */
-    private static final int DEFAULT_PRICE = 1990;
     /** 任务列表默认总数(一周 7 天) */
     private static final int DEFAULT_TASK_TOTAL = 7;
 
@@ -48,11 +49,14 @@ public class GutHealthProjectService {
     private FamilyMembershipMapper familyMembershipMapper;
 
     @Resource
-    private PlatformPointsService platformPointsService;
+    private CfEarnDispatchService cfEarnDispatchService;
 
     @Resource
     private SysConfigService sysConfigService;
 
+    @Resource
+    private ConfigService configService;
+
     @Resource
     private MemberUpgradeRecordMapper memberUpgradeRecordMapper;
 
@@ -264,6 +268,14 @@ public class GutHealthProjectService {
         project.setUpdatedAt(new Date());
         projectMapper.insert(project);
 
+        // F02 消费返 CF:调研订单属消费订单(N6 所有消费都返),金额取订单实付(分)
+        try {
+            cfEarnDispatchService.earnConsumeCf(project.getUserId(), "gut_health", order.getId(), order.getAmount());
+        } catch (Exception e) {
+            log.error("调研订单返CF失败(F02): orderId={}, userId={}, error={}",
+                    order.getId(), project.getUserId(), e.getMessage());
+        }
+
         log.info("菌群健康项目创建成功: orderNo={}, projectId={}, userId={}, amount={}",
                 orderNo, project.getId(), project.getUserId(), order.getAmount());
         return true;
@@ -440,8 +452,13 @@ public class GutHealthProjectService {
             amount = getProjectPrice();
         }
         try {
-            platformPointsService.earn(project.getUserId(), amount, "GUT_HEALTH", project.getId(),
-                    "菌群健康调研一周打卡完成,返还CF值");
+            CfEarnResult result = cfEarnDispatchService.earnGutHealthRefundCf(
+                    project.getUserId(), project.getId(), amount);
+            if (result == null || !Boolean.TRUE.equals(result.getEarned())) {
+                log.warn("菌群健康项目CF返还未发放: userId={}, projectId={}, reason={}",
+                        project.getUserId(), project.getId(), result != null ? result.getReason() : "null");
+                return false;
+            }
             project.setCfRefunded(1);
             project.setUpdatedAt(new Date());
             projectMapper.updateById(project);
@@ -804,15 +821,29 @@ public class GutHealthProjectService {
         return project;
     }
 
+    /**
+     * 项目价格(分)。读取顺序:旧 sys_config.gut_health_project_price → service_config cf.product.gut_health_price
+     * (W0.2 已迁移,兜底 1990 分 = 19.9 元仅注册在 CfConfigKeys,代码内无硬编码常量)。
+     */
     private int getProjectPrice() {
-        String priceStr = sysConfigService.getValue("gut_health_project_price");
-        if (priceStr != null) {
+        String legacy = null;
+        try {
+            legacy = sysConfigService.getValue("gut_health_project_price");
+        } catch (Exception e) {
+            log.warn("读取 sys_config.gut_health_project_price 失败: {}", e.getMessage());
+        }
+        if (legacy != null && !legacy.trim().isEmpty()) {
             try {
-                return Integer.parseInt(priceStr);
-            } catch (NumberFormatException ignored) {
+                return Integer.parseInt(legacy.trim());
+            } catch (NumberFormatException e) {
+                log.warn("sys_config.gut_health_project_price 非整数:{}", legacy);
             }
         }
-        return DEFAULT_PRICE;
+        int price = configService.getIntConfig(CfConfigKeys.PRODUCT_GUT_HEALTH_PRICE, 0);
+        if (price <= 0) {
+            throw new IllegalStateException("缺少菌群健康项目价格配置: " + CfConfigKeys.PRODUCT_GUT_HEALTH_PRICE);
+        }
+        return price;
     }
 
     private Long getUserFamilyId(Long userId) {

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

@@ -141,7 +141,7 @@ public class ProductOrderService {
     private FamilyPlatformPointsService familyPlatformPointsService;
 
     @Resource
-    private PlatformPointsService platformPointsService;
+    private CfEarnDispatchService cfEarnDispatchService;
 
     @Resource
     private SupplyHierarchyService supplyHierarchyService;
@@ -1050,11 +1050,12 @@ public class ProductOrderService {
             }
         }
 
-        // CF值:统一分佣(当前人 + 推荐人)
+        // CF值:统一分佣(当前人 F02 按实付 + 推荐人按订单金额)
         try {
+            Integer paidCent = order.getMoneyAmount() != null ? order.getMoneyAmount() : order.getTotalAmount();
             cfCommissionService.settle(order.getId(), "product", order.getBuyerId(),
                     userMapper.selectById(order.getBuyerId()).getFamilyId(),
-                    order.getTotalAmount(), order.getProductId());
+                    order.getTotalAmount(), order.getProductId(), paidCent);
         } catch (Exception e) {
             log.error("CF值分佣失败: orderId={}, error={}", order.getId(), e.getMessage());
         }
@@ -1163,9 +1164,13 @@ public class ProductOrderService {
                             .eq(ProductOrderGift::getFulfilled, 0));
             for (ProductOrderGift gift : pendingGifts) {
                 if ("cf".equals(gift.getGiftType()) && gift.getCfValue() != null && gift.getCfValue() > 0) {
-                    platformPointsService.earn(order.getBuyerId(), gift.getCfValue(),
-                            "product_gift", gift.getId(),
-                            "购买商品赠品: " + (gift.getGiftProductName() != null ? gift.getGiftProductName() : "CF值"));
+                    // F04 商品赠品 CF:数量取赠品配置,统一账本发放(幂等键:赠品 ID)
+                    try {
+                        cfEarnDispatchService.earnGiftCf(order.getBuyerId(), gift.getId(), gift.getCfValue());
+                    } catch (Exception e) {
+                        log.error("赠品CF值发放失败: orderId={}, giftId={}, error={}",
+                                order.getId(), gift.getId(), e.getMessage());
+                    }
                 } else if ("product".equals(gift.getGiftType()) && gift.getGiftProductId() != null) {
                     int qty = gift.getQuantity() != null ? gift.getQuantity() : 1;
                     boolean stockOk = productService.decreaseStock(gift.getGiftProductId(), qty);
@@ -1554,25 +1559,6 @@ public class ProductOrderService {
         order.setUpdatedAt(new Date());
         orderMapper.updateById(order);
 
-        // 家庭池 CF 值发放:floor(商品原价(元)) × points_multiplier
-        try {
-            Integer totalAmount = order.getTotalAmount();
-            if (totalAmount != null && totalAmount > 0 && order.getFamilyId() != null) {
-                Product p = productMapper.selectById(order.getProductId());
-                if (p != null && p.getPointsMultiplier() != null) {
-                    int earned = (totalAmount / 100) * p.getPointsMultiplier().intValue();
-                    if (earned > 0) {
-                        familyPlatformPointsService.earn(
-                                order.getFamilyId(), earned,
-                                "product_order", order.getId(),
-                                "购买商品: " + order.getProductName());
-                    }
-                }
-            }
-        } catch (Exception e) {
-            log.error("家庭池CF值发放失败: orderId={}, error={}", order.getId(), e.getMessage());
-        }
-
         return Result.success("已确认收货");
     }
 

+ 269 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportAnalyzeQuotaService.java

@@ -0,0 +1,269 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.dto.ReportAnalyzeQuotaDTO;
+import com.etotem.cfc.entity.MembershipLevel;
+import com.etotem.cfc.mapper.MembershipLevelMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.time.Duration;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * 报告快速分析配额服务(Redis 日计数)。
+ *
+ * <p>规格:{@code docs/superpowers/specs/2026-09-10-report-quick-analyze-design.md} §3.3、
+ * {@code 2026-09-13-report-quick-analyze-extend-design.md} §5。
+ *
+ * <p>口径:
+ * <ul>
+ *   <li>免费版(FREE,含 PROVIDER 归一化后的 FREE):每日 {@value #FREE_DAILY_LIMIT} 次,服务端硬编码,
+ *       不随 membership_levels 配置变化;</li>
+ *   <li>付费版:上限取 {@code membership_levels.max_daily_analyze};0 或 NULL = 不限次
+ *       (保留既有「付费版多轮对话不限制次数」口径);</li>
+ *   <li>日计数器 key:{@code report:analyze:{userId}:{yyyyMMdd}},INCR + 24h EXPIRE,按天自然滚动,
+ *       无定时任务;TTL 到期即等价于额度重置;</li>
+ *   <li>付费多轮:同一 {@code conversationId} 仅计 1 次({@code SADD} 原子判重 + 同日去重 set),
+ *       后续追问不重复扣次数;免费用户不做会话判重(后端已剥离 conversationId,天然单次);</li>
+ *   <li>Redis 异常 <strong>fail-open</strong> 放行(避免免费用户因 Redis 故障完全不可用),记 warn。</li>
+ * </ul>
+ *
+ * <p>范围说明:报告反馈(report_feedback)沿用其独立的 {@code report:feedback:free:} 计数器,
+ * 不由本服务管理(见 {@link ReportFeedbackService})。
+ */
+@Slf4j
+@Service
+public class ReportAnalyzeQuotaService {
+
+    /** 配额类型 */
+    public static final String QUOTA_TYPE = "REPORT_ANALYZE";
+
+    /** 免费版每日次数上限(硬编码,不读配置表) */
+    public static final int FREE_DAILY_LIMIT = 1;
+
+    /** 免费版等级编码(MembershipService 已将 PROVIDER 归一化为 FREE) */
+    private static final String LEVEL_FREE = "FREE";
+    private static final String LEVEL_PROVIDER = "PROVIDER";
+
+    /** 日计数器 key 前缀:report:analyze:{userId}:{yyyyMMdd} */
+    private static final String COUNTER_KEY_PREFIX = "report:analyze:";
+
+    /** 会话去重 set key 前缀:report:analyze:session:{yyyyMMdd}:{conversationId} */
+    private static final String SESSION_KEY_PREFIX = "report:analyze:session:";
+
+    private static final DateTimeFormatter PERIOD_FMT = DateTimeFormatter.ofPattern("yyyyMMdd");
+
+    /** TTL:略大于 1 天,保证当日末尾读到的仍是当日计数 */
+    private static final Duration TTL = Duration.ofDays(1);
+
+    /** 升级引导路径(与前端 pages/membership 分包约定一致) */
+    public static final String UPGRADE_URL = "/pages/membership/upgrade";
+
+    /** 免费版超限文案:含「今日已用完」,与付费版文案可区分 */
+    public static final String MSG_FREE_EXHAUSTED =
+            "免费版今日已用完(" + FREE_DAILY_LIMIT + " 次/天),升级会员解锁更多";
+
+    /** 付费版超限文案:含「请升级会员」,与免费版文案可区分 */
+    public static final String MSG_PAID_EXHAUSTED =
+            "请升级会员以获取更多快速分析次数";
+
+    @Resource
+    private RedisTemplate<String, Object> redisTemplate;
+
+    @Resource
+    private MembershipService membershipService;
+
+    @Resource
+    private MembershipLevelMapper membershipLevelMapper;
+
+    /**
+     * 查询当前用户的报告快速分析配额状态(只读,不扣减)。
+     * 用于前端在发起分析前展示剩余次数。
+     */
+    public ReportAnalyzeQuotaDTO getStatus(Long userId) {
+        ReportAnalyzeQuotaDTO dto = buildBase(userId);
+        if (!dto.isUnlimited()) {
+            dto.setUsed(readUsed(userId));
+        }
+        applyRemaining(dto);
+        return dto;
+    }
+
+    /**
+     * 尝试扣减一次配额。
+     *
+     * @param userId         当前登录用户 ID
+     * @param conversationId 会话 ID(付费版多轮去重用);为空时按单次计费
+     * @return 配额结果。{@code allowed=true} 表示放行;{@code allowed=false} 时
+     *         {@code message} 为超限文案(免费版 / 付费版文案不同),{@code actionUrl} 为升级路径
+     */
+    public ReportAnalyzeQuotaDTO consume(Long userId, String conversationId) {
+        ReportAnalyzeQuotaDTO dto = buildBase(userId);
+        boolean free = isFreeLevel(dto.getLevelCode());
+
+        if (dto.isUnlimited()) {
+            // 付费等级未配置上限:放行且不落计数器(避免 key 无限增长)
+            dto.setAllowed(true);
+            return dto;
+        }
+
+        String counterKey = counterKey(userId);
+        String sessionKey = free ? null : sessionKey(conversationId);
+
+        try {
+            // 付费多轮:同一会话内只计 1 次(SADD 返回 0 = 该会话已计过费)
+            if (sessionKey != null) {
+                Long added = redisTemplate.opsForSet().add(sessionKey, String.valueOf(userId));
+                if (added != null) {
+                    redisTemplate.expire(sessionKey, TTL);
+                }
+                if (added != null && added == 0L) {
+                    dto.setUsed(readUsed(userId));
+                    applyRemaining(dto);
+                    dto.setAllowed(true);
+                    return dto;
+                }
+            }
+
+            Long count = redisTemplate.opsForValue().increment(counterKey);
+            if (count != null && count == 1L) {
+                redisTemplate.expire(counterKey, TTL);
+            }
+            int used = (count != null) ? count.intValue() : readUsed(userId);
+            dto.setUsed(used);
+
+            if (used > dto.getTotal()) {
+                // 本次未扣成功:回滚会话标记,使同一会话仍可再次作为新一轮计费
+                if (sessionKey != null) {
+                    redisTemplate.opsForSet().remove(sessionKey, String.valueOf(userId));
+                }
+                applyRemaining(dto);
+                dto.setAllowed(false);
+                dto.setMessage(free ? MSG_FREE_EXHAUSTED : MSG_PAID_EXHAUSTED);
+                dto.setActionUrl(UPGRADE_URL);
+                log.info("报告快速分析配额超限: userId={}, level={}, used={}, total={}, free={}",
+                        userId, dto.getLevelCode(), used, dto.getTotal(), free);
+                return dto;
+            }
+
+            applyRemaining(dto);
+            dto.setAllowed(true);
+            return dto;
+        } catch (Exception e) {
+            // fail-open:Redis 异常时放行,避免免费用户因 Redis 故障完全不可用
+            log.warn("报告快速分析配额计数失败,放行: userId={}, err={}", userId, e.getMessage());
+            dto.setUsed(readUsed(userId));
+            applyRemaining(dto);
+            dto.setAllowed(true);
+            return dto;
+        }
+    }
+
+    /**
+     * 是否为免费版(含 PROVIDER 归一化结果)。免费版锁死多轮,后端需剥离 conversationId。
+     */
+    public boolean isFreeLevel(String levelCode) {
+        return LEVEL_FREE.equals(levelCode) || LEVEL_PROVIDER.equals(levelCode);
+    }
+
+    // ==================== 内部方法 ====================
+
+    /**
+     * 构建配额 DTO 基础信息(等级 + 每日上限 + 周期键)。
+     */
+    private ReportAnalyzeQuotaDTO buildBase(Long userId) {
+        ReportAnalyzeQuotaDTO dto = new ReportAnalyzeQuotaDTO();
+        dto.setQuotaType(QUOTA_TYPE);
+        dto.setPeriodKey(LocalDate.now().format(PERIOD_FMT));
+        String level = resolveLevel(userId);
+        dto.setLevelCode(level);
+        int limit = isFreeLevel(level) ? FREE_DAILY_LIMIT : resolveConfigLimit(level);
+        dto.setTotal(limit);
+        dto.setUnlimited(limit <= 0);
+        if (limit <= 0) {
+            dto.setRemaining(-1);
+            dto.setExhausted(false);
+        }
+        return dto;
+    }
+
+    /**
+     * 取用户当前有效会员等级;异常/空用户按免费版处理(最保守)。
+     */
+    private String resolveLevel(Long userId) {
+        if (userId == null) {
+            return LEVEL_FREE;
+        }
+        try {
+            String level = membershipService.getMemberLevel(userId);
+            return (level == null || level.isEmpty()) ? LEVEL_FREE : level;
+        } catch (Exception e) {
+            log.warn("获取会员等级失败,按免费版处理: userId={}, err={}", userId, e.getMessage());
+            return LEVEL_FREE;
+        }
+    }
+
+    /**
+     * 读取会员等级配置的每日上限;0 或 NULL 表示不限次;读取失败按不限次兜底。
+     */
+    private int resolveConfigLimit(String levelCode) {
+        try {
+            MembershipLevel lv = membershipLevelMapper.selectOne(
+                    new LambdaQueryWrapper<MembershipLevel>()
+                            .eq(MembershipLevel::getLevelCode, levelCode)
+                            .last("LIMIT 1"));
+            if (lv == null || lv.getMaxDailyAnalyze() == null) {
+                return 0;
+            }
+            return lv.getMaxDailyAnalyze();
+        } catch (Exception e) {
+            log.warn("读取 max_daily_analyze 失败,按不限次处理: level={}, err={}",
+                    levelCode, e.getMessage());
+            return 0;
+        }
+    }
+
+    /**
+     * 读取今日已用次数;Redis 异常时按 0 处理。
+     */
+    private int readUsed(Long userId) {
+        try {
+            Object v = redisTemplate.opsForValue().get(counterKey(userId));
+            if (v == null) {
+                return 0;
+            }
+            return Integer.parseInt(String.valueOf(v));
+        } catch (Exception e) {
+            return 0;
+        }
+    }
+
+    private void applyRemaining(ReportAnalyzeQuotaDTO dto) {
+        if (dto.isUnlimited()) {
+            dto.setRemaining(-1);
+            dto.setExhausted(false);
+            return;
+        }
+        int remaining = Math.max(0, dto.getTotal() - dto.getUsed());
+        dto.setRemaining(remaining);
+        dto.setExhausted(remaining <= 0);
+        if (dto.isExhausted()) {
+            dto.setActionUrl(UPGRADE_URL);
+        }
+    }
+
+    private String counterKey(Long userId) {
+        return COUNTER_KEY_PREFIX + userId + ":" + LocalDate.now().format(PERIOD_FMT);
+    }
+
+    private String sessionKey(String conversationId) {
+        if (conversationId == null || conversationId.trim().isEmpty()) {
+            return null;
+        }
+        return SESSION_KEY_PREFIX + LocalDate.now().format(PERIOD_FMT) + ":" + conversationId.trim();
+    }
+}

+ 1 - 0
cfc-backend/src/main/resources/schema.sql

@@ -4028,6 +4028,7 @@ CREATE TABLE IF NOT EXISTS membership_levels (
     features TEXT COMMENT '功能权限列表(JSON)',
     max_children INT DEFAULT 0 COMMENT '可添加孩子数量',
     max_tasks_per_day INT DEFAULT 0 COMMENT '每日任务上限',
+    max_daily_analyze INT DEFAULT NULL COMMENT '每日报告解析次数上限(0/NULL=不限)',
     ai_review_enabled TINYINT(1) DEFAULT 0 COMMENT 'AI审核功能',
     priority_support TINYINT(1) DEFAULT 0 COMMENT '优先客服',
     teacher_consultation TINYINT(1) DEFAULT 0 COMMENT '老师咨询',

+ 41 - 27
cfc-backend/src/test/java/com/etotem/cfc/service/CfCommissionServiceSettleTest.java

@@ -16,7 +16,6 @@ import com.etotem.cfc.entity.PpointConsumptionLog;
 import com.etotem.cfc.entity.Product;
 import com.etotem.cfc.entity.ReferralTree;
 import com.etotem.cfc.entity.User;
-import com.etotem.cfc.mapper.CfTransferRecordMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.OctopusEffectKeyPersonMapper;
 import com.etotem.cfc.mapper.OctopusEffectRecordMapper;
@@ -41,8 +40,8 @@ import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
-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;
@@ -52,17 +51,18 @@ import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 /**
- * CF 分佣收口(F-4)两级销售奖结算行为测试。
+ * CF 分佣(F-3a 买家 F02 + F-4 两级销售奖收口)结算行为测试。
  *
- * <p>覆盖口径(计划 §四 / §3.1 / §4.2 / §4.4):</p>
+ * <p>覆盖口径:</p>
  * <ul>
+ *   <li>当前消费人返 CF 统一走 F02({@link CfEarnDispatchService#earnConsumeCf},按实付金额),
+ *       不再直写旧账本;会员/订阅订单当前人也返(N6);</li>
  *   <li>第 1 个非同家庭引荐人 → F03a 直接销售奖,比例取"团队 8 层内滚动一年累计 P 点"档位;</li>
  *   <li>第 2 个非同家庭引荐人 → F03b 间接销售奖,比例取"自身滚动一年累计消费 P 点"档位;</li>
- *   <li>共同基数 = 本单消费产生的 P 点(商品按有效 P 点比例折算);</li>
+ *   <li>共同基数 = 本单消费产生的 P 点(商品按有效 P 点比例折算,商品订单实付与分润基数分离);</li>
  *   <li>同家庭互推(D5)跳过并顺延,最多两级;</li>
  *   <li>档位比例超 {@code cf_earn_rule.params_json.max_rate_percent} 时按上限封顶;</li>
- *   <li>发放统一走 F-2 的 {@code earnByRule}(幂等键复用 F-2 唯一键);销售奖不再写第二套流水
- *       ({@code cf_transfer_record} 仅买家 F02 路径写);</li>
+ *   <li>销售奖发放统一走 F-2 的 {@code earnByRule}(幂等键复用 F-2 唯一键),不写第二套流水;</li>
  *   <li>F03a 发放时创建感恩记录,幂等命中(duplicated)不重复创建。</li>
  * </ul>
  */
@@ -75,14 +75,10 @@ class CfCommissionServiceSettleTest {
     @Mock
     private CfReferralService cfReferralService;
     @Mock
-    private PlatformPointsService platformPointsService;
-    @Mock
-    private CfTransferRecordMapper cfTransferRecordMapper;
+    private CfEarnDispatchService cfEarnDispatchService;
     @Mock
     private PpointConfigService ppointConfigService;
     @Mock
-    private SysConfigService sysConfigService;
-    @Mock
     private ProductMapper productMapper;
     @Mock
     private OctopusKeyPersonMapper octopusKeyPersonMapper;
@@ -129,9 +125,6 @@ class CfCommissionServiceSettleTest {
         p.setCategoryId(1L);
         when(productMapper.selectById(PRODUCT_ID)).thenReturn(p);
         when(ppointConfigService.getEffectivePpoint(PRODUCT_ID, 1L)).thenReturn(100);
-        // 买家 F02 路径(本期不改口径)
-        when(sysConfigService.getValue("product_platform_points_share")).thenReturn("1000");
-        when(cfTransferRecordMapper.insert(any())).thenReturn(1);
         // 非商品路径的服务费率(L2 配置中心)
         when(configService.getIntConfig(CfConfigKeys.COMMISSION_SERVICE_RATE, 1000)).thenReturn(1000);
 
@@ -272,12 +265,12 @@ class CfCommissionServiceSettleTest {
         assertEquals(100, ctxCaptor2.getValue().get("base_ppoints"));
         assertEquals(5, ctxCaptor2.getValue().get("rate_percent"));
 
-        // 买家 F02 返 CF(本期口径不变):100 元 × 100P点/100 × 10% = 10
-        verify(platformPointsService).earn(eq(BUYER_ID), eq(10), eq("order_consume"), eq(ORDER_ID), anyString());
+        // 当前消费人返 CF 走 F02 统一账本:实付 10000 分
+        verify(cfEarnDispatchService).earnConsumeCf(eq(BUYER_ID), eq("product"), eq(ORDER_ID), eq(10000));
     }
 
     @Test
-    void settle_销售奖只走cfBalance_不再写旧流转记录() {
+    void settle_推荐人分润只走earnByRule_不再写旧分佣流水() {
         mockReferralChain(ref(10L, 1), ref(20L, 2));
         when(userMapper.selectById(10L)).thenReturn(user(10L, 200L));
         when(userMapper.selectById(20L)).thenReturn(user(20L, 300L));
@@ -290,8 +283,28 @@ class CfCommissionServiceSettleTest {
 
         cfCommissionService.settle(ORDER_ID, "product", BUYER_ID, BUYER_FAMILY, 10000, PRODUCT_ID);
 
-        // cf_transfer_record 只写买家 F02 一笔,销售奖不再产生第二套流水
-        verify(cfTransferRecordMapper, times(1)).insert(any());
+        // 推荐人只走 earnByRule(F03a/F03b),且买家 F02 仅调用一次(消费人本人)
+        verify(cfBalanceService, times(2)).earnByRule(anyLong(), anyString(), anyLong(), any());
+        verify(cfEarnDispatchService, times(1)).earnConsumeCf(anyLong(), anyString(), anyLong(), anyInt());
+    }
+
+    @Test
+    void settle_商品订单实付与分润基数分离() {
+        mockReferralChain(ref(10L, 1));
+        when(userMapper.selectById(10L)).thenReturn(user(10L, 200L));
+        when(cfReferralService.getTeamAnnualPpoints(10L, 8)).thenReturn(2000);
+        when(cfReferralService.matchDirectSalesTier(2000)).thenReturn(directTier("黄金", 2000, 40));
+        when(cfBalanceService.earnByRule(eq(memberIdOf(10L)), eq("F03a"), eq(ORDER_ID), any()))
+                .thenReturn(earnResult(48, false));
+
+        // 分润基数 12000 分(120 元 → 120 P 点),实付 10000 分
+        cfCommissionService.settle(ORDER_ID, "product", BUYER_ID, BUYER_FAMILY, 12000, PRODUCT_ID, 10000);
+
+        verify(cfEarnDispatchService).earnConsumeCf(eq(BUYER_ID), eq("product"), eq(ORDER_ID), eq(10000));
+        ArgumentCaptor<Map<String, Object>> captor = ArgumentCaptor.forClass(Map.class);
+        verify(cfBalanceService).earnByRule(eq(memberIdOf(10L)), eq("F03a"), eq(ORDER_ID), captor.capture());
+        assertEquals(120, captor.getValue().get("base_ppoints"));
+        assertEquals(40, captor.getValue().get("rate_percent"));
     }
 
     @Test
@@ -364,6 +377,8 @@ class CfCommissionServiceSettleTest {
         cfCommissionService.settle(ORDER_ID, "product", BUYER_ID, BUYER_FAMILY, 10000, PRODUCT_ID);
 
         verify(cfBalanceService, never()).earnByRule(anyLong(), anyString(), anyLong(), any());
+        // 买家 F02 仍正常返
+        verify(cfEarnDispatchService).earnConsumeCf(eq(BUYER_ID), eq("product"), eq(ORDER_ID), eq(10000));
     }
 
     @Test
@@ -423,20 +438,19 @@ class CfCommissionServiceSettleTest {
     }
 
     @Test
-    void settleReferrerOnly_只发销售奖_不返买家CF() {
+    void settleReferrerOnly_会员订阅订单_当前人也返F02() {
         mockReferralChain(ref(10L, 1));
         when(userMapper.selectById(10L)).thenReturn(user(10L, 200L));
         when(cfReferralService.getTeamAnnualPpoints(10L, 8)).thenReturn(100);
         when(cfReferralService.matchDirectSalesTier(100)).thenReturn(directTier("青铜", 100, 10));
-        when(cfBalanceService.earnByRule(anyLong(), anyString(), anyLong(), any()))
+        when(cfBalanceService.earnByRule(eq(memberIdOf(10L)), eq("F03a"), eq(ORDER_ID), any()))
                 .thenReturn(earnResult(10, false));
 
         cfCommissionService.settleReferrerOnly(ORDER_ID, "membership", BUYER_ID, BUYER_FAMILY, 10000);
 
-        verify(platformPointsService, never()).earn(anyLong(), org.mockito.ArgumentMatchers.anyInt(),
-                anyString(), any(), anyString());
-        verify(cfBalanceService).earnByRule(eq(memberIdOf(10L)), eq("F03a"), eq(ORDER_ID), any());
-        // 非商品订单基数:100 元 × 1000bps / 10000 = 10 P 点
+        // N6:会员/订阅订单当前消费人也返 F02(按实付金额)
+        verify(cfEarnDispatchService).earnConsumeCf(eq(BUYER_ID), eq("membership"), eq(ORDER_ID), eq(10000));
+        // 推荐人 F03a:非商品基数 = 100 元 × 1000bps / 10000 = 10 P 点
         ArgumentCaptor<Map<String, Object>> captor = ArgumentCaptor.forClass(Map.class);
         verify(cfBalanceService).earnByRule(eq(memberIdOf(10L)), eq("F03a"), eq(ORDER_ID), captor.capture());
         assertEquals(10, captor.getValue().get("base_ppoints"));
@@ -449,6 +463,6 @@ class CfCommissionServiceSettleTest {
         cfCommissionService.settle(ORDER_ID, "product", BUYER_ID, BUYER_FAMILY, 10000, PRODUCT_ID);
 
         verify(cfBalanceService, never()).earnByRule(anyLong(), anyString(), anyLong(), any());
-        assertTrue(true);
+        verify(cfEarnDispatchService).earnConsumeCf(eq(BUYER_ID), eq("product"), eq(ORDER_ID), eq(10000));
     }
 }

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

@@ -0,0 +1,272 @@
+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.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.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+/**
+ * 统一发放分派服务(CfEarnDispatchService)单元测试:
+ *  - F02 消费返 CF / F04 商品赠品 / F05 活动签到 / F06 菌群调研返还 四条途径发放;
+ *  - 数量口径:F02/F06 实付分→CF 1:1(amt_unit=cent + cf_per_yuan=100),F04/F05 取 per_item 配置;
+ *  - 幂等键(member_id, type, ref_type, ref_id)生效:重复触发不重复入账;
+ *  - F02 跨订单类型 refId 命名空间隔离(不同业务表自增 ID 会重复);
+ *  - 用户未映射到家庭成员时不发放。
+ */
+class CfEarnDispatchServiceTest {
+
+    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 CfEarnDispatchService dispatchService = new CfEarnDispatchService();
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        // MyBatis-Plus LambdaQueryWrapper 需要实体元数据(参照 CfBalanceServiceTest)
+        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, 0, 0, 0, 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;
+        });
+        when(familyMemberMapper.selectById(MEMBER_ID)).thenReturn(member(MEMBER_ID, FAMILY_ID));
+        when(configService.getConfig("cf.rate.cf_per_yuan")).thenReturn("100");
+        when(membershipService.getCurrentLevel(FAMILY_ID)).thenReturn(null);
+
+        // 规则种子 stub(与迁移408一致的口径):F02/F06 formula floor(amt)+amt_unit=cent;F04/F05 per_item
+        when(cfEarnRuleService.getByCode("F02"))
+                .thenReturn(rule("F02", "formula", "floor(amt)",
+                        "{\"cf_per_yuan\":100,\"amt_unit\":\"cent\"}", null, null, null, "cf_consume_order"));
+        when(cfEarnRuleService.getByCode("F04"))
+                .thenReturn(rule("F04", "per_item", null,
+                        "{\"source\":\"product_order_gift.cf_value\"}", null, null, null, "product_order_gift"));
+        when(cfEarnRuleService.getByCode("F05"))
+                .thenReturn(rule("F05", "per_item", null,
+                        "{\"source\":\"activities.platform_points\"}", null, null, null, "activity_checkin"));
+        when(cfEarnRuleService.getByCode("F06"))
+                .thenReturn(rule("F06", "formula", "floor(amt)",
+                        "{\"cf_per_yuan\":100,\"amt_unit\":\"cent\"}", null, null, null, "gut_health_refund"));
+
+        // 装配真实账本实现 + 分派服务
+        ReflectionTestUtils.setField(cfBalanceService, "cfBalanceMapper", cfBalanceMapper);
+        ReflectionTestUtils.setField(cfBalanceService, "cfBalanceLogMapper", cfBalanceLogMapper);
+        ReflectionTestUtils.setField(cfBalanceService, "cfEarnDailyLimitMapper", cfEarnDailyLimitMapper);
+        ReflectionTestUtils.setField(cfBalanceService, "cfEarnRuleService", cfEarnRuleService);
+        ReflectionTestUtils.setField(cfBalanceService, "familyMemberMapper", familyMemberMapper);
+        ReflectionTestUtils.setField(cfBalanceService, "membershipService", membershipService);
+        ReflectionTestUtils.setField(cfBalanceService, "configService", configService);
+        ReflectionTestUtils.setField(cfBalanceService, "objectMapper", objectMapper);
+
+        ReflectionTestUtils.setField(dispatchService, "cfBalanceService", cfBalanceService);
+        ReflectionTestUtils.setField(dispatchService, "familyMemberMapper", familyMemberMapper);
+
+        // 默认:用户 100 → 成员 1(登录账号自己的家庭成员记录)
+        FamilyMember resolved = new FamilyMember();
+        resolved.setId(MEMBER_ID);
+        resolved.setUserId(USER_ID);
+        resolved.setFamilyId(FAMILY_ID);
+        when(familyMemberMapper.selectOne(any())).thenReturn(resolved);
+    }
+
+    // ---------- F02 消费返 CF ----------
+
+    @Test
+    void earnConsumeCf_商品订单_按实付分1比1换算CF() {
+        // 199 元实付 = 19900 分 → 19900 CF(1 元 = 100 CF)
+        CfEarnResult r = dispatchService.earnConsumeCf(USER_ID, "product", 7L, 19900);
+
+        assertTrue(r.getEarned());
+        assertEquals(19900, r.getAmount());
+        assertEquals("cf_consume_order", r.getRefType());
+        assertEquals(701L, r.getRefId()); // 7×100 + product(1)
+        assertEquals(19900, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
+
+        CfBalanceLog log = insertedLogs.get(0);
+        assertEquals("earn", log.getType());
+        assertEquals("cf_consume_order", log.getRefType());
+        assertEquals(701L, log.getRefId());
+        assertEquals(19900, log.getAmount());
+        assertEquals(MEMBER_ID, log.getMemberId());
+    }
+
+    @Test
+    void earnConsumeCf_重复触发_幂等键生效不重复发放() {
+        dispatchService.earnConsumeCf(USER_ID, "product", 7L, 19900);
+        CfEarnResult second = dispatchService.earnConsumeCf(USER_ID, "product", 7L, 19900);
+
+        assertTrue(second.getEarned());
+        assertTrue(second.getDuplicated());
+        assertEquals(1, insertedLogs.size());
+        assertEquals(19900, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
+    }
+
+    @Test
+    void composeConsumeRefId_跨订单类型不冲突() {
+        // 商品订单 #1 与套餐订单 #1 分属不同业务表,refId 必须隔离,避免幂等误判
+        assertNotEquals(CfEarnDispatchService.composeConsumeRefId("product", 1L),
+                CfEarnDispatchService.composeConsumeRefId("package", 1L));
+        assertEquals(Long.valueOf(101L), CfEarnDispatchService.composeConsumeRefId("product", 1L));
+        assertEquals(Long.valueOf(102L), CfEarnDispatchService.composeConsumeRefId("package", 1L));
+        assertEquals(Long.valueOf(106L), CfEarnDispatchService.composeConsumeRefId("gut_health", 1L));
+    }
+
+    // ---------- F04 商品赠品 CF ----------
+
+    @Test
+    void earnGiftCf_赠品CF_取赠品配置数量() {
+        CfEarnResult r = dispatchService.earnGiftCf(USER_ID, 55L, 500);
+
+        assertTrue(r.getEarned());
+        assertEquals(500, r.getAmount());
+        assertEquals("product_order_gift", r.getRefType());
+        assertEquals(55L, r.getRefId());
+        assertEquals(500, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
+    }
+
+    // ---------- F05 活动签到 CF ----------
+
+    @Test
+    void earnActivityCheckinCf_签到CF_取活动配置数量() {
+        CfEarnResult r = dispatchService.earnActivityCheckinCf(USER_ID, 88L, 300);
+
+        assertTrue(r.getEarned());
+        assertEquals(300, r.getAmount());
+        assertEquals("activity_checkin", r.getRefType());
+        assertEquals(88L, r.getRefId());
+        assertEquals(300, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
+    }
+
+    // ---------- F06 菌群健康调研打卡返还 ----------
+
+    @Test
+    void earnGutHealthRefundCf_调研返还_项目价19_9元得1990CF() {
+        CfEarnResult r = dispatchService.earnGutHealthRefundCf(USER_ID, 66L, 1990);
+
+        assertTrue(r.getEarned());
+        assertEquals(1990, r.getAmount());
+        assertEquals("gut_health_refund", r.getRefType());
+        assertEquals(66L, r.getRefId());
+        assertEquals(1990, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
+    }
+
+    @Test
+    void earnGutHealthRefundCf_重复触发_幂等不重复返还() {
+        dispatchService.earnGutHealthRefundCf(USER_ID, 66L, 1990);
+        CfEarnResult second = dispatchService.earnGutHealthRefundCf(USER_ID, 66L, 1990);
+
+        assertTrue(second.getDuplicated());
+        assertEquals(1, insertedLogs.size());
+        assertEquals(1990, cfBalanceService.getBalance(MEMBER_ID).getAvailable());
+    }
+
+    // ---------- 成员解析 ----------
+
+    @Test
+    void earnForUser_未找到家庭成员_不发放() {
+        when(familyMemberMapper.selectOne(any())).thenReturn(null);
+
+        CfEarnResult r = dispatchService.earnConsumeCf(999L, "product", 1L, 100);
+
+        assertFalse(r.getEarned());
+        assertTrue(r.getReason().contains("家庭成员"));
+        assertEquals(0, insertedLogs.size());
+    }
+
+    // ---------- 构造 ----------
+
+    private CfBalance balance(long memberId, int available, int frozen, int totalEarned, int withdrawn, int exchanged) {
+        CfBalance b = new CfBalance();
+        b.setId(memberId);
+        b.setMemberId(memberId);
+        b.setUserId(USER_ID);
+        b.setFamilyId(FAMILY_ID);
+        b.setAvailable(available);
+        b.setFrozen(frozen);
+        b.setTotalEarned(totalEarned);
+        b.setWithdrawn(withdrawn);
+        b.setExchanged(exchanged);
+        return b;
+    }
+
+    private FamilyMember member(long memberId, long familyId) {
+        FamilyMember m = new FamilyMember();
+        m.setId(memberId);
+        m.setFamilyId(familyId);
+        m.setUserId(USER_ID);
+        return m;
+    }
+
+    private CfEarnRule rule(String code, String ruleType, String formula, String paramsJson,
+                            Integer dailyLimit, Integer totalLimit, Integer cooldownSeconds, String dedupRefType) {
+        CfEarnRule r = new CfEarnRule();
+        r.setCode(code);
+        r.setName(code + "-规则");
+        r.setCategory("consume");
+        r.setEnabled(1);
+        r.setRuleType(ruleType);
+        r.setFormula(formula);
+        r.setParamsJson(paramsJson);
+        r.setDailyLimitPerMember(dailyLimit);
+        r.setTotalLimitPerMember(totalLimit);
+        r.setCooldownSeconds(cooldownSeconds);
+        r.setDedupRefType(dedupRefType);
+        return r;
+    }
+}

+ 197 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/ReportAnalyzeQuotaServiceTest.java

@@ -0,0 +1,197 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.ReportAnalyzeQuotaDTO;
+import com.etotem.cfc.entity.MembershipLevel;
+import com.etotem.cfc.mapper.MembershipLevelMapper;
+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.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.SetOperations;
+import org.springframework.data.redis.core.ValueOperations;
+
+import java.lang.reflect.Field;
+import java.time.Duration;
+
+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.anyString;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * ReportAnalyzeQuotaService 单元测试(纯 Mockito,无 SpringBootTest、无真实 Redis)。
+ *
+ * 覆盖验收口径:
+ * <ul>
+ *   <li>免费版当日第 2 次被拒(文案「今日已用完」,与付费版「请升级会员」可区分);</li>
+ *   <li>计数 key 到期(次日新 key)后免费额度自动恢复;key 格式 report:analyze:{userId}:{yyyyMMdd};</li>
+ *   <li>付费版多轮:同一 conversationId 只计 1 次;</li>
+ *   <li>付费版超限被拒(文案「请升级会员」)且回滚会话标记;</li>
+ *   <li>付费版上限 0 = 不限次:放行且不落计数器;</li>
+ *   <li>只读查询不扣减次数。</li>
+ * </ul>
+ */
+public class ReportAnalyzeQuotaServiceTest {
+
+    @Mock
+    private RedisTemplate<String, Object> redisTemplate;
+
+    @Mock
+    private ValueOperations<String, Object> valueOps;
+
+    @Mock
+    private SetOperations<String, Object> setOps;
+
+    @Mock
+    private MembershipService membershipService;
+
+    @Mock
+    private MembershipLevelMapper membershipLevelMapper;
+
+    private ReportAnalyzeQuotaService service;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+        ReportAnalyzeQuotaService raw = new ReportAnalyzeQuotaService();
+        setField(raw, "redisTemplate", redisTemplate);
+        setField(raw, "membershipService", membershipService);
+        setField(raw, "membershipLevelMapper", membershipLevelMapper);
+        service = raw;
+
+        when(redisTemplate.opsForValue()).thenReturn(valueOps);
+        when(redisTemplate.opsForSet()).thenReturn(setOps);
+        when(redisTemplate.expire(anyString(), any(Duration.class))).thenReturn(Boolean.TRUE);
+    }
+
+    private void setField(Object target, String fieldName, Object value) {
+        try {
+            Field f = target.getClass().getDeclaredField(fieldName);
+            f.setAccessible(true);
+            f.set(target, value);
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private MembershipLevel level(String code, Integer maxDailyAnalyze) {
+        MembershipLevel lv = new MembershipLevel();
+        lv.setLevelCode(code);
+        lv.setMaxDailyAnalyze(maxDailyAnalyze);
+        return lv;
+    }
+
+    // ── 免费版 ─────────────────────────────────────────────────────────
+
+    @Test
+    void freeUser_secondCallSameDay_rejectedWithTodayExhaustedMessage() {
+        when(membershipService.getMemberLevel(1L)).thenReturn("FREE");
+        when(valueOps.increment(anyString())).thenReturn(1L, 2L);
+
+        ReportAnalyzeQuotaDTO first = service.consume(1L, null);
+        assertTrue(first.isAllowed());
+        assertEquals(1, first.getTotal());
+        assertEquals(0, first.getRemaining());
+
+        ReportAnalyzeQuotaDTO second = service.consume(1L, null);
+        assertFalse(second.isAllowed());
+        assertTrue(second.getMessage().contains("今日已用完"));
+        assertTrue(second.getMessage().contains("免费版"));
+        assertFalse(second.getMessage().contains("请升级会员"));
+        assertEquals(ReportAnalyzeQuotaService.UPGRADE_URL, second.getActionUrl());
+        assertTrue(second.isExhausted());
+        // 免费版读等级配置为硬编码 1,不查配置表
+        verify(membershipLevelMapper, never()).selectOne(any());
+    }
+
+    @Test
+    void freeUser_counterExpired_nextDay_allowedAgain_withDatedKey() {
+        when(membershipService.getMemberLevel(1L)).thenReturn("FREE");
+        // key 到期失效后重新 INCR 从 1 开始(次日为新 key)
+        when(valueOps.increment(anyString())).thenReturn(1L, 1L);
+
+        assertTrue(service.consume(1L, null).isAllowed());
+        assertTrue(service.consume(1L, null).isAllowed());
+
+        ArgumentCaptor<String> keyCaptor = ArgumentCaptor.forClass(String.class);
+        verify(valueOps, times(2)).increment(keyCaptor.capture());
+        assertTrue(keyCaptor.getAllValues().get(0).matches("report:analyze:1:\\d{8}"),
+                "计数器 key 应为 report:analyze:{userId}:{yyyyMMdd},实际=" + keyCaptor.getAllValues().get(0));
+    }
+
+    // ── 付费版 ─────────────────────────────────────────────────────────
+
+    @Test
+    void paidBasic_sameConversation_multiRound_chargedOnlyOnce() {
+        when(membershipService.getMemberLevel(2L)).thenReturn("BASIC");
+        when(membershipLevelMapper.selectOne(any())).thenReturn(level("BASIC", 3));
+        // SADD:首次加入返回 1(已计费),同一会话再次加入返回 0(已计过费)
+        when(setOps.add(anyString(), any())).thenReturn(1L, 0L);
+        when(valueOps.increment(anyString())).thenReturn(1L);
+        when(valueOps.get(anyString())).thenReturn(1);
+
+        ReportAnalyzeQuotaDTO first = service.consume(2L, "conv-1");
+        assertTrue(first.isAllowed());
+        assertEquals(2, first.getRemaining());
+
+        // 同一会话第二轮:放行但不重复扣次数
+        ReportAnalyzeQuotaDTO second = service.consume(2L, "conv-1");
+        assertTrue(second.isAllowed());
+        assertEquals(2, second.getRemaining());
+        verify(valueOps, times(1)).increment(anyString());
+    }
+
+    @Test
+    void paidBasic_quotaExhausted_rejectedWithUpgradeMessage_andSessionRolledBack() {
+        when(membershipService.getMemberLevel(3L)).thenReturn("BASIC");
+        when(membershipLevelMapper.selectOne(any())).thenReturn(level("BASIC", 3));
+        when(setOps.add(anyString(), any())).thenReturn(1L);
+        when(valueOps.increment(anyString())).thenReturn(4L);
+
+        ReportAnalyzeQuotaDTO result = service.consume(3L, "conv-x");
+        assertFalse(result.isAllowed());
+        assertTrue(result.getMessage().contains("请升级会员"));
+        assertFalse(result.getMessage().contains("今日已用完"));
+        assertEquals(ReportAnalyzeQuotaService.UPGRADE_URL, result.getActionUrl());
+        // 本次未扣成功 → 回滚会话标记,同一会话仍可再次作为新一轮计费
+        verify(setOps).remove(anyString(), any());
+    }
+
+    @Test
+    void paidFamily_limitZero_unlimited_allowedWithoutCounting() {
+        when(membershipService.getMemberLevel(4L)).thenReturn("FAMILY");
+        when(membershipLevelMapper.selectOne(any())).thenReturn(level("FAMILY", 0));
+
+        ReportAnalyzeQuotaDTO result = service.consume(4L, "conv-1");
+        assertTrue(result.isAllowed());
+        assertTrue(result.isUnlimited());
+        assertEquals(-1, result.getRemaining());
+        assertFalse(result.isExhausted());
+        verify(valueOps, never()).increment(anyString());
+        verifyNoInteractions(setOps);
+    }
+
+    // ── 只读查询 ───────────────────────────────────────────────────────
+
+    @Test
+    void getStatus_reportsRemaining_withoutConsuming() {
+        when(membershipService.getMemberLevel(5L)).thenReturn("FREE");
+        when(valueOps.get(anyString())).thenReturn(0);
+
+        ReportAnalyzeQuotaDTO status = service.getStatus(5L);
+        assertEquals("FREE", status.getLevelCode());
+        assertEquals(1, status.getTotal());
+        assertEquals(1, status.getRemaining());
+        assertFalse(status.isExhausted());
+        assertTrue(status.isAllowed());
+        verify(valueOps, never()).increment(anyString());
+    }
+}

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-c760c15e973c1f45fd8c875561a8a8c2d62c4aa2
+beba8a3bc2e9fea047394da4ac6a590449690c23

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

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

+ 1 - 1
cfc-web/package.json

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

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

@@ -4,6 +4,161 @@
 
 ---
 
+## v1.0.1785 (2026-10-24)
+
+### 新功能
+- F-3a 消费返佣四条途径接入 earnByRule(F02/F04/F05/F06)
+
+### 其他
+- 新增分派服务:CfEarnDispatchService(userId -> family_members.id 解析 + ctx/refId 组装)
+- F02 消费返 CF(cf_consume_order)
+- - CfEarnDispatchService.java:95 earnConsumeCf,ctx amt=实付分;refId=orderId*100+订单类型命名空间(跨业务表隔离)
+- - CfCommissionService.java:86-92 settle 7参重载(实付与分润基数分离);:121 earnBuyerCf 替换旧 order_consume 直写
+- - CfCommissionService.java:112 settleReferrerOnly 同样返当前人 F02(N6)
+- - ProductOrderService.java:1053-1058 商品订单传 moneyAmount(实付),分润基数仍传 totalAmount
+- - GutHealthProjectService.java:271-277 调研订单 processPaymentCallback 返 F02
+- - AssessmentOrderService/PaymentService/MembershipService/MemberSubscriptionService 走 settle/settleReferrerOnly 自动覆盖
+- F04 商品赠品 CF(product_order_gift)
+- - CfEarnDispatchService.java:120 earnGiftCf,ctx amount=赠品 CF 数,refId=giftId
+- - ProductOrderService.java:1166-1173 赠品发放改投 earnByRule(单赠品 try/catch 隔离)
+- F05 活动签到 CF(activity_checkin)
+- - CfEarnDispatchService.java:132 earnActivityCheckinCf,ctx amount=活动 platform_points,refId=签到记录 ID
+- - ActivityService.java:416-424 签到发放改投 earnByRule
+- F06 菌群健康调研打卡返还(gut_health_refund)
+- - CfEarnDispatchService.java:144 earnGutHealthRefundCf,ctx amt=返还金额分,refId=projectId
+- - GutHealthProjectService.java:446-471 refundCf 仅 earned=true 才置 cfRefunded=1(闸门拦下不误标记)
+- - GutHealthProjectService.java:828-847 getProjectPrice 去 DEFAULT_PRICE 常量,改读 sys_config + CfConfigKeys 兜底
+- 旧 platformPointsService / familyPlatformPointsService / system_points 直写已从上述四条途径移除;
+- referral_dist 分润保留旧路径(属 F-4 批次)。
+- 测试:CfEarnDispatchServiceTest(8) + CfCommissionServiceSettleTest(4) + CfBalanceServiceTest(20)
+-       + CfFormulaEvaluatorTest(8) = 40 用例全绿;mvn clean compile BUILD SUCCESS。
+- 构建修复:755a5eba9 迁移419 ensureColumn membership_levels.max_daily_analyze(同步 schema.sql)。
+- 注:本次代码实体由仓库自动 bump 脚本先行提交,对应 hash:
+-     777f7da6d CfEarnDispatchService / CfCommissionService / ProductOrderService
+-     08be1b957 ActivityService / GutHealthProjectService
+-     b47039370 CfEarnDispatchServiceTest / CfCommissionServiceSettleTest
+-     fa718679f MembershipLevel.maxDailyAnalyze 字段补回
+- 
+
+### Bug 修复
+- 迁移419 补齐 membership_levels.max_daily_analyze 列(修复 ReportAnalyzeQuota 编译与运行时报错)
+
+
+## v1.0.1784 (2026-10-24)
+
+### 重构
+- F-6 存量迁移 三账本合一(迁移417)
+- W0.3 审计日志统一(audit_log 表 + AOP 切面 + 查询接口,迁移 414)
+- F-2 CF值统一账本服务(CfBalanceService + 统一发放入口 + 迁移416)
+- W0.2 双配置中心合并,service_config 收编 product_config
+
+### 其他
+- 
+- 并提供统一发放入口 earnByRule(memberId, ruleCode, refId, ctx):读规则(启用+生效期+params_json)
+- → 会员等级日限/累计上限/冷却三道闸 → 公式/固定/逐人数量计算 → 幂等入账。
+- 要点:
+- - 并发安全:所有变更先 SELECT ... FOR UPDATE 锁余额行,事务内更新余额并写流水;
+- - 幂等:(member_id, type, ref_type, ref_id) 唯一键 uk_ref_type 兜底,命中返回原结果;
+-   拦截类跳过写 type=skip、amount=0 流水可追溯(今日已发放次数仅统计 type=earn);
+- - 分→CF 换算由 params.amt_unit=cent + cf.rate.cf_per_yuan 驱动(F02/F06/F18,1元=100CF),
+-   公式经白名单求值器 CfFormulaEvaluator(floor/ceil/round/min/max/abs/cfg),业务数值不落代码;
+- - 管理员调整 ref_type=admin_adjust 并留痕 operator_user_id。
+- 数据层:迁移416 补 operator_user_id、幂等键升级为 (member_id,type,ref_type,ref_id)、
+- 种子 cf.withdraw_enabled=false / cf.rate.cf_per_yuan=100;schema.sql 同步。
+- 迁移编号说明(主理人 2026-10-25 编排):407-412(F-1)、413(W0.2 product_config 收编)、
+- 414(W0.3 审计)、415(F-6 存量迁移 lan-204)均已占用,本 F-2 使用 416。
+- 测试:CfBalanceServiceTest 20 例 + CfFormulaEvaluatorTest 8 例全部通过;mvn clean compile 通过。
+- 
+-   若目标环境存在 product_config 表则按 product.{productKey}.{key} 规则并入(product_key→product_code→product_id→group_key→default 标识规则),表缺失时幂等空跑;同步 schema.sql
+- - ServiceConfigItem 新增 productKey 字段;ConfigService 支持按 productKey 过滤列表明细 + product 侧配置 get/set(读 service_config WHERE product_key=?)
+- - AdminServiceConfigController /list、/save 支持 productKey 维度
+- - 新增 AdminProductConfigController:product_config 写接口 410 Gone(30 天只读回滚窗口,2026-11-23 可删表)
+- - cfc-web 配置中心统一入口(ConfigCenter.vue + 路由 + 菜单),product_key 可筛选;out/ 备份脚本
+- 注:本基线(origin/cfclub)与测试库不存在 product_config 表/实体,迁移数据并入为空跑(0→0),
+- ProductConfigServiceImpl 亦不存在,step4 以 ConfigService 同等语义实现。
+- 
+- - 新建 8 表: cf_balance / cf_balance_log / cf_earn_rule(+24行F02-F24种子) / cf_earn_rule_history / cf_direct_sales_tier / cf_indirect_sales_tier / cf_earn_daily_limit(+N11 20行) / service_config
+- - coupon / user_coupon 补 family_id(优惠券家庭化 N3)
+- - DatabaseInitializer 迁移 407-412,全部 try-catch + 存在性判断,可重复执行
+- - schema.sql 同步建表定义
+- - 实体 + Mapper(@TableName/@TableId AUTO,MapperScan 覆盖)
+- 配置治理(W0.1):
+- - service_config 10 项 CF 通用运营参数 + cf.version
+- - sys_config/products 散落参数迁入 service_config,旧位置标记(不删值,过渡兼容)
+- - ConfigService.getConfig(本地缓存 TTL 60s → DB → 兜底常量)+ CfConfigCache.invalidate
+- - 新增 /api/admin/service-config 与 /api/admin/cf-earn-rule 配置接口,save/toggle 挂缓存失效 + 规则变更留痕
+- - L1 常量类 OrderConstants / AdminConstants(仅 CF 无关技术参数)
+- 不含任何发放/扣减业务逻辑(F-2/F-3 批)
+- 
+- 
+- - FamilyPlatformPointsService.exchangeCouponByCf: 返回券实例ID(family_coupon.id);FamilyPlatformBalanceController 透传到 Result.data
+- - checkout.vue doExchangeCoupon: 兑换成功后按实例ID在 /coupon/list 结果中精确匹配并自动选中,交服务端 applyCouponDiscount 计算折扣
+- 
+- 
+- 统一家庭成员口径:memberType 赋值 + family_members userId 去重兜底
+-   同一 userId 多条记录只保留一条(优先 status=active + updated_at 最新)
+-   userId 为 null 的孤儿记录保持原样(local 成员)
+- - 补全 memberType 赋值:userId 非空且关联 User 有 openid → 'login';否则 → 'local'
+-   parent 段维持 'parent' 不变
+- - 保持 isSelf 唯一标记逻辑不变
+- - UserService.getVisibleMembers() 两段去重逻辑保持正确(复用 listMembers)
+- - mvn clean compile 验证通过
+- - 不改 schema、不新增接口
+- 
+- 2) 海报模板 layoutConfig 扩展(迁移404):entity+service JSON 校验+schema.sql;ActivityPoster.vue 全量读取 layout 配置(9 元素组/25+ 字段),视觉增强(品牌头分隔线、封面圆角、标题 accent、二维码白底卡片+CTA 胶囊、footer 自适应颜色);cfc-web PosterTemplateManage.vue 布局 JSON 编辑器
+- 3) 详情页美化沿用既有卡片化排版;PROJECT-OVERVIEW.md 索引同步
+- 
+- - Bug2: onShow 从子页返回时改走 refresh 语义(重置页码+清空列表再加载),并加请求序号丢弃过期响应,返回详情后列表不再翻倍
+- - load* 补齐 Promise 返回,修复 onRefresh .finally 的 undefined 隐患
+- 
+- 且限定 created_at 为今日,导致关闭的不是用户点击的那条任务实例(或查不到直接跳过)。
+- 改动:新增 TaskService.updateCheckinTaskProgress(taskId, ...),按前端透传的具体
+- taskId 精确定位实例,累计进度达标后复用 awardTaskCompletionRewards 置 completed 并发奖;
+- 睡眠/运动/饮食三个打卡 Controller 均切到新方法(未带 taskId 的兜底路径行为不变)。
+- 次日由 RepeatTaskGenerator 重新生成当日实例,卡片正常重现。
+- 
+- - BasicQuotaService 改日/月双周期配额:AI 对话每天 3 次、饮食识别每天 3 次、报告解读每月 1 次
+- - consumeMemberQuota 按等级分流,FAMILY+ 不落配额;QuotaStatusDTO 增加 unlimited 供前端判断
+- - 测评预约去配额(凭证已购额度预约)、报告上传全员开放、每日任务不限(max_tasks_per_day=0)
+- - 会员价:min_discount_level 为空时默认 BASIC,修复 FREE 也能享会员价的漏洞;活动侧纳入 BASIC
+- - 迁移 405 同步等级/权益/配额口径(原计划编号 382 已被占用)
+- 前端:
+- - AI 对话/饮食识别配额展示改日口径并按 unlimited 隐藏(FAMILY+ 不显示也不拦截)
+- - 升级引导弹窗按日/月双口径出文案,去掉「AI 顾问」「每月3次」旧口径
+- 
+- #2 详情页返回列表不再翻倍:loadData() 统一为「重置页码+清空+加载第一页」
+- #3 补齐活动简介录入(create.vue 表单/回填/海报 intro 入参),海报预览与后台模板管理此前已完成
+- #4 审核中心顶部待审数不减少:ReviewCenter.vue 13 个 Tab 绑定 @refresh-summary,各子 Tab 审核后 emit
+- #5 日期时间统一 yyyy-MM-dd HH:mm(复用 utils/format.js);管理端 4 处 UTC 偏移串少 8 小时改为 fmtLocalTime 转 Asia/Shanghai
+- #6 重复报名误报「网络异常」:新增 showApiError 区分后端业务错误(如已报名)与真实网络错误
+- #7 活动详情页美化
+- #8 我的活动列表纳入已报名活动
+- 
+- - index-home:loadFamilyData 的 catch 路径补调 loadTodayTasks(),即使获取家庭成员列表失败,从打卡页返回后也能刷新任务列表,确保已完成任务卡片消失
+- - 统一从 ../../utils/api.js 引入 completeTask as completeTaskApi(与 index-home 别名一致)
+- - 新增修复报告 docs/superpowers/plans/verify/fix-homepage-task-close.md
+- 
+
+### 新功能
+- F-1 数据层 + W0.1 配置归口(cf_balance/cf_earn_rule/service_config + 迁移407-412)
+- 新增过期优惠券物理清理定时任务
+- 下单页兑换CF券后自动选中并立即使用
+- 活动详情重设计三合一
+- 9.9 元体验会员(BASIC)权益口径修正与会员价机制
+
+### Bug 修复
+- 心愿券单券日限次生效 + 结算页改用服务端抵扣
+- 修复 4 问题(formatDate/任务不关闭/计数/成员切换),9 文件
+- :活动时间收敛为 5 分钟粒度(只到分,小程序+后台)
+- 发现活动列表摘要纯文本化 + 详情返回不再重复追加
+- 修复首页每日打卡任务完成后卡片不关闭
+- 活动模块 8 项问题收口(列表/返回/简介/海报/日期/报名/审核计数/我的活动)
+- 修复首页每日任务打卡后卡片不消失
+
+### 文档
+- 任务5配额状态接口与定时任务独立核验通过(verify-task5)
+
+
 ## v1.0.1782 (2026-10-23)
 
 ### Bug 修复

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

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1782
+> 当前版本: v1.0.1785
 
 ## 历史版本
 
@@ -8,6 +8,161 @@
 
 ---
 
+## v1.0.1785 (2026-10-24)
+
+### 新功能
+- F-3a 消费返佣四条途径接入 earnByRule(F02/F04/F05/F06)
+
+### 其他
+- 新增分派服务:CfEarnDispatchService(userId -> family_members.id 解析 + ctx/refId 组装)
+- F02 消费返 CF(cf_consume_order)
+- - CfEarnDispatchService.java:95 earnConsumeCf,ctx amt=实付分;refId=orderId*100+订单类型命名空间(跨业务表隔离)
+- - CfCommissionService.java:86-92 settle 7参重载(实付与分润基数分离);:121 earnBuyerCf 替换旧 order_consume 直写
+- - CfCommissionService.java:112 settleReferrerOnly 同样返当前人 F02(N6)
+- - ProductOrderService.java:1053-1058 商品订单传 moneyAmount(实付),分润基数仍传 totalAmount
+- - GutHealthProjectService.java:271-277 调研订单 processPaymentCallback 返 F02
+- - AssessmentOrderService/PaymentService/MembershipService/MemberSubscriptionService 走 settle/settleReferrerOnly 自动覆盖
+- F04 商品赠品 CF(product_order_gift)
+- - CfEarnDispatchService.java:120 earnGiftCf,ctx amount=赠品 CF 数,refId=giftId
+- - ProductOrderService.java:1166-1173 赠品发放改投 earnByRule(单赠品 try/catch 隔离)
+- F05 活动签到 CF(activity_checkin)
+- - CfEarnDispatchService.java:132 earnActivityCheckinCf,ctx amount=活动 platform_points,refId=签到记录 ID
+- - ActivityService.java:416-424 签到发放改投 earnByRule
+- F06 菌群健康调研打卡返还(gut_health_refund)
+- - CfEarnDispatchService.java:144 earnGutHealthRefundCf,ctx amt=返还金额分,refId=projectId
+- - GutHealthProjectService.java:446-471 refundCf 仅 earned=true 才置 cfRefunded=1(闸门拦下不误标记)
+- - GutHealthProjectService.java:828-847 getProjectPrice 去 DEFAULT_PRICE 常量,改读 sys_config + CfConfigKeys 兜底
+- 旧 platformPointsService / familyPlatformPointsService / system_points 直写已从上述四条途径移除;
+- referral_dist 分润保留旧路径(属 F-4 批次)。
+- 测试:CfEarnDispatchServiceTest(8) + CfCommissionServiceSettleTest(4) + CfBalanceServiceTest(20)
+-       + CfFormulaEvaluatorTest(8) = 40 用例全绿;mvn clean compile BUILD SUCCESS。
+- 构建修复:755a5eba9 迁移419 ensureColumn membership_levels.max_daily_analyze(同步 schema.sql)。
+- 注:本次代码实体由仓库自动 bump 脚本先行提交,对应 hash:
+-     777f7da6d CfEarnDispatchService / CfCommissionService / ProductOrderService
+-     08be1b957 ActivityService / GutHealthProjectService
+-     b47039370 CfEarnDispatchServiceTest / CfCommissionServiceSettleTest
+-     fa718679f MembershipLevel.maxDailyAnalyze 字段补回
+- 
+
+### Bug 修复
+- 迁移419 补齐 membership_levels.max_daily_analyze 列(修复 ReportAnalyzeQuota 编译与运行时报错)
+
+
+## v1.0.1784 (2026-10-24)
+
+### 重构
+- F-6 存量迁移 三账本合一(迁移417)
+- W0.3 审计日志统一(audit_log 表 + AOP 切面 + 查询接口,迁移 414)
+- F-2 CF值统一账本服务(CfBalanceService + 统一发放入口 + 迁移416)
+- W0.2 双配置中心合并,service_config 收编 product_config
+
+### 其他
+- 
+- 并提供统一发放入口 earnByRule(memberId, ruleCode, refId, ctx):读规则(启用+生效期+params_json)
+- → 会员等级日限/累计上限/冷却三道闸 → 公式/固定/逐人数量计算 → 幂等入账。
+- 要点:
+- - 并发安全:所有变更先 SELECT ... FOR UPDATE 锁余额行,事务内更新余额并写流水;
+- - 幂等:(member_id, type, ref_type, ref_id) 唯一键 uk_ref_type 兜底,命中返回原结果;
+-   拦截类跳过写 type=skip、amount=0 流水可追溯(今日已发放次数仅统计 type=earn);
+- - 分→CF 换算由 params.amt_unit=cent + cf.rate.cf_per_yuan 驱动(F02/F06/F18,1元=100CF),
+-   公式经白名单求值器 CfFormulaEvaluator(floor/ceil/round/min/max/abs/cfg),业务数值不落代码;
+- - 管理员调整 ref_type=admin_adjust 并留痕 operator_user_id。
+- 数据层:迁移416 补 operator_user_id、幂等键升级为 (member_id,type,ref_type,ref_id)、
+- 种子 cf.withdraw_enabled=false / cf.rate.cf_per_yuan=100;schema.sql 同步。
+- 迁移编号说明(主理人 2026-10-25 编排):407-412(F-1)、413(W0.2 product_config 收编)、
+- 414(W0.3 审计)、415(F-6 存量迁移 lan-204)均已占用,本 F-2 使用 416。
+- 测试:CfBalanceServiceTest 20 例 + CfFormulaEvaluatorTest 8 例全部通过;mvn clean compile 通过。
+- 
+-   若目标环境存在 product_config 表则按 product.{productKey}.{key} 规则并入(product_key→product_code→product_id→group_key→default 标识规则),表缺失时幂等空跑;同步 schema.sql
+- - ServiceConfigItem 新增 productKey 字段;ConfigService 支持按 productKey 过滤列表明细 + product 侧配置 get/set(读 service_config WHERE product_key=?)
+- - AdminServiceConfigController /list、/save 支持 productKey 维度
+- - 新增 AdminProductConfigController:product_config 写接口 410 Gone(30 天只读回滚窗口,2026-11-23 可删表)
+- - cfc-web 配置中心统一入口(ConfigCenter.vue + 路由 + 菜单),product_key 可筛选;out/ 备份脚本
+- 注:本基线(origin/cfclub)与测试库不存在 product_config 表/实体,迁移数据并入为空跑(0→0),
+- ProductConfigServiceImpl 亦不存在,step4 以 ConfigService 同等语义实现。
+- 
+- - 新建 8 表: cf_balance / cf_balance_log / cf_earn_rule(+24行F02-F24种子) / cf_earn_rule_history / cf_direct_sales_tier / cf_indirect_sales_tier / cf_earn_daily_limit(+N11 20行) / service_config
+- - coupon / user_coupon 补 family_id(优惠券家庭化 N3)
+- - DatabaseInitializer 迁移 407-412,全部 try-catch + 存在性判断,可重复执行
+- - schema.sql 同步建表定义
+- - 实体 + Mapper(@TableName/@TableId AUTO,MapperScan 覆盖)
+- 配置治理(W0.1):
+- - service_config 10 项 CF 通用运营参数 + cf.version
+- - sys_config/products 散落参数迁入 service_config,旧位置标记(不删值,过渡兼容)
+- - ConfigService.getConfig(本地缓存 TTL 60s → DB → 兜底常量)+ CfConfigCache.invalidate
+- - 新增 /api/admin/service-config 与 /api/admin/cf-earn-rule 配置接口,save/toggle 挂缓存失效 + 规则变更留痕
+- - L1 常量类 OrderConstants / AdminConstants(仅 CF 无关技术参数)
+- 不含任何发放/扣减业务逻辑(F-2/F-3 批)
+- 
+- 
+- - FamilyPlatformPointsService.exchangeCouponByCf: 返回券实例ID(family_coupon.id);FamilyPlatformBalanceController 透传到 Result.data
+- - checkout.vue doExchangeCoupon: 兑换成功后按实例ID在 /coupon/list 结果中精确匹配并自动选中,交服务端 applyCouponDiscount 计算折扣
+- 
+- 
+- 统一家庭成员口径:memberType 赋值 + family_members userId 去重兜底
+-   同一 userId 多条记录只保留一条(优先 status=active + updated_at 最新)
+-   userId 为 null 的孤儿记录保持原样(local 成员)
+- - 补全 memberType 赋值:userId 非空且关联 User 有 openid → 'login';否则 → 'local'
+-   parent 段维持 'parent' 不变
+- - 保持 isSelf 唯一标记逻辑不变
+- - UserService.getVisibleMembers() 两段去重逻辑保持正确(复用 listMembers)
+- - mvn clean compile 验证通过
+- - 不改 schema、不新增接口
+- 
+- 2) 海报模板 layoutConfig 扩展(迁移404):entity+service JSON 校验+schema.sql;ActivityPoster.vue 全量读取 layout 配置(9 元素组/25+ 字段),视觉增强(品牌头分隔线、封面圆角、标题 accent、二维码白底卡片+CTA 胶囊、footer 自适应颜色);cfc-web PosterTemplateManage.vue 布局 JSON 编辑器
+- 3) 详情页美化沿用既有卡片化排版;PROJECT-OVERVIEW.md 索引同步
+- 
+- - Bug2: onShow 从子页返回时改走 refresh 语义(重置页码+清空列表再加载),并加请求序号丢弃过期响应,返回详情后列表不再翻倍
+- - load* 补齐 Promise 返回,修复 onRefresh .finally 的 undefined 隐患
+- 
+- 且限定 created_at 为今日,导致关闭的不是用户点击的那条任务实例(或查不到直接跳过)。
+- 改动:新增 TaskService.updateCheckinTaskProgress(taskId, ...),按前端透传的具体
+- taskId 精确定位实例,累计进度达标后复用 awardTaskCompletionRewards 置 completed 并发奖;
+- 睡眠/运动/饮食三个打卡 Controller 均切到新方法(未带 taskId 的兜底路径行为不变)。
+- 次日由 RepeatTaskGenerator 重新生成当日实例,卡片正常重现。
+- 
+- - BasicQuotaService 改日/月双周期配额:AI 对话每天 3 次、饮食识别每天 3 次、报告解读每月 1 次
+- - consumeMemberQuota 按等级分流,FAMILY+ 不落配额;QuotaStatusDTO 增加 unlimited 供前端判断
+- - 测评预约去配额(凭证已购额度预约)、报告上传全员开放、每日任务不限(max_tasks_per_day=0)
+- - 会员价:min_discount_level 为空时默认 BASIC,修复 FREE 也能享会员价的漏洞;活动侧纳入 BASIC
+- - 迁移 405 同步等级/权益/配额口径(原计划编号 382 已被占用)
+- 前端:
+- - AI 对话/饮食识别配额展示改日口径并按 unlimited 隐藏(FAMILY+ 不显示也不拦截)
+- - 升级引导弹窗按日/月双口径出文案,去掉「AI 顾问」「每月3次」旧口径
+- 
+- #2 详情页返回列表不再翻倍:loadData() 统一为「重置页码+清空+加载第一页」
+- #3 补齐活动简介录入(create.vue 表单/回填/海报 intro 入参),海报预览与后台模板管理此前已完成
+- #4 审核中心顶部待审数不减少:ReviewCenter.vue 13 个 Tab 绑定 @refresh-summary,各子 Tab 审核后 emit
+- #5 日期时间统一 yyyy-MM-dd HH:mm(复用 utils/format.js);管理端 4 处 UTC 偏移串少 8 小时改为 fmtLocalTime 转 Asia/Shanghai
+- #6 重复报名误报「网络异常」:新增 showApiError 区分后端业务错误(如已报名)与真实网络错误
+- #7 活动详情页美化
+- #8 我的活动列表纳入已报名活动
+- 
+- - index-home:loadFamilyData 的 catch 路径补调 loadTodayTasks(),即使获取家庭成员列表失败,从打卡页返回后也能刷新任务列表,确保已完成任务卡片消失
+- - 统一从 ../../utils/api.js 引入 completeTask as completeTaskApi(与 index-home 别名一致)
+- - 新增修复报告 docs/superpowers/plans/verify/fix-homepage-task-close.md
+- 
+
+### 新功能
+- F-1 数据层 + W0.1 配置归口(cf_balance/cf_earn_rule/service_config + 迁移407-412)
+- 新增过期优惠券物理清理定时任务
+- 下单页兑换CF券后自动选中并立即使用
+- 活动详情重设计三合一
+- 9.9 元体验会员(BASIC)权益口径修正与会员价机制
+
+### Bug 修复
+- 心愿券单券日限次生效 + 结算页改用服务端抵扣
+- 修复 4 问题(formatDate/任务不关闭/计数/成员切换),9 文件
+- :活动时间收敛为 5 分钟粒度(只到分,小程序+后台)
+- 发现活动列表摘要纯文本化 + 详情返回不再重复追加
+- 修复首页每日打卡任务完成后卡片不关闭
+- 活动模块 8 项问题收口(列表/返回/简介/海报/日期/报名/审核计数/我的活动)
+- 修复首页每日任务打卡后卡片不消失
+
+### 文档
+- 任务5配额状态接口与定时任务独立核验通过(verify-task5)
+
+
 ## v1.0.1782 (2026-10-23)
 
 ### Bug 修复