Jelajahi Sumber

refactor(cf): F-4 分佣收口 + F03a/F03b 发放接入(迁移418-419)

推荐人返佣收口为两级销售奖:F03a 直接销售奖(团队8层内滚动一年累计P点档位,上限70%)+ F03b 间接销售奖(自身滚动一年消费P点档位,上限20%),共同基数=本单消费产生的P点;取代原'团队规模阶梯 cf_rate_tier'单级分佣。

- 迁移418:cf_direct_sales_tier / cf_indirect_sales_tier 各补5档种子(缺行才补、不覆盖)+ cf_earn_rule.dedup_ref_type 对齐为规则编码 F03a/F03b
- 迁移419:新建 ppoint_consumption_log(滚动一年档位聚合数据源,唯一键 order_type+order_id 幂等)
- CfCommissionService:计算本单P点基数 → 落消费日志 → referral_tree 按 level 升序上溯跳过同家庭(D5)取前两级非同家庭引荐人 → earnByRule 发放(幂等键复用 F-2 uk_ref_type)
- CfReferralService:新增 matchDirectSalesTier / matchIndirectSalesTier / getTeamAnnualPpoints / getSelfAnnualPpoints / getTeamUserIds(365天滚动窗口、IN 分批500)
- 发放统一只写 cf_balance + cf_balance_log;销售奖不再写 cf_transfer_record(仅买家 F02 路径保留);F03a 且非幂等命中时触发感恩记录
- 比例零硬编码:档位表提供 rate_percent,cf_earn_rule.params_json.max_rate_percent 封顶,金额由公式 floor(base_ppoints*rate_percent/100) 计算
- CommissionDistService.distribute 写接口 410 Gone(抛 IllegalStateException),commission_dist_log 转只读历史表,读取接口不变
- cf_rate_tier 保留只读(等级展示),分佣链路已移除依赖
- /api/commission/cf/rate 扩展 teamDepth/teamAnnualPpoints/selfAnnualPpoints/direct*/indirect* 字段,legacy ratePercent/tierName/totalTeamSize 取 F03a 值兼容旧端
- 小程序 team.vue 新增两级销售奖档位卡片(纯 CSS 无 JS)
- 单测:CfCommissionServiceSettleTest 重写 + CfReferralServiceSalesTierTest / CommissionDistService410Test 新增 + DatabaseInitializerSeedTest 扩展;mvn clean compile 通过,62 项测试全绿
Sisyphus 2 minggu lalu
induk
melakukan
13a1e1257b

+ 132 - 4
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3308,6 +3308,13 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
             // 事务已整体回滚;保留启动可用性,下次启动可安全重试。
             log.warn("迁移417: 旧账本存量迁移失败,本批已回滚,下次启动重试: {}", e.getMessage());
         }
+
+        // 迁移418: 两级销售奖档位种子 + F03a/F03b 幂等 ref_type 对齐(F-4 分佣收口)
+        seedCfSalesTiers();
+        alignSalesRuleRefTypes();
+
+        // 迁移419: 创建 ppoint_consumption_log(滚动一年销售奖档位聚合数据源;F-4)
+        createPpointConsumptionLogTable();
     }
 
     private void migrateHealthDimensionsTable() {
@@ -13276,13 +13283,13 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             {"F03a", "直接销售奖", "consume", "formula", "",
                     "floor(base_ppoints * rate_percent / 100)",
                     "{\"base\":\"team_annual_ppoints\",\"tier_table\":\"cf_direct_sales_tier\",\"window\":\"1y_rolling\",\"team_depth\":8,\"max_rate_percent\":70,\"downgrade\":true}",
-                    "", "", "", "cf_direct_sales", "20",
-                    "基数=直接推荐人消费P点;档位按团队8层内年度累计P点(cf_direct_sales_tier)"},
+                    "", "", "", "F03a", "20",
+                    "基数=直接推荐人消费P点;档位按团队8层内年度累计P点(cf_direct_sales_tier);幂等ref_type与规则编码一致(F-4)"},
             {"F03b", "间接销售奖", "consume", "formula", "",
                     "floor(base_ppoints * rate_percent / 100)",
                     "{\"base\":\"self_annual_ppoints\",\"tier_table\":\"cf_indirect_sales_tier\",\"window\":\"1y_rolling\",\"max_rate_percent\":20,\"downgrade\":true}",
-                    "", "", "", "cf_indirect_sales", "30",
-                    "基数=间接推荐人消费P点;档位按自身年度累计消费P点(cf_indirect_sales_tier)"},
+                    "", "", "", "F03b", "30",
+                    "基数=间接推荐人消费P点;档位按自身年度累计消费P点(cf_indirect_sales_tier);幂等ref_type与规则编码一致(F-4)"},
             {"F04", "商品赠品CF", "consume", "per_item", "",
                     "",
                     "{\"source\":\"product_order_gift.cf_value\"}",
@@ -13965,4 +13972,125 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             log.warn("迁移414: audit_log 索引检查/补建失败(可能已存在): {}", e.getMessage());
         }
     }
+
+    // ==================== 迁移418:F-4 分佣收口 ====================
+
+    /**
+     * 直接销售奖档位种子(F03a;初始运营值,管理端可调)。
+     * 列序:tier_name, min_annual_ppoints, rate_percent, sort_order, remark
+     */
+    private static final String[][] CF_DIRECT_SALES_TIER_SEEDS = {
+            {"青铜", "100", "10", "1", "团队8层内年度P点≥100,直接销售奖10%"},
+            {"白银", "500", "25", "2", "团队8层内年度P点≥500,直接销售奖25%"},
+            {"黄金", "2000", "40", "3", "团队8层内年度P点≥2000,直接销售奖40%"},
+            {"铂金", "5000", "55", "4", "团队8层内年度P点≥5000,直接销售奖55%"},
+            {"钻石", "10000", "70", "5", "团队8层内年度P点≥10000,直接销售奖70%"}
+    };
+
+    /**
+     * 间接销售奖档位种子(F03b;初始运营值,管理端可调)。
+     * 列序:tier_name, min_self_ppoints, rate_percent, sort_order, remark
+     */
+    private static final String[][] CF_INDIRECT_SALES_TIER_SEEDS = {
+            {"青铜", "100", "2", "1", "自身年度消费P点≥100,间接销售奖2%"},
+            {"白银", "500", "5", "2", "自身年度消费P点≥500,间接销售奖5%"},
+            {"黄金", "2000", "10", "3", "自身年度消费P点≥2000,间接销售奖10%"},
+            {"铂金", "5000", "15", "4", "自身年度消费P点≥5000,间接销售奖15%"},
+            {"钻石", "10000", "20", "5", "自身年度消费P点≥10000,间接销售奖20%"}
+    };
+
+    // 迁移418: 两级销售奖档位种子(幂等:min 下限唯一键已存在则跳过,不覆盖运营改过的档位)
+    // 迁移418(F-4):缺行才补的档位种子,包内可见以便单测直接调用
+    void seedCfSalesTiers() {
+        int inserted = 0;
+        for (String[] t : CF_DIRECT_SALES_TIER_SEEDS) {
+            try {
+                Integer cnt = jdbcTemplate.queryForObject(
+                        "SELECT COUNT(*) FROM cf_direct_sales_tier WHERE min_annual_ppoints = ?",
+                        Integer.class, Integer.valueOf(t[1]));
+                if (cnt != null && cnt > 0) {
+                    continue;
+                }
+                jdbcTemplate.update("INSERT INTO cf_direct_sales_tier "
+                                + "(tier_name, min_annual_ppoints, rate_percent, sort_order, enabled, remark, created_at, updated_at) "
+                                + "VALUES (?, ?, ?, ?, 1, ?, NOW(), NOW())",
+                        t[0], Integer.valueOf(t[1]), Integer.valueOf(t[2]), Integer.valueOf(t[3]), t[4]);
+                inserted++;
+            } catch (Exception e) {
+                log.warn("迁移418: 写入直接销售奖档位种子 {} 失败: {}", t[0], e.getMessage());
+            }
+        }
+        for (String[] t : CF_INDIRECT_SALES_TIER_SEEDS) {
+            try {
+                Integer cnt = jdbcTemplate.queryForObject(
+                        "SELECT COUNT(*) FROM cf_indirect_sales_tier WHERE min_self_ppoints = ?",
+                        Integer.class, Integer.valueOf(t[1]));
+                if (cnt != null && cnt > 0) {
+                    continue;
+                }
+                jdbcTemplate.update("INSERT INTO cf_indirect_sales_tier "
+                                + "(tier_name, min_self_ppoints, rate_percent, sort_order, enabled, remark, created_at, updated_at) "
+                                + "VALUES (?, ?, ?, ?, 1, ?, NOW(), NOW())",
+                        t[0], Integer.valueOf(t[1]), Integer.valueOf(t[2]), Integer.valueOf(t[3]), t[4]);
+                inserted++;
+            } catch (Exception e) {
+                log.warn("迁移418: 写入间接销售奖档位种子 {} 失败: {}", t[0], e.getMessage());
+            }
+        }
+        if (inserted > 0) {
+            log.info("迁移418: 两级销售奖档位种子新增 {} 行(缺行才补,已存在不覆盖)", inserted);
+        }
+    }
+
+    // 迁移418: F03a/F03b 幂等 ref_type 对齐为规则编码(计划 §5.2 ref_type 见 F02-F24 编码;F-4)
+    // 迁移418(F-4):ref_type 对齐,包内可见以便单测直接调用
+    void alignSalesRuleRefTypes() {
+        try {
+            int updated = jdbcTemplate.update(
+                    "UPDATE cf_earn_rule SET dedup_ref_type = 'F03a', updated_at = NOW() "
+                            + "WHERE code = 'F03a' AND dedup_ref_type <> 'F03a'");
+            if (updated > 0) {
+                log.info("迁移418: F03a dedup_ref_type 已对齐为 F03a({} 行)", updated);
+            }
+        } catch (Exception e) {
+            log.warn("迁移418: F03a dedup_ref_type 对齐失败: {}", e.getMessage());
+        }
+        try {
+            int updated = jdbcTemplate.update(
+                    "UPDATE cf_earn_rule SET dedup_ref_type = 'F03b', updated_at = NOW() "
+                            + "WHERE code = 'F03b' AND dedup_ref_type <> 'F03b'");
+            if (updated > 0) {
+                log.info("迁移418: F03b dedup_ref_type 已对齐为 F03b({} 行)", updated);
+            }
+        } catch (Exception e) {
+            log.warn("迁移418: F03b dedup_ref_type 对齐失败: {}", e.getMessage());
+        }
+    }
+
+    // ==================== 迁移419:P 点消费日志(F-4 滚动一年档位数据源) ====================
+
+    // 迁移419: 创建 ppoint_consumption_log(消费支付成功时落一行,按消费时间滚动一年聚合)
+    // 迁移419(F-4):建表幂等,包内可见以便单测直接调用
+    void createPpointConsumptionLogTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `ppoint_consumption_log` ("
+                    + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
+                    + "`user_id` BIGINT NOT NULL COMMENT '消费人用户ID(users.id)', "
+                    + "`member_id` BIGINT NULL COMMENT '消费人家庭成员ID(family_members.id)', "
+                    + "`family_id` BIGINT NULL COMMENT '消费家庭ID', "
+                    + "`order_id` BIGINT NOT NULL COMMENT '订单ID', "
+                    + "`order_type` VARCHAR(32) NOT NULL COMMENT '订单类型(product/package/assessment/membership/subscription)', "
+                    + "`product_id` BIGINT NULL COMMENT '商品ID(商品订单)', "
+                    + "`amount_cent` INT NULL COMMENT '实付金额(分)', "
+                    + "`ppoints` INT NOT NULL DEFAULT 0 COMMENT '本单产生的P点', "
+                    + "`remark` VARCHAR(255) NULL, "
+                    + "`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '消费时间(订单支付成功时刻)', "
+                    + "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 表");
+        } catch (Exception e) {
+            log.warn("迁移419: 创建 ppoint_consumption_log 表失败(可能已存在): {}", e.getMessage());
+        }
+    }
 }

+ 20 - 5
cfc-backend/src/main/java/com/etotem/cfc/controller/CfCommissionController.java

@@ -2,7 +2,8 @@ package com.etotem.cfc.controller;
 
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.common.Result;
-import com.etotem.cfc.entity.CfRateTier;
+import com.etotem.cfc.entity.CfDirectSalesTier;
+import com.etotem.cfc.entity.CfIndirectSalesTier;
 import com.etotem.cfc.entity.PlatformBalanceLog;
 import com.etotem.cfc.service.CfReferralService;
 import com.etotem.cfc.service.PlatformPointsService;
@@ -25,15 +26,29 @@ public class CfCommissionController {
     @Resource
     private PlatformPointsService platformPointsService;
 
-    /** 我的团队规模 + 当前返佣比例 + 档位名 */
+    /** 我的团队规模 + 返佣比例 + 档位名(F-4:两级销售奖档位;保留 legacy 团队规模字段供旧端兼容) */
     @PostMapping("/rate")
     public Result<Map<String, Object>> rate(@RequestAttribute("userId") Long userId) {
         int teamSize = cfReferralService.getTotalTeamSize(userId);
-        CfRateTier tier = cfReferralService.matchRateTier(teamSize);
+        // F-4 两级销售奖:F03a 直接销售奖按团队8层内滚动一年P点档位,F03b 间接销售奖按自身滚动一年消费P点档位
+        int teamAnnualPpoints = cfReferralService.getTeamAnnualPpoints(userId, 8);
+        int selfAnnualPpoints = cfReferralService.getSelfAnnualPpoints(userId);
+        CfDirectSalesTier directTier = cfReferralService.matchDirectSalesTier(teamAnnualPpoints);
+        CfIndirectSalesTier indirectTier = cfReferralService.matchIndirectSalesTier(selfAnnualPpoints);
+
         Map<String, Object> data = new HashMap<>();
+        // legacy 字段(旧团队规模阶梯语义,保留给旧端):F03a 直接销售奖档位对齐
         data.put("totalTeamSize", teamSize);
-        data.put("ratePercent", tier == null ? 0 : tier.getRatePercent());
-        data.put("tierName", tier == null ? "未入档" : tier.getTierName());
+        data.put("ratePercent", directTier == null ? 0 : directTier.getRatePercent());
+        data.put("tierName", directTier == null ? "未入档" : directTier.getTierName());
+        // F-4 新档位字段
+        data.put("teamDepth", 8);
+        data.put("teamAnnualPpoints", teamAnnualPpoints);
+        data.put("selfAnnualPpoints", selfAnnualPpoints);
+        data.put("directRatePercent", directTier == null ? 0 : directTier.getRatePercent());
+        data.put("directTierName", directTier == null ? "未入档" : directTier.getTierName());
+        data.put("indirectRatePercent", indirectTier == null ? 0 : indirectTier.getRatePercent());
+        data.put("indirectTierName", indirectTier == null ? "未入档" : indirectTier.getTierName());
         return Result.success(data);
     }
 

+ 54 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/PpointConsumptionLog.java

@@ -0,0 +1,54 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * P 点消费日志(滚动一年分佣档位聚合的数据源)。
+ *
+ * <p>口径见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §4.4:
+ * 直接销售奖(F03a)按"团队 8 层内年度累计 P 点"分档,间接销售奖(F03b)按"自身年度累计消费 P 点"分档,
+ * 两个口径都依赖"按消费时间滚动一年"的 P 点累计,因此消费支付成功时落一行 P 点日志。
+ * 幂等键:{@code (order_type, order_id)} 唯一键,同一订单重复触发只记一行。</p>
+ */
+@Data
+@TableName("ppoint_consumption_log")
+public class PpointConsumptionLog implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 消费人用户ID(users.id) */
+    private Long userId;
+
+    /** 消费人家庭成员ID(family_members.id,可为空) */
+    private Long memberId;
+
+    /** 消费家庭ID */
+    private Long familyId;
+
+    /** 订单ID */
+    private Long orderId;
+
+    /** 订单类型(product/package/assessment/membership/subscription) */
+    private String orderType;
+
+    /** 商品ID(商品订单) */
+    private Long productId;
+
+    /** 实付金额(分) */
+    private Integer amountCent;
+
+    /** 本单产生的 P 点 */
+    private Integer ppoints;
+
+    private String remark;
+
+    /** 消费时间(订单支付成功时刻) */
+    private Date createdAt;
+}

+ 30 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/PpointConsumptionLogMapper.java

@@ -0,0 +1,30 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.PpointConsumptionLog;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.Collection;
+import java.util.Date;
+
+@Mapper
+public interface PpointConsumptionLogMapper extends BaseMapper<PpointConsumptionLog> {
+
+    /**
+     * 滚动一年窗口内指定用户集合的 P 点累计(§4.4 团队/自身年度累计)。
+     *
+     * @param userIds 用户ID集合(空集合返回 0,由调用方保证非空)
+     * @param since   滚动一年起点(含,按消费时间)
+     */
+    @Select({
+            "<script>",
+            "SELECT COALESCE(SUM(ppoints), 0) FROM ppoint_consumption_log",
+            "WHERE created_at &gt;= #{since}",
+            "AND user_id IN",
+            "<foreach collection='userIds' item='uid' open='(' separator=',' close=')'>#{uid}</foreach>",
+            "</script>"
+    })
+    int sumPpointsSince(@Param("userIds") Collection<Long> userIds, @Param("since") Date since);
+}

+ 227 - 33
cfc-backend/src/main/java/com/etotem/cfc/service/CfCommissionService.java

@@ -1,44 +1,85 @@
 package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.etotem.cfc.entity.CfRateTier;
+import com.etotem.cfc.config.ConfigService;
+import com.etotem.cfc.constant.CfConfigKeys;
+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;
 import com.etotem.cfc.entity.OctopusKeyPerson;
+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;
 import com.etotem.cfc.mapper.OctopusKeyPersonMapper;
+import com.etotem.cfc.mapper.PpointConsumptionLogMapper;
 import com.etotem.cfc.mapper.ProductMapper;
 import com.etotem.cfc.mapper.ReferralTreeMapper;
 import com.etotem.cfc.mapper.UserMapper;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import org.springframework.dao.DuplicateKeyException;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import javax.annotation.Resource;
 import java.util.Date;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 
 /**
- * 统一 CF 值分佣服务。
- * 规则:
- *  - 普通订单(商品/套餐/测评):当前消费人返 CF + 推荐人按团队规模阶梯比例分润
- *  - 会员/订阅订单:只返推荐人,不返当前人
- *  - 同家庭互推:跳过本人,上溯到第一个非同家庭引荐人
+ * 统一 CF 值分佣服务(F-4 分佣收口)。
+ *
+ * <p>口径见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §四:
+ * <ul>
+ *   <li>普通订单(商品/套餐/测评):当前消费人返 CF + 推荐人两级销售奖;</li>
+ *   <li>会员/订阅订单:只返推荐人;</li>
+ *   <li>同家庭互推:跳过本人,上溯到第一个非同家庭引荐人。</li>
+ * </ul>
+ *
+ * <p>两级销售奖(取代原"团队规模阶梯 cf_rate_tier"单级分佣):
+ * <ul>
+ *   <li><b>F03a 直接销售奖</b>:基数 = 本单消费产生的 P 点;比例按推荐人
+ *       <b>团队 8 层内滚动一年累计 P 点</b>在 {@code cf_direct_sales_tier} 的档位(最高 70%);</li>
+ *   <li><b>F03b 间接销售奖</b>:基数 = 本单消费产生的 P 点;比例按推荐人
+ *       <b>自身滚动一年累计消费 P 点</b>在 {@code cf_indirect_sales_tier} 的档位(最高 20%)。</li>
+ * </ul>
+ * 数量与比例不在代码里写死:比例来自档位表,金额由 {@code cf_earn_rule} 的公式
+ * ({@code floor(base_ppoints * rate_percent / 100)})+ params_json 上限驱动,
+ * 发放统一走 {@link CfBalanceService#earnByRule(Long, String, Long, Map)}(幂等键复用 F-2 唯一键)。
+ * </p>
  */
 @Service
 public class CfCommissionService {
 
     private static final Logger log = LoggerFactory.getLogger(CfCommissionService.class);
 
+    /** 直接销售奖规则编码(cf_earn_rule.code) */
+    public static final String RULE_DIRECT_SALES = "F03a";
+
+    /** 间接销售奖规则编码(cf_earn_rule.code) */
+    public static final String RULE_INDIRECT_SALES = "F03b";
+
+    /** F03a 团队口径最大层级(§4.1.1:团队 8 层以内) */
+    private static final int TEAM_DEPTH = 8;
+
+    /** 销售奖发放的参与层级数量(直接奖 L1 + 间接奖 L2) */
+    private static final int SALES_AWARD_LEVELS = 2;
+
     @Resource
     private ReferralTreeMapper referralTreeMapper;
     @Resource
@@ -61,6 +102,18 @@ public class CfCommissionService {
     private OctopusEffectRecordMapper octopusEffectRecordMapper;
     @Resource
     private OctopusEffectKeyPersonMapper octopusEffectKeyPersonMapper;
+    @Resource
+    private CfBalanceService cfBalanceService;
+    @Resource
+    private CfEarnRuleService cfEarnRuleService;
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+    @Resource
+    private PpointConsumptionLogMapper ppointConsumptionLogMapper;
+    @Resource
+    private ConfigService configService;
+    @Resource
+    private ObjectMapper objectMapper;
 
     /**
      * 通用分佣(双返):当前人 + 推荐人
@@ -118,13 +171,33 @@ public class CfCommissionService {
     }
 
     /**
-     * 推荐人分润:查全链路 → 同家庭跳过上溯 → 按阶梯比例
+     * 推荐人分润:两级销售奖(F03a 直接销售奖 + F03b 间接销售奖)。
+     *
+     * <p>口径见计划 §4.1 / §4.4:</p>
+     * <ul>
+     *   <li>共同基数 = 本单消费产生的 P 点(商品按有效 P 点比例折算,非商品按服务费率折算);</li>
+     *   <li>第 1 个非同家庭引荐人拿 F03a,比例按其<b>团队 8 层内滚动一年累计 P 点</b>档位(上限 70%);</li>
+     *   <li>第 2 个非同家庭引荐人拿 F03b,比例按其<b>自身滚动一年累计消费 P 点</b>档位(上限 20%);</li>
+     *   <li>同家庭互推跳过(D5),继续向上上溯,最多两级;</li>
+     *   <li>金额由 {@code cf_earn_rule} 公式计算,发放统一走 F-2 的 {@code earnByRule}(幂等键复用 F-2 唯一键)。</li>
+     * </ul>
      */
     private void distributeToReferrers(Long orderId, String orderType, Long buyerUserId, Long buyerFamilyId,
                                        Integer orderAmountCent, Long productId) {
         if (orderAmountCent == null || orderAmountCent <= 0) return;
         int amountYuan = orderAmountCent / 100;
 
+        // 本单消费产生的 P 点:F03a / F03b 的共同基数(§4.4)
+        int basePpoints = calcOrderPpoints(orderType, amountYuan, productId);
+        if (basePpoints <= 0) {
+            log.info("本单 P 点基数为 0,跳过销售奖: orderType={}, orderId={}", orderType, orderId);
+            return;
+        }
+
+        // 落 P 点消费日志(滚动一年档位聚合数据源;唯一键 order_type+order_id 幂等)
+        recordPpointConsumption(orderId, orderType, buyerUserId, buyerFamilyId, orderAmountCent,
+                productId, basePpoints);
+
         // 取买家全链路推荐人(referral_tree,含全部层级)
         List<ReferralTree> referrals = referralTreeMapper.selectList(
                 new LambdaQueryWrapper<ReferralTree>()
@@ -132,10 +205,13 @@ public class CfCommissionService {
                         .orderByAsc(ReferralTree::getLevel));
         if (referrals == null || referrals.isEmpty()) return;
 
+        // 向上上溯:跳过同家庭(D5),取前两级非同家庭引荐人
         Set<Long> processed = new HashSet<>();
+        int awardedLevel = 0;
         for (ReferralTree ref : referrals) {
+            if (awardedLevel >= SALES_AWARD_LEVELS) break;
             Long referrerId = ref.getParentId();
-            if (processed.contains(referrerId)) continue;
+            if (referrerId == null || processed.contains(referrerId)) continue;
             processed.add(referrerId);
 
             User referrer = userMapper.selectById(referrerId);
@@ -144,47 +220,165 @@ public class CfCommissionService {
             if (buyerFamilyId != null && buyerFamilyId.equals(referrer.getFamilyId())) {
                 continue;
             }
-            // 按团队规模匹配阶梯比例
-            int teamSize = cfReferralService.getTotalTeamSize(referrerId);
-            CfRateTier tier = cfReferralService.matchRateTier(teamSize);
-            int ratePercent = tier == null ? 0 : tier.getRatePercent();
-            if (ratePercent <= 0) continue;
-
-            // 分润基数:商品按 P点,非商品按服务费率
-            int base = calcReferrerBase(orderType, amountYuan, productId);
-            if (base <= 0) continue;
-            int share = base * ratePercent / 100;
-            if (share <= 0) continue;
 
-            try {
-                platformPointsService.earn(referrerId, share, "referral_dist", orderId,
-                        "推荐分润:" + orderType);
-                record(referrerId, null, referrer.getFamilyId(), share, "allocate", "referral_dist", orderId, "推荐分润");
-                // 自动创建感恩记录:邀请人的被邀请人购买后,记录为关键人关系
-                autoCreateGratitudeEffect(referrerId, buyerUserId, share, orderId);
-            } catch (Exception e) {
-                log.error("推荐人分润失败: referrer={}, orderId={}, err={}", referrerId, orderId, e.getMessage());
+            boolean direct = (awardedLevel == 0);
+            awardedLevel++;
+            awardSalesBonus(orderId, buyerUserId, referrerId,
+                    direct ? RULE_DIRECT_SALES : RULE_INDIRECT_SALES, basePpoints, direct);
+        }
+    }
+
+    /**
+     * 发放单级销售奖(F03a / F03b)。
+     *
+     * @param direct true=F03a 直接销售奖(团队档位),false=F03b 间接销售奖(自身档位)
+     */
+    private void awardSalesBonus(Long orderId, Long buyerUserId, Long referrerId,
+                                 String ruleCode, int basePpoints, boolean direct) {
+        int ratePercent = salesRatePercent(referrerId, ruleCode);
+        if (ratePercent <= 0) {
+            log.info("销售奖未发放(无匹配档位): rule={}, referrer={}, orderId={}", ruleCode, referrerId, orderId);
+            return;
+        }
+        Long memberId = resolveMemberId(referrerId);
+        if (memberId == null) {
+            log.warn("销售奖未发放(无家庭成员档案): rule={}, referrer={}, orderId={}", ruleCode, referrerId, orderId);
+            return;
+        }
+
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put("base_ppoints", basePpoints);
+        ctx.put("rate_percent", ratePercent);
+        CfEarnResult result;
+        try {
+            result = cfBalanceService.earnByRule(memberId, ruleCode, orderId, ctx);
+        } catch (Exception e) {
+            log.error("销售奖发放失败: rule={}, referrer={}, orderId={}, err={}",
+                    ruleCode, referrerId, orderId, e.getMessage());
+            return;
+        }
+        if (result == null || !Boolean.TRUE.equals(result.getEarned())) {
+            log.info("销售奖未发放: rule={}, referrer={}, orderId={}, reason={}", ruleCode, referrerId, orderId,
+                    result == null ? "null" : result.getReason());
+            return;
+        }
+        int share = result.getAmount() == null ? 0 : result.getAmount();
+        if (share <= 0) return;
+
+        // 落库统一走 cf_balance + cf_balance_log(ref_type=F03a/F03b、ref_id=orderId 可追溯,计划 §4.3);
+        // 不再写旧账本 cf_transfer_record,避免分佣出现第二套流水。
+        // 自动创建感恩记录:邀请人的被邀请人购买后,记录为关键人关系(仅直接销售奖触发)
+        if (direct && !Boolean.TRUE.equals(result.getDuplicated())) {
+            autoCreateGratitudeEffect(referrerId, buyerUserId, share, orderId);
+        }
+        log.info("销售奖发放成功: rule={}, referrer={}, orderId={}, base_ppoints={}, rate={}%, amount={}",
+                ruleCode, referrerId, orderId, basePpoints, ratePercent, share);
+    }
+
+    /**
+     * 销售奖比例:F03a 按团队 8 层内滚动一年 P 点档位,F03b 按自身滚动一年消费 P 点档位;
+     * 再按规则 {@code params_json.max_rate_percent} 封顶(档位表被改超上限时兜底)。
+     */
+    private int salesRatePercent(Long referrerId, String ruleCode) {
+        int tierRate;
+        if (RULE_DIRECT_SALES.equals(ruleCode)) {
+            int teamAnnualPpoints = cfReferralService.getTeamAnnualPpoints(referrerId, TEAM_DEPTH);
+            CfDirectSalesTier tier = cfReferralService.matchDirectSalesTier(teamAnnualPpoints);
+            tierRate = (tier == null || tier.getRatePercent() == null) ? 0 : tier.getRatePercent();
+        } else {
+            int selfAnnualPpoints = cfReferralService.getSelfAnnualPpoints(referrerId);
+            CfIndirectSalesTier tier = cfReferralService.matchIndirectSalesTier(selfAnnualPpoints);
+            tierRate = (tier == null || tier.getRatePercent() == null) ? 0 : tier.getRatePercent();
+        }
+        if (tierRate <= 0) return 0;
+        int maxRate = maxRatePercent(ruleCode);
+        if (maxRate > 0 && tierRate > maxRate) {
+            log.warn("销售奖比例超过规则上限,按上限封顶: rule={}, tierRate={}, max={}", ruleCode, tierRate, maxRate);
+            tierRate = maxRate;
+        }
+        return tierRate;
+    }
+
+    /** 规则上限比例(cf_earn_rule.params_json.max_rate_percent),缺失返回 0(不封顶) */
+    private int maxRatePercent(String ruleCode) {
+        try {
+            CfEarnRule rule = cfEarnRuleService.getByCode(ruleCode);
+            if (rule == null || rule.getParamsJson() == null || rule.getParamsJson().trim().isEmpty()) return 0;
+            Map<String, Object> params = objectMapper.readValue(rule.getParamsJson(),
+                    new TypeReference<Map<String, Object>>() {
+                    });
+            Object max = params.get("max_rate_percent");
+            if (max instanceof Number) return ((Number) max).intValue();
+            if (max != null && !String.valueOf(max).trim().isEmpty()) {
+                return Integer.parseInt(String.valueOf(max).trim());
             }
-            // 只返第一个非同家庭引荐人(D5),不再上溯更远推荐人
-            break;
+        } catch (Exception e) {
+            log.warn("读取规则上限比例失败: rule={}, err={}", ruleCode, e.getMessage());
         }
+        return 0;
     }
 
-    private int calcReferrerBase(String orderType, int amountYuan, Long productId) {
+    /**
+     * 本单消费产生的 P 点(销售奖基数)。
+     * 商品:实付(元) × 有效 P 点 / 100;非商品:实付(元) × 服务费率(bps) / 10000。
+     */
+    private int calcOrderPpoints(String orderType, int amountYuan, Long productId) {
         if ("product".equals(orderType) && productId != null) {
             try {
                 Product p = productMapper.selectById(productId);
                 if (p == null) return 0;
-                return ppointConfigService.getEffectivePpoint(productId, p.getCategoryId());
+                int effectivePpoint = ppointConfigService.getEffectivePpoint(productId, p.getCategoryId());
+                if (effectivePpoint <= 0) return 0;
+                return amountYuan * effectivePpoint / 100;
             } catch (Exception e) {
-                log.warn("计算推荐人分润基数失败: productId={}, err={}", productId, e.getMessage());
+                log.warn("计算本单 P 点失败: productId={}, err={}", productId, e.getMessage());
                 return 0;
             }
         }
-        int serviceRateBps = getSysBps("commission_service_rate", 1000);
+        int serviceRateBps = configService.getIntConfig(CfConfigKeys.COMMISSION_SERVICE_RATE, 1000);
+        if (serviceRateBps <= 0) return 0;
         return amountYuan * serviceRateBps / 10000;
     }
 
+    /** 落 P 点消费日志(唯一键 order_type+order_id,重复触发忽略) */
+    private void recordPpointConsumption(Long orderId, String orderType, Long buyerUserId, Long buyerFamilyId,
+                                         Integer amountCent, Long productId, int ppoints) {
+        try {
+            PpointConsumptionLog entity = new PpointConsumptionLog();
+            entity.setUserId(buyerUserId);
+            entity.setMemberId(resolveMemberId(buyerUserId));
+            entity.setFamilyId(buyerFamilyId);
+            entity.setOrderId(orderId);
+            entity.setOrderType(orderType);
+            entity.setProductId(productId);
+            entity.setAmountCent(amountCent);
+            entity.setPpoints(ppoints);
+            entity.setRemark("消费P点:" + orderType);
+            entity.setCreatedAt(new Date());
+            ppointConsumptionLogMapper.insert(entity);
+        } catch (DuplicateKeyException e) {
+            log.info("P 点消费日志已存在,跳过重复写入: orderType={}, orderId={}", orderType, orderId);
+        } catch (Exception e) {
+            log.warn("写 P 点消费日志失败: orderType={}, orderId={}, err={}", orderType, orderId, e.getMessage());
+        }
+    }
+
+    /** 用户ID → 家庭成员ID(family_members.user_id,登录账号自身;无档案返回 null) */
+    private Long resolveMemberId(Long userId) {
+        if (userId == null) return null;
+        try {
+            FamilyMember member = familyMemberMapper.selectOne(
+                    new LambdaQueryWrapper<FamilyMember>()
+                            .eq(FamilyMember::getUserId, userId)
+                            .orderByAsc(FamilyMember::getId)
+                            .last("LIMIT 1"));
+            return member == null ? null : member.getId();
+        } catch (Exception e) {
+            log.warn("查询家庭成员档案失败: userId={}, err={}", userId, e.getMessage());
+            return null;
+        }
+    }
+
     private int getSysBps(String key, int def) {
         try {
             String v = sysConfigService.getValue(key);

+ 142 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/CfReferralService.java

@@ -1,10 +1,15 @@
 package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.CfDirectSalesTier;
+import com.etotem.cfc.entity.CfIndirectSalesTier;
 import com.etotem.cfc.entity.CfRateTier;
 import com.etotem.cfc.entity.ReferralTree;
 import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.CfDirectSalesTierMapper;
+import com.etotem.cfc.mapper.CfIndirectSalesTierMapper;
 import com.etotem.cfc.mapper.CfRateTierMapper;
+import com.etotem.cfc.mapper.PpointConsumptionLogMapper;
 import com.etotem.cfc.mapper.ReferralTreeMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import org.springframework.stereotype.Service;
@@ -12,21 +17,49 @@ import org.springframework.transaction.annotation.Transactional;
 
 import javax.annotation.Resource;
 import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Collections;
 import java.util.Date;
+import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Set;
 
 /**
- * 推荐关系物化与团队规模统计(全层级)
+ * 推荐关系物化与团队统计(团队规模 + 团队/自身滚动一年 P 点累计 + 两级销售奖档位匹配)。
+ *
+ * <p>口径见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §4.1 / §4.2 / §4.4:
+ * <ul>
+ *   <li>{@code getTeamAnnualPpoints(referrerId, 8)}:referrerId 向下 8 层内所有成员滚动一年消费 P 点累计;</li>
+ *   <li>{@code getSelfAnnualPpoints(referrerId)}:本人滚动一年消费 P 点累计;</li>
+ *   <li>档位匹配:直接销售奖读 {@code cf_direct_sales_tier}(按团队年度 P 点),
+ *       间接销售奖读 {@code cf_indirect_sales_tier}(按自身年度 P 点),滚动窗口内累计下降即自动降档。</li>
+ * </ul>
+ * </p>
  */
 @Service
 public class CfReferralService {
 
+    /** F03a 团队口径最大层级(§4.1.1:团队 8 层以内) */
+    public static final int DEFAULT_TEAM_DEPTH = 8;
+
+    /** 滚动一年窗口天数(§4.1/§4.4:按消费时间滚动一年) */
+    private static final int ROLLING_YEAR_DAYS = 365;
+
+    /** 年度累计查询的用户ID批大小(IN 列表分批,避免超长 SQL) */
+    private static final int USER_ID_BATCH_SIZE = 500;
+
     @Resource
     private ReferralTreeMapper referralTreeMapper;
     @Resource
     private UserMapper userMapper;
     @Resource
     private CfRateTierMapper cfRateTierMapper;
+    @Resource
+    private CfDirectSalesTierMapper cfDirectSalesTierMapper;
+    @Resource
+    private CfIndirectSalesTierMapper cfIndirectSalesTierMapper;
+    @Resource
+    private PpointConsumptionLogMapper ppointConsumptionLogMapper;
 
     /**
      * 绑定推荐关系时物化整条祖先链(不含本人,直接推荐人为 level 1)到 referral_tree。
@@ -76,6 +109,10 @@ public class CfReferralService {
     /**
      * 按团队规模匹配阶梯比例:cf_rate_tier WHERE enabled=1 AND min_team_size <= size ORDER BY min_team_size DESC LIMIT 1
      * 无匹配返回 null。
+     *
+     * <p>注:cf_rate_tier 已按 §4.3 废弃(表保留只读),仅供等级展示/历史场景使用;
+     * 分佣链路已改用两级销售奖档位(F03a 走 {@link #matchDirectSalesTier},F03b 走
+     * {@link #matchIndirectSalesTier})。</p>
      */
     public CfRateTier matchRateTier(int teamSize) {
         List<CfRateTier> tiers = cfRateTierMapper.selectList(
@@ -86,4 +123,108 @@ public class CfReferralService {
                         .last("LIMIT 1"));
         return tiers.isEmpty() ? null : tiers.get(0);
     }
+
+    // ==================== 两级销售奖档位匹配(§4.2) ====================
+
+    /**
+     * 直接销售奖档位匹配:cf_direct_sales_tier WHERE enabled=1
+     * AND min_annual_ppoints <= teamAnnualPpoints ORDER BY min_annual_ppoints DESC LIMIT 1
+     * 无匹配返回 null(此时不发放 F03a)。
+     */
+    public CfDirectSalesTier matchDirectSalesTier(int teamAnnualPpoints) {
+        List<CfDirectSalesTier> tiers = cfDirectSalesTierMapper.selectList(
+                new LambdaQueryWrapper<CfDirectSalesTier>()
+                        .eq(CfDirectSalesTier::getEnabled, 1)
+                        .le(CfDirectSalesTier::getMinAnnualPpoints, teamAnnualPpoints)
+                        .orderByDesc(CfDirectSalesTier::getMinAnnualPpoints)
+                        .last("LIMIT 1"));
+        return tiers.isEmpty() ? null : tiers.get(0);
+    }
+
+    /**
+     * 间接销售奖档位匹配:cf_indirect_sales_tier WHERE enabled=1
+     * AND min_self_ppoints <= selfAnnualPpoints ORDER BY min_self_ppoints DESC LIMIT 1
+     * 无匹配返回 null(此时不发放 F03b)。
+     */
+    public CfIndirectSalesTier matchIndirectSalesTier(int selfAnnualPpoints) {
+        List<CfIndirectSalesTier> tiers = cfIndirectSalesTierMapper.selectList(
+                new LambdaQueryWrapper<CfIndirectSalesTier>()
+                        .eq(CfIndirectSalesTier::getEnabled, 1)
+                        .le(CfIndirectSalesTier::getMinSelfPpoints, selfAnnualPpoints)
+                        .orderByDesc(CfIndirectSalesTier::getMinSelfPpoints)
+                        .last("LIMIT 1"));
+        return tiers.isEmpty() ? null : tiers.get(0);
+    }
+
+    // ==================== 滚动一年 P 点累计(§4.4) ====================
+
+    /**
+     * 团队年度累计 P 点:referrerId 向下 depth 层内所有成员,滚动一年(按消费时间)消费 P 点累计。
+     *
+     * <p>数据源 {@code ppoint_consumption_log}(消费支付成功时由 {@code CfCommissionService}
+     * 落一行);referral_tree 的 level 即祖先链深度,{@code parent_id = referrerId AND level <= depth}
+     * 的 child_id 集合即团队内成员(不含本人)。</p>
+     *
+     * @param referrerUserId 推荐人用户ID(users.id)
+     * @param depth          向下最大层级(默认 8,≤0 时按 8 处理)
+     */
+    public int getTeamAnnualPpoints(Long referrerUserId, int depth) {
+        return sumAnnualPpoints(getTeamUserIds(referrerUserId, depth));
+    }
+
+    /**
+     * 本人年度累计消费 P 点:滚动一年(按消费时间)本人消费产生的 P 点累计(§4.1.2 F03b 档位口径)。
+     */
+    public int getSelfAnnualPpoints(Long referrerUserId) {
+        if (referrerUserId == null) {
+            return 0;
+        }
+        return sumAnnualPpoints(Collections.singletonList(referrerUserId));
+    }
+
+    /**
+     * 团队用户ID集合:referrerUserId 向下 depth 层内所有成员(referral_tree.parent_id = 本人,
+     * level <= depth 的 child_id 去重;不含本人)。空=无团队。
+     */
+    public List<Long> getTeamUserIds(Long referrerUserId, int depth) {
+        if (referrerUserId == null) {
+            return Collections.emptyList();
+        }
+        int safeDepth = depth <= 0 ? DEFAULT_TEAM_DEPTH : depth;
+        List<ReferralTree> rows = referralTreeMapper.selectList(
+                new LambdaQueryWrapper<ReferralTree>()
+                        .eq(ReferralTree::getParentId, referrerUserId)
+                        .le(ReferralTree::getLevel, safeDepth));
+        if (rows == null || rows.isEmpty()) {
+            return Collections.emptyList();
+        }
+        Set<Long> ids = new LinkedHashSet<>();
+        for (ReferralTree row : rows) {
+            if (row.getChildId() != null && !row.getChildId().equals(referrerUserId)) {
+                ids.add(row.getChildId());
+            }
+        }
+        return new ArrayList<>(ids);
+    }
+
+    /** 滚动一年起点(now - 365 天,含) */
+    public Date rollingYearAgo() {
+        Calendar cal = Calendar.getInstance();
+        cal.add(Calendar.DAY_OF_YEAR, -ROLLING_YEAR_DAYS);
+        return cal.getTime();
+    }
+
+    /** 按用户集合分批求和滚动一年 P 点(IN 分批避免超长 SQL) */
+    private int sumAnnualPpoints(List<Long> userIds) {
+        if (userIds == null || userIds.isEmpty()) {
+            return 0;
+        }
+        Date since = rollingYearAgo();
+        int total = 0;
+        for (int i = 0; i < userIds.size(); i += USER_ID_BATCH_SIZE) {
+            List<Long> batch = userIds.subList(i, Math.min(i + USER_ID_BATCH_SIZE, userIds.size()));
+            total += ppointConsumptionLogMapper.sumPpointsSince(batch, since);
+        }
+        return total;
+    }
 }

+ 22 - 137
cfc-backend/src/main/java/com/etotem/cfc/service/CommissionDistService.java

@@ -6,25 +6,22 @@ import com.etotem.cfc.mapper.*;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
 
 import javax.annotation.Resource;
-import java.util.Date;
 import java.util.List;
 import com.etotem.cfc.util.SortUtil;
 
 /**
- * P点分润服务
- * 
- * 核心逻辑:
- * 1. 用户购买商品/活动产生P点(基于PpointConfigService计算)
- * 2. 查找购买用户的推荐链(referral_tree),取一级和二级推荐人
- * 3. 每个推荐人在 promotion_tier 表中查其财富成长等级
- * 4. 财富等级的 profitSharePercent 决定了该推荐人分得P点的比例
- * 5. 仅一级和二级推荐人参与分润
- * 
- * 分润公式:
- *   推荐人分得P点 = sourcePpoint × profitSharePercent / 100
+ * P点分润服务(只读历史表服务)。
+ *
+ * <p>口径见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §四 / §七 F-4:</p>
+ * <ul>
+ *   <li>旧分润口径({@code cf_rate_tier} 团队规模阶梯 / {@code promotion_tier} 财富成长等级)已废弃;</li>
+ *   <li>写接口 {@link #distribute} 标记 <b>410 Gone</b>,{@code commission_dist_log} 表转只读历史表;</li>
+ *   <li>两级销售奖(F03a 直接 + F03b 间接)统一由 {@link CfCommissionService} 计算并发放到
+ *       {@code cf_balance} + {@code cf_balance_log};</li>
+ *   <li>本服务仅保留历史分润记录的读取查询(管理端/用户端历史列表)。</li>
+ * </ul>
  *
  * @deprecated 已由 CfCommissionService 统一 CF 值分佣替代,本服务不再被业务订单调用,保留历史数据读取。
  */
@@ -37,134 +34,22 @@ public class CommissionDistService {
     @Resource
     private CommissionDistLogMapper commissionDistLogMapper;
 
-    @Resource
-    private ReferralTreeMapper referralTreeMapper;
-
-    @Resource
-    private PromotionTierService promotionTierService;
-
-    @Resource
-    private PpointConfigService ppointConfigService;
-
-    @Resource
-    private PlatformPointsService platformPointsService;
-
-    @Resource
-    private ProductMapper productMapper;
-
     /**
-     * 执行P点分润
-     * 当用户的购买产生P点时,按推荐关系分配给一级和二级推荐人
+     * 执行P点分润(写接口已废弃,410 Gone)。
+     *
+     * <p>口径见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §四 / §七 F-4:
+     * 旧"团队规模阶梯 / 财富成长等级"双轨分佣已收口为 F03a 直接销售奖 + F03b 间接销售奖,
+     * 由 {@link CfCommissionService} 统一发放(写 {@code cf_balance} + {@code cf_balance_log}),
+     * {@code commission_dist_log} 表转只读历史表、停止写入。</p>
      *
-     * @param buyerId  买家用户ID
-     * @param orderId  订单ID
-     * @param productId 商品ID
-     * @return 分润记录,若无需分润返回null
+     * @deprecated 已废弃(410 Gone),请改用 {@link CfCommissionService#settle} /
+     *             {@link CfCommissionService#settleReferrerOnly}
      */
-    @Transactional
+    @Deprecated
     public CommissionDistLog distribute(Long buyerId, Long orderId, Long productId) {
-        if (buyerId == null || productId == null) {
-            return null;
-        }
-
-        // 1. 计算该商品产生的P点
-        Product product = productMapper.selectById(productId);
-        if (product == null) {
-            return null;
-        }
-        int sourcePpoint = ppointConfigService.getEffectivePpoint(productId, product.getCategoryId());
-        if (sourcePpoint <= 0) {
-            log.info("商品{}无P点配置,不分润", productId);
-            return null;
-        }
-
-        // 2. 查找购买用户的一级和二级推荐人
-        LambdaQueryWrapper<ReferralTree> wrapper = new LambdaQueryWrapper<ReferralTree>()
-                .eq(ReferralTree::getChildId, buyerId)
-                .in(ReferralTree::getLevel, 1, 2)
-                .orderByAsc(ReferralTree::getLevel);
-        SortUtil.applySort(wrapper);
-        List<ReferralTree> referrals = referralTreeMapper.selectList(wrapper);
-
-        if (referrals == null || referrals.isEmpty()) {
-            log.info("用户{}无推荐关系,不分润", buyerId);
-            return null;
-        }
-
-        // 3. 按推荐等级分配P点
-        Long level1UserId = null;
-        Integer level1Ppoint = 0;
-        Integer level1WealthPercent = 0;
-        Long level2UserId = null;
-        Integer level2Ppoint = 0;
-        Integer level2WealthPercent = 0;
-
-        for (ReferralTree ref : referrals) {
-            int level = ref.getLevel();
-            Long referrerId = ref.getParentId();
-
-            // 获取推荐人当前财富成长等级
-            PromotionTier tier = promotionTierService.getCurrentTier(referrerId);
-            int wealthPercent = 0;
-            if (tier != null) {
-                // 从 promotion_tier_config 查 profitSharePercent
-                PromotionTierConfig config = promotionTierService.getTierConfig(tier.getTier());
-                if (config != null && config.getProfitSharePercent() != null) {
-                    wealthPercent = config.getProfitSharePercent();
-                }
-            }
-
-            // 计算该推荐人应分得P点
-            // 公式:sourcePpoint * wealthPercent / 100
-            int ppointShare = (int) Math.floor((double) sourcePpoint * wealthPercent / 100.0);
-
-            if (level == 1) {
-                level1UserId = referrerId;
-                level1Ppoint = ppointShare;
-                level1WealthPercent = wealthPercent;
-            } else if (level == 2) {
-                level2UserId = referrerId;
-                level2Ppoint = ppointShare;
-                level2WealthPercent = wealthPercent;
-            }
-        }
-
-        // 4. 记录分润日志
-        CommissionDistLog logRecord = new CommissionDistLog();
-        logRecord.setBuyerId(buyerId);
-        logRecord.setOrderId(orderId);
-        logRecord.setProductId(productId);
-        logRecord.setSourcePpoint(sourcePpoint);
-        logRecord.setLevel1UserId(level1UserId);
-        logRecord.setLevel1Ppoint(level1Ppoint);
-        logRecord.setLevel1WealthPercent(level1WealthPercent);
-        logRecord.setLevel2UserId(level2UserId);
-        logRecord.setLevel2Ppoint(level2Ppoint);
-        logRecord.setLevel2WealthPercent(level2WealthPercent);
-        logRecord.setStatus("completed");
-        logRecord.setRemark("商品P点分润");
-        logRecord.setCreatedAt(new Date());
-        commissionDistLogMapper.insert(logRecord);
-
-        // 给推荐人写 CF 值余额
-        try {
-            if (level1UserId != null && level1Ppoint > 0) {
-                platformPointsService.earn(level1UserId, level1Ppoint,
-                        "referral_dist", orderId, "一级推荐人分润");
-            }
-            if (level2UserId != null && level2Ppoint > 0) {
-                platformPointsService.earn(level2UserId, level2Ppoint,
-                        "referral_dist", orderId, "二级推荐人分润");
-            }
-        } catch (Exception e) {
-            log.error("推荐人CF值写入失败: buyerId={}, error={}", buyerId, e.getMessage());
-        }
-
-        log.info("P点分润完成: buyerId={}, productId={}, sourcePpoint={}, level1={}/{}pp, level2={}/{}pp",
-                buyerId, productId, sourcePpoint,
-                level1UserId, level1Ppoint, level2UserId, level2Ppoint);
-
-        return logRecord;
+        log.warn("旧分佣写接口已废弃(410 Gone):buyerId={}, orderId={}, productId={};"
+                + "请改用 CfCommissionService(F03a/F03b 两级销售奖)", buyerId, orderId, productId);
+        throw new IllegalStateException("410 Gone:旧分佣写接口已废弃,请使用 CfCommissionService(F03a/F03b 两级销售奖)");
     }
 
     /**

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

@@ -6787,6 +6787,23 @@ CREATE TABLE IF NOT EXISTS cf_earn_daily_limit (
     UNIQUE KEY uk_rule_level (rule_code, level_code)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CF值规则×会员等级每日次数上限(N11)';
 
+-- 表 P点消费日志(滚动一年销售奖档位聚合数据源,F-4)
+CREATE TABLE IF NOT EXISTS ppoint_consumption_log (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT NOT NULL COMMENT '消费人用户ID(users.id)',
+    member_id BIGINT NULL COMMENT '消费人家庭成员ID(family_members.id)',
+    family_id BIGINT NULL COMMENT '消费家庭ID',
+    order_id BIGINT NOT NULL COMMENT '订单ID',
+    order_type VARCHAR(32) NOT NULL COMMENT '订单类型(product/package/assessment/membership/subscription)',
+    product_id BIGINT NULL COMMENT '商品ID(商品订单)',
+    amount_cent INT NULL COMMENT '实付金额(分)',
+    ppoints INT NOT NULL DEFAULT 0 COMMENT '本单产生的P点',
+    remark VARCHAR(255),
+    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '消费时间(订单支付成功时刻)',
+    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)';
+
 -- 表 服务配置中心(L2,CF通用运营参数归口)
 CREATE TABLE IF NOT EXISTS service_config (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,

+ 92 - 0
cfc-backend/src/test/java/com/etotem/cfc/config/DatabaseInitializerSeedTest.java

@@ -5,12 +5,20 @@ import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.ArgumentCaptor;
 import org.mockito.InjectMocks;
 import org.mockito.Mock;
+import org.mockito.Mockito;
 import org.mockito.junit.jupiter.MockitoExtension;
 import org.springframework.jdbc.core.JdbcTemplate;
 
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 @ExtendWith(MockitoExtension.class)
 class DatabaseInitializerSeedTest {
@@ -30,4 +38,88 @@ class DatabaseInitializerSeedTest {
         assertTrue(sql.getValue().contains("(expected_indicators IS NULL OR expected_indicators = '')"),
                 "SQL 必须仅在列为空时写入,避免覆盖管理员编辑");
     }
+
+    // ---------------- F-4 迁移418/419(分佣收口) ----------------
+
+    @Test
+    void seedCfSalesTiers_档位缺失时补齐两级各5档() {
+        // 档位表为空 → 每档都插入
+        when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), any())).thenReturn(0);
+
+        initializer.seedCfSalesTiers();
+
+        List<String> sqls = updateSqls();
+        long directInserts = sqls.stream()
+                .filter(s -> s.startsWith("INSERT INTO cf_direct_sales_tier")).count();
+        long indirectInserts = sqls.stream()
+                .filter(s -> s.startsWith("INSERT INTO cf_indirect_sales_tier")).count();
+        assertEquals(5, directInserts, "直接销售奖应补齐 5 档(青铜/白银/黄金/铂金/钻石)");
+        assertEquals(5, indirectInserts, "间接销售奖应补齐 5 档(青铜/白银/黄金/铂金/钻石)");
+    }
+
+    @Test
+    void seedCfSalesTiers_档位已存在时不重复插入() {
+        // 档位已存在 → 跳过
+        when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), any())).thenReturn(1);
+
+        initializer.seedCfSalesTiers();
+
+        assertEquals(0, updateSqls().stream()
+                .filter(s -> s.startsWith("INSERT INTO cf_")).count(),
+                "档位已存在时不得重复插入");
+    }
+
+    @Test
+    void seedCfSalesTiers_直接档位按团队P点下限判断() {
+        when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), any())).thenReturn(0);
+
+        initializer.seedCfSalesTiers();
+
+        ArgumentCaptor<String> sql = ArgumentCaptor.forClass(String.class);
+        verify(jdbcTemplate, Mockito.times(10)).queryForObject(sql.capture(), eq(Integer.class), any());
+        List<String> checks = sql.getAllValues();
+        assertEquals(5, checks.stream().filter(s -> s.contains("cf_direct_sales_tier")).count(),
+                "直接销售奖 5 档各判重一次");
+        assertEquals(5, checks.stream().filter(s -> s.contains("cf_indirect_sales_tier")).count(),
+                "间接销售奖 5 档各判重一次");
+        assertTrue(checks.stream().anyMatch(s -> s.contains("min_annual_ppoints = ?")),
+                "直接销售奖档位判重必须按团队年度 P 点下限(min_annual_ppoints)");
+        assertTrue(checks.stream().anyMatch(s -> s.contains("min_self_ppoints = ?")),
+                "间接销售奖档位判重必须按自身年度 P 点下限(min_self_ppoints)");
+    }
+
+    @Test
+    void alignSalesRuleRefTypes_把F03aF03b幂等键对齐为规则编码() {
+        initializer.alignSalesRuleRefTypes();
+
+        List<String> sqls = updateSqls();
+        assertTrue(sqls.stream().anyMatch(s -> s.contains("UPDATE cf_earn_rule SET dedup_ref_type = 'F03a'")
+                        && s.contains("WHERE code = 'F03a'")),
+                "F03a 的 dedup_ref_type 应对齐为 F03a");
+        assertTrue(sqls.stream().anyMatch(s -> s.contains("UPDATE cf_earn_rule SET dedup_ref_type = 'F03b'")
+                        && s.contains("WHERE code = 'F03b'")),
+                "F03b 的 dedup_ref_type 应对齐为 F03b");
+    }
+
+    @Test
+    void createPpointConsumptionLogTable_执行建表语句() {
+        initializer.createPpointConsumptionLogTable();
+
+        ArgumentCaptor<String> sql = ArgumentCaptor.forClass(String.class);
+        verify(jdbcTemplate).execute(sql.capture());
+        assertTrue(sql.getValue().contains("CREATE TABLE IF NOT EXISTS `ppoint_consumption_log`"),
+                "应幂等建表 ppoint_consumption_log");
+        assertTrue(sql.getValue().contains("UNIQUE KEY `uk_order` (`order_type`, `order_id`)"),
+                "P 点消费日志必须带 (order_type, order_id) 唯一键以保证重复触发只记一行");
+        assertTrue(sql.getValue().contains("KEY `idx_user_time` (`user_id`, `created_at`)"),
+                "P 点消费日志必须带 user_id+created_at 索引以支持滚动一年聚合");
+    }
+
+    /** 收集 update 调用的 SQL(避免 varargs 匹配的脆弱性,用 mockingDetails 遍历) */
+    private List<String> updateSqls() {
+        return Mockito.mockingDetails(jdbcTemplate).getInvocations().stream()
+                .filter(inv -> "update".equals(inv.getMethod().getName()))
+                .map(inv -> inv.<String>getArgument(0))
+                .collect(Collectors.toList());
+    }
 }

+ 338 - 38
cfc-backend/src/test/java/com/etotem/cfc/service/CfCommissionServiceSettleTest.java

@@ -1,37 +1,70 @@
 package com.etotem.cfc.service;
 
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.etotem.cfc.entity.CfRateTier;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.config.ConfigService;
+import com.etotem.cfc.constant.CfConfigKeys;
+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.FamilyMember;
+import com.etotem.cfc.entity.OctopusEffectRecord;
+import com.etotem.cfc.entity.OctopusKeyPerson;
+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;
+import com.etotem.cfc.mapper.OctopusKeyPersonMapper;
+import com.etotem.cfc.mapper.PpointConsumptionLogMapper;
 import com.etotem.cfc.mapper.ProductMapper;
 import com.etotem.cfc.mapper.ReferralTreeMapper;
 import com.etotem.cfc.mapper.UserMapper;
+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.InjectMocks;
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
+import org.mockito.Spy;
 
 import java.util.Arrays;
+import java.util.Collections;
+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;
 import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 /**
- * CF 分佣"只返第一个非同家庭引荐人"(设计决策 D5)结算行为测试。
+ * CF 分佣收口(F-4)两级销售奖结算行为测试。
  *
- * 验证 settle 在推荐链上的上溯语义:
- *  - 同家庭推荐人跳过,继续上溯
- *  - 找到第一个非同家庭引荐人后发放分润并终止(不再给更远推荐人发放)
+ * <p>覆盖口径(计划 §四 / §3.1 / §4.2 / §4.4):</p>
+ * <ul>
+ *   <li>第 1 个非同家庭引荐人 → F03a 直接销售奖,比例取"团队 8 层内滚动一年累计 P 点"档位;</li>
+ *   <li>第 2 个非同家庭引荐人 → F03b 间接销售奖,比例取"自身滚动一年累计消费 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>F03a 发放时创建感恩记录,幂等命中(duplicated)不重复创建。</li>
+ * </ul>
  */
 class CfCommissionServiceSettleTest {
 
@@ -51,6 +84,24 @@ class CfCommissionServiceSettleTest {
     private SysConfigService sysConfigService;
     @Mock
     private ProductMapper productMapper;
+    @Mock
+    private OctopusKeyPersonMapper octopusKeyPersonMapper;
+    @Mock
+    private OctopusEffectRecordMapper octopusEffectRecordMapper;
+    @Mock
+    private OctopusEffectKeyPersonMapper octopusEffectKeyPersonMapper;
+    @Mock
+    private CfBalanceService cfBalanceService;
+    @Mock
+    private CfEarnRuleService cfEarnRuleService;
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+    @Mock
+    private PpointConsumptionLogMapper ppointConsumptionLogMapper;
+    @Mock
+    private ConfigService configService;
+    @Spy
+    private ObjectMapper objectMapper = new ObjectMapper();
 
     @InjectMocks
     private CfCommissionService cfCommissionService;
@@ -60,19 +111,70 @@ class CfCommissionServiceSettleTest {
     private static final Long BUYER_ID = 100L;
     private static final Long BUYER_FAMILY = 100L;
 
+    /** 家庭成员ID = 用户ID + 1000,便于断言 */
+    private static Long memberIdOf(Long userId) {
+        return userId + 1000;
+    }
+
     @BeforeEach
     void setUp() {
         MockitoAnnotations.openMocks(this);
-        // 商品:P点=100,平台分享比例=1000bps(10%)
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), ReferralTree.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), FamilyMember.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), OctopusKeyPerson.class);
+
+        // 商品:有效 P 点=100(商品订单 100 元 → 基数 100 P 点)
         Product p = new Product();
         p.setId(PRODUCT_ID);
         p.setCategoryId(1L);
         when(productMapper.selectById(PRODUCT_ID)).thenReturn(p);
         when(ppointConfigService.getEffectivePpoint(PRODUCT_ID, 1L)).thenReturn(100);
+        // 买家 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);
+
+        // 用户ID → 家庭成员档案
+        when(familyMemberMapper.selectOne(any())).thenAnswer(inv -> {
+            LambdaQueryWrapper<FamilyMember> wrapper = inv.getArgument(0);
+            // MP 惰性绑定参数:必须先生成 SQL 片段,paramNameValuePairs 才有值
+            wrapper.getSqlSegment();
+            Long uid = wrapper.getParamNameValuePairs().values().stream()
+                    .filter(v -> v instanceof Long)
+                    .map(v -> (Long) v)
+                    .findFirst()
+                    .orElse(null);
+            return uid == null ? null : member(uid);
+        });
+
+        // 规则上限比例:F03a ≤ 70%,F03b ≤ 20%
+        when(cfEarnRuleService.getByCode("F03a"))
+                .thenReturn(earnRule("F03a", "{\"base\":\"ppoints\",\"tier_table\":\"cf_direct_sales_tier\","
+                        + "\"window\":\"1y_rolling\",\"team_depth\":8,\"max_rate_percent\":70,\"downgrade\":true}"));
+        when(cfEarnRuleService.getByCode("F03b"))
+                .thenReturn(earnRule("F03b", "{\"base\":\"ppoints\",\"tier_table\":\"cf_indirect_sales_tier\","
+                        + "\"window\":\"1y_rolling\",\"max_rate_percent\":20,\"downgrade\":true}"));
+
+        // P 点消费日志写入
+        when(ppointConsumptionLogMapper.insert(any())).thenReturn(1);
+        // 感恩记录相关
+        when(octopusKeyPersonMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(octopusKeyPersonMapper.insert(any())).thenAnswer(inv -> {
+            OctopusKeyPerson kp = inv.getArgument(0);
+            kp.setId(500L);
+            return 1;
+        });
+        when(octopusEffectRecordMapper.insert(any())).thenAnswer(inv -> {
+            OctopusEffectRecord record = inv.getArgument(0);
+            record.setId(600L);
+            return 1;
+        });
+        when(octopusEffectKeyPersonMapper.insert(any())).thenReturn(1);
     }
 
+    // ---------------- 测试夹具 ----------------
+
     private ReferralTree ref(Long parent, int level) {
         ReferralTree r = new ReferralTree();
         r.setParentId(parent);
@@ -88,67 +190,265 @@ class CfCommissionServiceSettleTest {
         return u;
     }
 
-    private CfRateTier tier(int rate) {
-        CfRateTier t = new CfRateTier();
+    private FamilyMember member(Long userId) {
+        FamilyMember m = new FamilyMember();
+        m.setId(memberIdOf(userId));
+        m.setUserId(userId);
+        m.setFamilyId(userId + 5000);
+        return m;
+    }
+
+    private CfDirectSalesTier directTier(String name, int min, int rate) {
+        CfDirectSalesTier t = new CfDirectSalesTier();
+        t.setTierName(name);
+        t.setMinAnnualPpoints(min);
+        t.setRatePercent(rate);
+        t.setEnabled(1);
+        return t;
+    }
+
+    private CfIndirectSalesTier indirectTier(String name, int min, int rate) {
+        CfIndirectSalesTier t = new CfIndirectSalesTier();
+        t.setTierName(name);
+        t.setMinSelfPpoints(min);
         t.setRatePercent(rate);
+        t.setEnabled(1);
         return t;
     }
 
+    private CfEarnRule earnRule(String code, String paramsJson) {
+        CfEarnRule r = new CfEarnRule();
+        r.setCode(code);
+        r.setEnabled(1);
+        r.setRuleType("formula");
+        r.setFormula("floor(base_ppoints * rate_percent / 100)");
+        r.setParamsJson(paramsJson);
+        r.setDedupRefType(code);
+        return r;
+    }
+
+    private CfEarnResult earnResult(int amount, boolean duplicated) {
+        CfEarnResult r = new CfEarnResult();
+        r.setEarned(true);
+        r.setAmount(amount);
+        r.setDuplicated(duplicated);
+        return r;
+    }
+
     private void mockReferralChain(ReferralTree... nodes) {
         when(referralTreeMapper.selectList(any(LambdaQueryWrapper.class)))
                 .thenReturn(Arrays.asList(nodes));
     }
 
+    // ---------------- 测试 ----------------
+
+    @Test
+    void settle_两级销售奖_F03a按团队档位_F03b按自身档位() {
+        mockReferralChain(ref(10L, 1), ref(20L, 2));
+        when(userMapper.selectById(10L)).thenReturn(user(10L, 200L));
+        when(userMapper.selectById(20L)).thenReturn(user(20L, 300L));
+        // F03a:团队 8 层滚动一年累计 2000 P 点 → 黄金档 40%
+        when(cfReferralService.getTeamAnnualPpoints(10L, 8)).thenReturn(2000);
+        when(cfReferralService.matchDirectSalesTier(2000)).thenReturn(directTier("黄金", 2000, 40));
+        // F03b:自身滚动一年累计 500 P 点 → 白银档 5%
+        when(cfReferralService.getSelfAnnualPpoints(20L)).thenReturn(500);
+        when(cfReferralService.matchIndirectSalesTier(500)).thenReturn(indirectTier("白银", 500, 5));
+        when(cfBalanceService.earnByRule(eq(memberIdOf(10L)), eq("F03a"), eq(ORDER_ID), any()))
+                .thenReturn(earnResult(40, false));
+        when(cfBalanceService.earnByRule(eq(memberIdOf(20L)), eq("F03b"), eq(ORDER_ID), any()))
+                .thenReturn(earnResult(5, false));
+
+        cfCommissionService.settle(ORDER_ID, "product", BUYER_ID, BUYER_FAMILY, 10000, PRODUCT_ID);
+
+        // F03a:基数 100 P 点 × 40%
+        ArgumentCaptor<Map<String, Object>> ctxCaptor = ArgumentCaptor.forClass(Map.class);
+        verify(cfBalanceService).earnByRule(eq(memberIdOf(10L)), eq("F03a"), eq(ORDER_ID), ctxCaptor.capture());
+        assertEquals(100, ctxCaptor.getValue().get("base_ppoints"));
+        assertEquals(40, ctxCaptor.getValue().get("rate_percent"));
+
+        // F03b:基数 100 P 点 × 5%
+        ArgumentCaptor<Map<String, Object>> ctxCaptor2 = ArgumentCaptor.forClass(Map.class);
+        verify(cfBalanceService).earnByRule(eq(memberIdOf(20L)), eq("F03b"), eq(ORDER_ID), ctxCaptor2.capture());
+        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());
+    }
+
     @Test
-    void settle_同家庭推荐人跳过_只返第一个非同家庭() {
-        // 链路:level1(10) 同家庭 → 跳过;level2(20) 非同家庭 → 发放后终止;level3(30) 不应再发放
+    void settle_销售奖只走cfBalance_不再写旧流转记录() {
+        mockReferralChain(ref(10L, 1), ref(20L, 2));
+        when(userMapper.selectById(10L)).thenReturn(user(10L, 200L));
+        when(userMapper.selectById(20L)).thenReturn(user(20L, 300L));
+        when(cfReferralService.getTeamAnnualPpoints(10L, 8)).thenReturn(100);
+        when(cfReferralService.matchDirectSalesTier(100)).thenReturn(directTier("青铜", 100, 10));
+        when(cfReferralService.getSelfAnnualPpoints(20L)).thenReturn(100);
+        when(cfReferralService.matchIndirectSalesTier(100)).thenReturn(indirectTier("青铜", 100, 2));
+        when(cfBalanceService.earnByRule(anyLong(), anyString(), anyLong(), any()))
+                .thenReturn(earnResult(10, false));
+
+        cfCommissionService.settle(ORDER_ID, "product", BUYER_ID, BUYER_FAMILY, 10000, PRODUCT_ID);
+
+        // cf_transfer_record 只写买家 F02 一笔,销售奖不再产生第二套流水
+        verify(cfTransferRecordMapper, times(1)).insert(any());
+    }
+
+    @Test
+    void settle_同家庭引荐人跳过_顺延到下一级拿F03a() {
         mockReferralChain(ref(10L, 1), ref(20L, 2), ref(30L, 3));
         when(userMapper.selectById(10L)).thenReturn(user(10L, BUYER_FAMILY));
         when(userMapper.selectById(20L)).thenReturn(user(20L, 200L));
         when(userMapper.selectById(30L)).thenReturn(user(30L, 300L));
-        // 让 level2、level3 都有有效阶梯比例,以区分"只返第一个"与"全层级都返"
-        when(cfReferralService.getTotalTeamSize(20L)).thenReturn(5);
-        when(cfReferralService.matchRateTier(5)).thenReturn(tier(10));
-        when(cfReferralService.getTotalTeamSize(30L)).thenReturn(5);
-        when(cfReferralService.matchRateTier(5)).thenReturn(tier(10));
+        when(cfReferralService.getTeamAnnualPpoints(20L, 8)).thenReturn(500);
+        when(cfReferralService.matchDirectSalesTier(500)).thenReturn(directTier("白银", 500, 25));
+        when(cfReferralService.getSelfAnnualPpoints(30L)).thenReturn(100);
+        when(cfReferralService.matchIndirectSalesTier(100)).thenReturn(indirectTier("青铜", 100, 2));
+        when(cfBalanceService.earnByRule(eq(memberIdOf(20L)), eq("F03a"), eq(ORDER_ID), any()))
+                .thenReturn(earnResult(25, false));
+        when(cfBalanceService.earnByRule(eq(memberIdOf(30L)), eq("F03b"), eq(ORDER_ID), any()))
+                .thenReturn(earnResult(2, false));
 
         cfCommissionService.settle(ORDER_ID, "product", BUYER_ID, BUYER_FAMILY, 10000, PRODUCT_ID);
 
-        // 当前人返 CF:100元 × 100P点/100 × 10% = 10
-        verify(platformPointsService).earn(eq(BUYER_ID), eq(10), eq("order_consume"), eq(ORDER_ID), anyString());
-        // 只返 level2:100P点 × 10% = 10,一次
-        verify(platformPointsService).earn(eq(20L), eq(10), eq("referral_dist"), eq(ORDER_ID), anyString());
-        // level1 同家庭、level3 更远推荐人均不分润
-        verify(platformPointsService, never()).earn(eq(10L), anyInt(), eq("referral_dist"), eq(ORDER_ID), anyString());
-        verify(platformPointsService, never()).earn(eq(30L), anyInt(), eq("referral_dist"), eq(ORDER_ID), anyString());
+        // 同家庭的 level1 不发放
+        verify(cfBalanceService, never()).earnByRule(eq(memberIdOf(10L)), anyString(), anyLong(), any());
+        verify(cfBalanceService).earnByRule(eq(memberIdOf(20L)), eq("F03a"), eq(ORDER_ID), any());
+        verify(cfBalanceService).earnByRule(eq(memberIdOf(30L)), eq("F03b"), eq(ORDER_ID), any());
     }
 
     @Test
-    void settle_首级即非同家庭_只返首级不再上溯() {
-        // 链路:level1(10) 非同家庭、level2(20) 非同家庭 → 只返 level1,不再上溯 level2
-        mockReferralChain(ref(10L, 1), ref(20L, 2));
+    void settle_最多两级_第三级引荐人不发放() {
+        mockReferralChain(ref(10L, 1), ref(20L, 2), ref(30L, 3));
         when(userMapper.selectById(10L)).thenReturn(user(10L, 200L));
         when(userMapper.selectById(20L)).thenReturn(user(20L, 300L));
-        when(cfReferralService.getTotalTeamSize(10L)).thenReturn(3);
-        when(cfReferralService.matchRateTier(3)).thenReturn(tier(10));
-        // level2 也有有效比例,当前"全层级都返"实现会误发 level2
-        when(cfReferralService.getTotalTeamSize(20L)).thenReturn(5);
-        when(cfReferralService.matchRateTier(5)).thenReturn(tier(10));
+        when(userMapper.selectById(30L)).thenReturn(user(30L, 400L));
+        when(cfReferralService.getTeamAnnualPpoints(10L, 8)).thenReturn(100);
+        when(cfReferralService.matchDirectSalesTier(100)).thenReturn(directTier("青铜", 100, 10));
+        when(cfReferralService.getSelfAnnualPpoints(20L)).thenReturn(100);
+        when(cfReferralService.matchIndirectSalesTier(100)).thenReturn(indirectTier("青铜", 100, 2));
+        when(cfBalanceService.earnByRule(anyLong(), anyString(), anyLong(), any()))
+                .thenReturn(earnResult(10, false));
 
         cfCommissionService.settle(ORDER_ID, "product", BUYER_ID, BUYER_FAMILY, 10000, PRODUCT_ID);
 
-        verify(platformPointsService).earn(eq(10L), eq(10), eq("referral_dist"), eq(ORDER_ID), anyString());
-        verify(platformPointsService, never()).earn(eq(20L), anyInt(), eq("referral_dist"), eq(ORDER_ID), anyString());
+        verify(cfBalanceService).earnByRule(eq(memberIdOf(10L)), eq("F03a"), eq(ORDER_ID), any());
+        verify(cfBalanceService).earnByRule(eq(memberIdOf(20L)), eq("F03b"), eq(ORDER_ID), any());
+        verify(cfBalanceService, never()).earnByRule(eq(memberIdOf(30L)), anyString(), anyLong(), any());
     }
 
     @Test
-    void settle_全链路同家庭_无人分润() {
-        mockReferralChain(ref(10L, 1), ref(20L, 2));
-        when(userMapper.selectById(10L)).thenReturn(user(10L, BUYER_FAMILY));
-        when(userMapper.selectById(20L)).thenReturn(user(20L, BUYER_FAMILY));
+    void settle_档位比例超规则上限_按上限封顶() {
+        mockReferralChain(ref(10L, 1));
+        when(userMapper.selectById(10L)).thenReturn(user(10L, 200L));
+        // 运营把档位误配到 90% → 按 F03a 的 max_rate_percent=70 封顶
+        when(cfReferralService.getTeamAnnualPpoints(10L, 8)).thenReturn(10000);
+        when(cfReferralService.matchDirectSalesTier(10000)).thenReturn(directTier("钻石", 10000, 90));
+        when(cfBalanceService.earnByRule(eq(memberIdOf(10L)), eq("F03a"), eq(ORDER_ID), any()))
+                .thenReturn(earnResult(70, false));
+
+        cfCommissionService.settle(ORDER_ID, "product", BUYER_ID, BUYER_FAMILY, 10000, PRODUCT_ID);
+
+        ArgumentCaptor<Map<String, Object>> captor = ArgumentCaptor.forClass(Map.class);
+        verify(cfBalanceService).earnByRule(eq(memberIdOf(10L)), eq("F03a"), eq(ORDER_ID), captor.capture());
+        assertEquals(70, captor.getValue().get("rate_percent"));
+    }
+
+    @Test
+    void settle_无匹配档位_不发放销售奖() {
+        mockReferralChain(ref(10L, 1));
+        when(userMapper.selectById(10L)).thenReturn(user(10L, 200L));
+        when(cfReferralService.getTeamAnnualPpoints(10L, 8)).thenReturn(99);
+        when(cfReferralService.matchDirectSalesTier(99)).thenReturn(null);
+
+        cfCommissionService.settle(ORDER_ID, "product", BUYER_ID, BUYER_FAMILY, 10000, PRODUCT_ID);
+
+        verify(cfBalanceService, never()).earnByRule(anyLong(), anyString(), anyLong(), any());
+    }
+
+    @Test
+    void settle_落P点消费日志_基数与订单对应() {
+        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()))
+                .thenReturn(earnResult(10, false));
+
+        cfCommissionService.settle(ORDER_ID, "product", BUYER_ID, BUYER_FAMILY, 10000, PRODUCT_ID);
+
+        ArgumentCaptor<PpointConsumptionLog> captor = ArgumentCaptor.forClass(PpointConsumptionLog.class);
+        verify(ppointConsumptionLogMapper).insert(captor.capture());
+        PpointConsumptionLog log = captor.getValue();
+        assertEquals(BUYER_ID, log.getUserId());
+        assertEquals(ORDER_ID, log.getOrderId());
+        assertEquals("product", log.getOrderType());
+        assertEquals(PRODUCT_ID, log.getProductId());
+        assertEquals(10000, log.getAmountCent());
+        assertEquals(100, log.getPpoints());
+        assertNotNull(log.getCreatedAt());
+    }
+
+    @Test
+    void settle_F03a发放_自动创建感恩记录() {
+        mockReferralChain(ref(10L, 1));
+        when(userMapper.selectById(10L)).thenReturn(user(10L, 200L));
+        when(userMapper.selectById(BUYER_ID)).thenReturn(user(BUYER_ID, BUYER_FAMILY));
+        when(cfReferralService.getTeamAnnualPpoints(10L, 8)).thenReturn(100);
+        when(cfReferralService.matchDirectSalesTier(100)).thenReturn(directTier("青铜", 100, 10));
+        when(cfBalanceService.earnByRule(eq(memberIdOf(10L)), eq("F03a"), eq(ORDER_ID), any()))
+                .thenReturn(earnResult(10, false));
+
+        cfCommissionService.settle(ORDER_ID, "product", BUYER_ID, BUYER_FAMILY, 10000, PRODUCT_ID);
+
+        verify(octopusEffectRecordMapper).insert(any());
+        verify(octopusEffectKeyPersonMapper).insert(any());
+    }
+
+    @Test
+    void settle_幂等命中_不重复创建感恩记录() {
+        mockReferralChain(ref(10L, 1));
+        when(userMapper.selectById(10L)).thenReturn(user(10L, 200L));
+        when(userMapper.selectById(BUYER_ID)).thenReturn(user(BUYER_ID, BUYER_FAMILY));
+        when(cfReferralService.getTeamAnnualPpoints(10L, 8)).thenReturn(100);
+        when(cfReferralService.matchDirectSalesTier(100)).thenReturn(directTier("青铜", 100, 10));
+        // 幂等命中:earned=true 且 duplicated=true
+        when(cfBalanceService.earnByRule(eq(memberIdOf(10L)), eq("F03a"), eq(ORDER_ID), any()))
+                .thenReturn(earnResult(10, true));
+
+        cfCommissionService.settle(ORDER_ID, "product", BUYER_ID, BUYER_FAMILY, 10000, PRODUCT_ID);
+
+        verify(octopusEffectRecordMapper, never()).insert(any());
+        verify(octopusEffectKeyPersonMapper, never()).insert(any());
+    }
+
+    @Test
+    void settleReferrerOnly_只发销售奖_不返买家CF() {
+        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()))
+                .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 点
+        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"));
+    }
+
+    @Test
+    void settle_无推荐链_不发放也不报错() {
+        mockReferralChain();
 
         cfCommissionService.settle(ORDER_ID, "product", BUYER_ID, BUYER_FAMILY, 10000, PRODUCT_ID);
 
-        verify(platformPointsService, never()).earn(anyLong(), anyInt(), eq("referral_dist"), any(), anyString());
+        verify(cfBalanceService, never()).earnByRule(anyLong(), anyString(), anyLong(), any());
+        assertTrue(true);
     }
 }

+ 354 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/CfReferralServiceSalesTierTest.java

@@ -0,0 +1,354 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.entity.CfDirectSalesTier;
+import com.etotem.cfc.entity.CfIndirectSalesTier;
+import com.etotem.cfc.entity.ReferralTree;
+import com.etotem.cfc.mapper.CfDirectSalesTierMapper;
+import com.etotem.cfc.mapper.CfIndirectSalesTierMapper;
+import com.etotem.cfc.mapper.CfRateTierMapper;
+import com.etotem.cfc.mapper.PpointConsumptionLogMapper;
+import com.etotem.cfc.mapper.ReferralTreeMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+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.anyCollection;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 两级销售奖档位匹配 + 滚动一年 P 点累计(CfReferralService)单元测试。
+ *
+ * <p>覆盖口径(计划 §4.2 / §4.4):</p>
+ * <ul>
+ *   <li>F03a 直接销售奖档位:{@code cf_direct_sales_tier},按团队 8 层内滚动一年累计 P 点取最高档;</li>
+ *   <li>F03b 间接销售奖档位:{@code cf_indirect_sales_tier},按自身滚动一年累计消费 P 点取最高档;</li>
+ *   <li>团队成员 = {@code referral_tree.parent_id = 本人 AND level <= depth} 的 child_id 去重(不含本人);</li>
+ *   <li>滚动窗口 = 消费时间起 365 天;用户集合过大时分批求和。</li>
+ * </ul>
+ *
+ * <p>档位查询在数据库侧执行(enabled=1 AND min <= 值 ORDER BY min DESC LIMIT 1),
+ * 单测由 mock 用 thenAnswer 模拟 DB 侧语义,再由生产方法自行取第一条。</p>
+ */
+class CfReferralServiceSalesTierTest {
+
+    @Mock
+    private ReferralTreeMapper referralTreeMapper;
+    @Mock
+    private UserMapper userMapper;
+    @Mock
+    private CfRateTierMapper cfRateTierMapper;
+    @Mock
+    private CfDirectSalesTierMapper cfDirectSalesTierMapper;
+    @Mock
+    private CfIndirectSalesTierMapper cfIndirectSalesTierMapper;
+    @Mock
+    private PpointConsumptionLogMapper ppointConsumptionLogMapper;
+
+    @InjectMocks
+    private CfReferralService cfReferralService;
+
+    /** 用户ID → 该用户滚动一年累计 P 点(模拟 ppoint_consumption_log 聚合结果) */
+    private final Map<Long, Integer> ppointsByUser = new HashMap<>();
+
+    private final List<ReferralTree> treeRows = new ArrayList<>();
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), ReferralTree.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), CfDirectSalesTier.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), CfIndirectSalesTier.class);
+
+        // 模拟 DB 侧语义:WHERE parent_id = ? AND level <= ?(去重在生产方法里做)
+        when(referralTreeMapper.selectList(any(LambdaQueryWrapper.class))).thenAnswer(inv -> {
+            LambdaQueryWrapper<ReferralTree> wrapper = inv.getArgument(0);
+            // MP 惰性绑定参数:必须先生成 SQL 片段,paramNameValuePairs 才有值
+            wrapper.getSqlSegment();
+            Map<String, Object> params = wrapper.getParamNameValuePairs();
+            Long parentId = params.values().stream()
+                    .filter(v -> v instanceof Long)
+                    .map(v -> (Long) v)
+                    .findFirst()
+                    .orElse(null);
+            Integer depth = params.values().stream()
+                    .filter(v -> v instanceof Integer)
+                    .map(v -> (Integer) v)
+                    .findFirst()
+                    .orElse(null);
+            return treeRows.stream()
+                    .filter(r -> parentId != null && parentId.equals(r.getParentId()))
+                    .filter(r -> depth == null || (r.getLevel() != null && r.getLevel() <= depth))
+                    .collect(Collectors.toList());
+        });
+
+        // 模拟 DB 侧语义:SELECT SUM(ppoints) ... WHERE created_at >= ? AND user_id IN (...)
+        when(ppointConsumptionLogMapper.sumPpointsSince(anyCollection(), any(Date.class))).thenAnswer(inv -> {
+            Collection<Long> userIds = inv.getArgument(0);
+            int sum = 0;
+            for (Long uid : userIds) {
+                sum += ppointsByUser.getOrDefault(uid, 0);
+            }
+            return sum;
+        });
+    }
+
+    // ---------------- 档位匹配 ----------------
+
+    private CfDirectSalesTier directTier(String name, int min, int rate) {
+        CfDirectSalesTier t = new CfDirectSalesTier();
+        t.setTierName(name);
+        t.setMinAnnualPpoints(min);
+        t.setRatePercent(rate);
+        t.setEnabled(1);
+        return t;
+    }
+
+    private CfIndirectSalesTier indirectTier(String name, int min, int rate) {
+        CfIndirectSalesTier t = new CfIndirectSalesTier();
+        t.setTierName(name);
+        t.setMinSelfPpoints(min);
+        t.setRatePercent(rate);
+        t.setEnabled(1);
+        return t;
+    }
+
+    /** 种子档位:100/10% 500/25% 2000/40% 5000/55% 10000/70% */
+    private void seedDirectTiers() {
+        List<CfDirectSalesTier> tiers = new ArrayList<>();
+        tiers.add(directTier("青铜", 100, 10));
+        tiers.add(directTier("白银", 500, 25));
+        tiers.add(directTier("黄金", 2000, 40));
+        tiers.add(directTier("铂金", 5000, 55));
+        tiers.add(directTier("钻石", 10000, 70));
+        when(cfDirectSalesTierMapper.selectList(any(LambdaQueryWrapper.class))).thenAnswer(inv -> {
+            LambdaQueryWrapper<CfDirectSalesTier> wrapper = inv.getArgument(0);
+            int value = extractIntParam(wrapper, 0);
+            return tiers.stream()
+                    .filter(t -> t.getEnabled() != null && t.getEnabled() == 1
+                            && t.getMinAnnualPpoints() != null && t.getMinAnnualPpoints() <= value)
+                    .sorted(Comparator.comparing(CfDirectSalesTier::getMinAnnualPpoints).reversed())
+                    .collect(Collectors.toList());
+        });
+    }
+
+    /** 种子档位:100/2% 500/5% 2000/10% 5000/15% 10000/20% */
+    private void seedIndirectTiers() {
+        List<CfIndirectSalesTier> tiers = new ArrayList<>();
+        tiers.add(indirectTier("青铜", 100, 2));
+        tiers.add(indirectTier("白银", 500, 5));
+        tiers.add(indirectTier("黄金", 2000, 10));
+        tiers.add(indirectTier("铂金", 5000, 15));
+        tiers.add(indirectTier("钻石", 10000, 20));
+        when(cfIndirectSalesTierMapper.selectList(any(LambdaQueryWrapper.class))).thenAnswer(inv -> {
+            LambdaQueryWrapper<CfIndirectSalesTier> wrapper = inv.getArgument(0);
+            int value = extractIntParam(wrapper, 0);
+            return tiers.stream()
+                    .filter(t -> t.getEnabled() != null && t.getEnabled() == 1
+                            && t.getMinSelfPpoints() != null && t.getMinSelfPpoints() <= value)
+                    .sorted(Comparator.comparing(CfIndirectSalesTier::getMinSelfPpoints).reversed())
+                    .collect(Collectors.toList());
+        });
+    }
+
+    /**
+     * 从 wrapper 参数中取出档位比较值。
+     * eq(enabled,1) 恒为 1,le(min_xxx, 值) 的值不一定是 1,需排除 enabled 那个参数。
+     */
+    private int extractIntParam(LambdaQueryWrapper<?> wrapper, int fallback) {
+        wrapper.getSqlSegment();
+        List<Integer> ints = wrapper.getParamNameValuePairs().values().stream()
+                .filter(v -> v instanceof Integer)
+                .map(v -> (Integer) v)
+                .collect(Collectors.toList());
+        for (Integer v : ints) {
+            if (v != 1) {
+                return v;
+            }
+        }
+        return ints.isEmpty() ? fallback : ints.get(0);
+    }
+
+    @Test
+    void matchDirectSalesTier_边界档位() {
+        seedDirectTiers();
+        assertTier(100, "青铜", 10);
+        assertTier(500, "白银", 25);
+        assertTier(2000, "黄金", 40);
+        assertTier(5000, "铂金", 55);
+        assertTier(10000, "钻石", 70);
+    }
+
+    @Test
+    void matchDirectSalesTier_临界相邻值取上一档() {
+        seedDirectTiers();
+        assertTier(99, null, 0);
+        assertTier(499, "青铜", 10);
+        assertTier(1999, "白银", 25);
+        assertTier(4999, "黄金", 40);
+        assertTier(9999, "铂金", 55);
+    }
+
+    @Test
+    void matchIndirectSalesTier_边界档位() {
+        seedIndirectTiers();
+        assertIndirectTier(100, "青铜", 2);
+        assertIndirectTier(500, "白银", 5);
+        assertIndirectTier(2000, "黄金", 10);
+        assertIndirectTier(5000, "铂金", 15);
+        assertIndirectTier(10000, "钻石", 20);
+    }
+
+    @Test
+    void matchIndirectSalesTier_临界相邻值取上一档() {
+        seedIndirectTiers();
+        assertIndirectTier(99, null, 0);
+        assertIndirectTier(499, "青铜", 2);
+        assertIndirectTier(1999, "白银", 5);
+        assertIndirectTier(9999, "铂金", 15);
+    }
+
+    private void assertTier(int ppoints, String name, int rate) {
+        CfDirectSalesTier result = cfReferralService.matchDirectSalesTier(ppoints);
+        if (name == null) {
+            assertNull(result, "teamAnnualPpoints=" + ppoints + " 应无匹配档位");
+            return;
+        }
+        assertNotNull(result, "teamAnnualPpoints=" + ppoints);
+        assertEquals(name, result.getTierName(), "teamAnnualPpoints=" + ppoints);
+        assertEquals(rate, result.getRatePercent(), "teamAnnualPpoints=" + ppoints);
+    }
+
+    private void assertIndirectTier(int ppoints, String name, int rate) {
+        CfIndirectSalesTier result = cfReferralService.matchIndirectSalesTier(ppoints);
+        if (name == null) {
+            assertNull(result, "selfAnnualPpoints=" + ppoints + " 应无匹配档位");
+            return;
+        }
+        assertNotNull(result, "selfAnnualPpoints=" + ppoints);
+        assertEquals(name, result.getTierName(), "selfAnnualPpoints=" + ppoints);
+        assertEquals(rate, result.getRatePercent(), "selfAnnualPpoints=" + ppoints);
+    }
+
+    // ---------------- 滚动一年 P 点累计 ----------------
+
+    private ReferralTree row(Long parentId, Long childId, int level) {
+        ReferralTree r = new ReferralTree();
+        r.setParentId(parentId);
+        r.setChildId(childId);
+        r.setLevel(level);
+        return r;
+    }
+
+    @Test
+    void getTeamAnnualPpoints_无团队返回0() {
+        int total = cfReferralService.getTeamAnnualPpoints(1L, 8);
+
+        assertEquals(0, total);
+        verify(ppointConsumptionLogMapper, never()).sumPpointsSince(anyCollection(), any(Date.class));
+    }
+
+    @Test
+    void getTeamAnnualPpoints_按8层过滤且去重后累计() {
+        // 团队成员:11(level1) 12(level2) 11(level3 重复) 13(level9 超出8层)
+        treeRows.add(row(1L, 11L, 1));
+        treeRows.add(row(1L, 12L, 2));
+        treeRows.add(row(1L, 11L, 3));
+        treeRows.add(row(1L, 13L, 9));
+        ppointsByUser.put(11L, 100);
+        ppointsByUser.put(12L, 250);
+        ppointsByUser.put(13L, 999);
+
+        int total = cfReferralService.getTeamAnnualPpoints(1L, 8);
+
+        // 13 在 8 层之外、11 去重 → 100 + 250
+        assertEquals(350, total);
+    }
+
+    @Test
+    void getTeamAnnualPpoints_大批量用户分批求和() {
+        for (long i = 1; i <= 600; i++) {
+            treeRows.add(row(1L, 1000L + i, 1));
+            ppointsByUser.put(1000L + i, 1);
+        }
+
+        int total = cfReferralService.getTeamAnnualPpoints(1L, 8);
+
+        assertEquals(600, total);
+        // 600 人分 2 批(500 + 100)
+        verify(ppointConsumptionLogMapper, times(2)).sumPpointsSince(anyCollection(), any(Date.class));
+
+        ArgumentCaptor<Collection<Long>> captor = ArgumentCaptor.forClass(Collection.class);
+        verify(ppointConsumptionLogMapper, times(2)).sumPpointsSince(captor.capture(), any(Date.class));
+        List<Collection<Long>> batches = captor.getAllValues();
+        assertEquals(500, batches.get(0).size());
+        assertEquals(100, batches.get(1).size());
+    }
+
+    @Test
+    void getSelfAnnualPpoints_只统计本人() {
+        ppointsByUser.put(1L, 88);
+        ppointsByUser.put(2L, 999);
+
+        int total = cfReferralService.getSelfAnnualPpoints(1L);
+
+        assertEquals(88, total);
+        ArgumentCaptor<Collection<Long>> captor = ArgumentCaptor.forClass(Collection.class);
+        verify(ppointConsumptionLogMapper).sumPpointsSince(captor.capture(), any(Date.class));
+        assertEquals(1, captor.getValue().size());
+        assertTrue(captor.getValue().contains(1L));
+    }
+
+    @Test
+    void getSelfAnnualPpoints_用户ID为空返回0() {
+        assertEquals(0, cfReferralService.getSelfAnnualPpoints(null));
+        verify(ppointConsumptionLogMapper, never()).sumPpointsSince(anyCollection(), any(Date.class));
+    }
+
+    @Test
+    void rollingYearAgo_约为365天前() {
+        Date since = cfReferralService.rollingYearAgo();
+        long diffDays = (System.currentTimeMillis() - since.getTime()) / (24L * 60 * 60 * 1000);
+
+        assertTrue(diffDays >= 364 && diffDays <= 365, "滚动窗口应为 365 天,实际=" + diffDays + " 天");
+    }
+
+    @Test
+    void getTeamUserIds_不含本人且去重() {
+        treeRows.add(row(1L, 1L, 1));   // 本人(异常数据)应排除
+        treeRows.add(row(1L, 11L, 1));
+        treeRows.add(row(1L, 11L, 2));  // 重复成员应去重
+        treeRows.add(row(1L, 12L, 2));
+
+        List<Long> ids = cfReferralService.getTeamUserIds(1L, 8);
+
+        assertEquals(2, ids.size());
+        assertTrue(ids.contains(11L));
+        assertTrue(ids.contains(12L));
+        assertTrue(!ids.contains(1L));
+    }
+}

+ 97 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/CommissionDistService410Test.java

@@ -0,0 +1,97 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.entity.CommissionDistLog;
+import com.etotem.cfc.mapper.CommissionDistLogMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 旧分佣写接口收口测试(计划 §四 / §七 F-4)。
+ *
+ * <p>{@code CommissionDistService.distribute} 已废弃为 410 Gone:
+ * 旧"团队规模阶梯 / 财富成长等级"双轨分佣收口为 F03a 直接销售奖 + F03b 间接销售奖,
+ * 由 {@link CfCommissionService} 统一发放到 {@code cf_balance} + {@code cf_balance_log},
+ * {@code commission_dist_log} 表转只读历史表、停止写入。</p>
+ */
+class CommissionDistService410Test {
+
+    @Mock
+    private CommissionDistLogMapper commissionDistLogMapper;
+
+    @InjectMocks
+    private CommissionDistService commissionDistService;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), CommissionDistLog.class);
+    }
+
+    @Test
+    void distribute_写接口已废弃_抛410Gone() {
+        IllegalStateException e = assertThrows(IllegalStateException.class,
+                () -> commissionDistService.distribute(1L, 2L, 3L));
+
+        assertTrue(e.getMessage().contains("410 Gone"), "异常信息应说明 410 Gone,实际=" + e.getMessage());
+        assertTrue(e.getMessage().contains("CfCommissionService"), "异常信息应指向新的统一分佣服务");
+    }
+
+    @Test
+    void distribute_不再写历史分润表() {
+        assertThrows(IllegalStateException.class,
+                () -> commissionDistService.distribute(1L, 2L, 3L));
+
+        verify(commissionDistLogMapper, never()).insert(any());
+    }
+
+    @Test
+    void getUserDistLogs_仍可读取历史分润记录() {
+        CommissionDistLog row = new CommissionDistLog();
+        row.setId(1L);
+        row.setBuyerId(9L);
+        row.setLevel1Ppoint(10);
+        row.setLevel2Ppoint(20);
+        when(commissionDistLogMapper.selectList(any(LambdaQueryWrapper.class)))
+                .thenReturn(Arrays.asList(row));
+
+        List<CommissionDistLog> logs = commissionDistService.getUserDistLogs(9L, 1, 20);
+
+        assertEquals(1, logs.size());
+        assertEquals(10, logs.get(0).getLevel1Ppoint());
+    }
+
+    @Test
+    void getTotalDistributedPpoint_按级别累计历史分润() {
+        CommissionDistLog row = new CommissionDistLog();
+        row.setId(1L);
+        row.setStatus("completed");
+        row.setLevel1UserId(9L);
+        row.setLevel1Ppoint(10);
+        row.setLevel2Ppoint(20);
+        when(commissionDistLogMapper.selectList(any(LambdaQueryWrapper.class)))
+                .thenReturn(Arrays.asList(row));
+
+        int total = commissionDistService.getTotalDistributedPpoint(9L);
+
+        // 买家自身是 level1 → 取 level1Ppoint
+        assertEquals(10, total);
+    }
+}

+ 60 - 0
cfc-frontend/pages/promotion/team.vue

@@ -21,6 +21,27 @@
       </view>
     </view>
 
+    <!-- 两级销售奖档位(F-4:F03a 直接 / F03b 间接) -->
+    <view class="stats-card sales-tier-card" v-if="cfRate">
+      <view class="sales-tier-item">
+        <view class="sales-tier-head">
+          <text class="sales-tier-title">直接销售奖</text>
+          <text class="sales-tier-rate">{{ (cfRate.directRatePercent || 0) + '%' }}</text>
+        </view>
+        <text class="sales-tier-sub">团队年度P点 {{ cfRate.teamAnnualPpoints || 0 }}</text>
+        <text class="sales-tier-sub">档位 {{ cfRate.directTierName || '未入档' }}</text>
+      </view>
+      <view class="sales-tier-divider"></view>
+      <view class="sales-tier-item">
+        <view class="sales-tier-head">
+          <text class="sales-tier-title">间接销售奖</text>
+          <text class="sales-tier-rate">{{ (cfRate.indirectRatePercent || 0) + '%' }}</text>
+        </view>
+        <text class="sales-tier-sub">自身年度P点 {{ cfRate.selfAnnualPpoints || 0 }}</text>
+        <text class="sales-tier-sub">档位 {{ cfRate.indirectTierName || '未入档' }}</text>
+      </view>
+    </view>
+
     <!-- Tab 切换 -->
     <view class="tab-bar">
       <view class="tab-item" :class="level === 'L1' ? 'tab-active' : ''" @click="switchLevel('L1')">
@@ -518,6 +539,45 @@ export default {
   background: $brand-primary-light-2;
 }
 
+/* ===== 两级销售奖档位(F-4) ===== */
+.sales-tier-card {
+  flex-direction: row;
+  align-items: stretch;
+  padding: 24rpx;
+}
+.sales-tier-item {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+}
+.sales-tier-head {
+  display: flex;
+  align-items: baseline;
+  justify-content: space-between;
+  margin-bottom: 8rpx;
+}
+.sales-tier-title {
+  font-size: calc(26rpx * var(--font-scale, 1));
+  font-weight: 600;
+  color: $uni-text-color;
+}
+.sales-tier-rate {
+  font-size: calc(32rpx * var(--font-scale, 1));
+  font-weight: bold;
+  color: $brand-primary;
+}
+.sales-tier-sub {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $app-text-subtle;
+  display: block;
+  line-height: 32rpx;
+}
+.sales-tier-divider {
+  width: 1rpx;
+  background: $brand-primary-light-2;
+  margin: 0 20rpx;
+}
+
 /* ===== Tab切换 ===== */
 .tab-bar {
   display: flex;

+ 1 - 0
docs/superpowers/PROJECT-OVERVIEW.md

@@ -227,6 +227,7 @@
 | **BASIC 体验会员 + 权益对比表动态化 + PROVIDER 退出会员权益**(2026-10-18):迁移 381/351/321 统一 level_order 语义(越大越高,PROVIDER=1 与 FREE 同级)+ `getMemberLevel` 归一 PROVIDER→FREE + `canUseFeature` 移除全权限兜底 + 活动价判定去 PROVIDER + 新接口 `POST /api/membership/compare` 聚合各档实际权限(折扣 bps / max_children / max_tasks_per_day / BasicQuotaService 配额 / features 开关),upgrade.vue 与 index.vue 对比表改动态渲染 | `api/API_REFERENCE.md` 4.50 |
 | **CF 值体系统一 · 数据层与配置归口(F-1 + W0.1)**(2026-10-24):8 张新表(cf_balance / cf_balance_log / cf_earn_rule + 24 行种子 / cf_earn_rule_history / cf_direct_sales_tier / cf_indirect_sales_tier / cf_earn_daily_limit + service_config)+ coupon/user_coupon 补 family_id + 迁移 407-412 + `ConfigService.getConfig`(缓存 TTL 60s + `CfConfigCache.invalidate`) | `plans/2026-10-24-cf-value-unification-plan.md` + `api/API_REFERENCE.md` 六 |
 | **CF 值体系统一 · F-6 存量迁移**(2026-10-24):迁移417 将 `family_platform_balance`、`family_members.system_points`、`user_platform_balance` 一次性幂等迁入成员维度 `cf_balance`;三类 `migration_*` 流水守卫重复执行,旧表保留只读历史 | `plans/2026-10-24-cf-value-unification-plan.md` §六 + `api/API_REFERENCE.md` 6.4 |
+| **CF 值体系统一 · F-4 分佣收口 + F03a/F03b 发放接入**(2026-10-25):推荐人返佣收口为两级销售奖(F03a 按团队 8 层内滚动一年累计 P 点档位上限 70% / F03b 按自身滚动一年消费 P 点档位上限 20%),共同基数 = 本单消费 P 点;迁移418 补两级档位种子 + `dedup_ref_type` 对齐为 F03a/F03b,迁移419 新建 `ppoint_consumption_log`(滚动一年聚合数据源,唯一键 `order_type+order_id`);发放统一走 F-2 `earnByRule` 只写 `cf_balance` + `cf_balance_log`,旧 `cf_transfer_record` 流水仅限买家 F02 路径;`CommissionDistService.distribute` 写接口 410 Gone、`commission_dist_log` 转只读;`/api/commission/cf/rate` 扩展两级档位字段 | `plans/2026-10-24-cf-value-unification-plan.md` §四/§3.1/§4.2/§4.4 + `api/API_REFERENCE.md` 4.33 + 6.5 |
 
 ---
 

+ 56 - 11
docs/superpowers/api/API_REFERENCE.md

@@ -1066,25 +1066,42 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 
 ### 4.33 CF 值分佣(`/api/commission/cf/*`)
 
-> CF 值(平台积分)统一分佣体系:普通订单双返(当前消费人 + 推荐人按团队规模阶梯分润),会员/订阅只返推荐人,同家庭互推跳过本人上溯。
+> CF 值(平台积分)统一分佣体系(F-4 分佣收口,2026-10-25):普通订单双返(当前消费人 F02 + 推荐人两级销售奖),会员/订阅只返推荐人,同家庭互推跳过本人上溯。
+> 推荐人返佣收口为 **F03a 直接销售奖 + F03b 间接销售奖**,共同基数 = 本单消费产生的 P 点;旧「团队规模阶梯 `cf_rate_tier`」已废弃(表保留只读,仅等级展示/历史查询使用)。
 
 | 路径 | 说明 |
 |------|------|
-| `POST /api/commission/cf/rate` | 我的团队规模 + 当前返佣比例 + 档位名。返回 `{ totalTeamSize, ratePercent, tierName }`(未入档时 ratePercent=0、tierName="未入档") |
+| `POST /api/commission/cf/rate` | 我的团队规模 + 两级销售奖档位。返回 `{ totalTeamSize, ratePercent, tierName, teamDepth, teamAnnualPpoints, selfAnnualPpoints, directRatePercent, directTierName, indirectRatePercent, indirectTierName }`;legacy 字段 `ratePercent/tierName` 取 F03a 值供旧端兼容(未入档时 ratePercent=0、tierName="未入档") |
 | `POST /api/commission/cf/summary` | 我的 CF 钱包汇总,返回 `PlatformBalanceService.getBalance(userId)` 结果 |
 | `POST /api/commission/cf/list` | 我的 CF 流水(分页),请求 `{ page, size }`,返回 `Page<PlatformBalanceLog>` |
 
-**阶梯档位(cf_rate_tier,管理端可配置):**
+**两级销售奖档位(迁移418 种子,管理端可配置):**
 
-| 团队规模(min_team_size) | 档位 | 返佣比例 |
+共同基数 = 本单消费产生的 P 点(商品:实付元 × 有效 P 点 / 100;非商品:实付元 × `cf.commission.service_rate`(bps) / 10000)。第 1 个非同家庭引荐人拿 F03a,第 2 个拿 F03b,最多两级。
+
+F03a 直接销售奖(`cf_direct_sales_tier`,按**团队 8 层内滚动一年累计 P 点**):
+
+| 团队年度 P 点下限 | 档位 | 比例 |
 |:---:|:---:|:---:|
-| 0~2 | 铜牌 | 5% |
-| 3~9 | 银牌 | 10% |
-| 10~29 | 金牌 | 15% |
-| 30~99 | 铂金 | 20% |
-| 100+ | 钻石 | 25% |
+| 100 | 青铜 | 10% |
+| 500 | 白银 | 25% |
+| 2000 | 黄金 | 40% |
+| 5000 | 铂金 | 55% |
+| 10000 | 钻石 | 70% |
 
-> 匹配语义:`enabled=1 AND min_team_size <= teamSize ORDER BY min_team_size DESC LIMIT 1`。
+F03b 间接销售奖(`cf_indirect_sales_tier`,按**自身滚动一年累计消费 P 点**):
+
+| 自身年度 P 点下限 | 档位 | 比例 |
+|:---:|:---:|:---:|
+| 100 | 青铜 | 2% |
+| 500 | 白银 | 5% |
+| 2000 | 黄金 | 10% |
+| 5000 | 铂金 | 15% |
+| 10000 | 钻石 | 20% |
+
+> 匹配语义:`enabled=1 AND min_xxx <= 值 ORDER BY min_xxx DESC LIMIT 1`,无匹配档位不发放;比例按 `cf_earn_rule.params_json.max_rate_percent`(F03a=70、F03b=20)封顶。
+> 金额由 `cf_earn_rule.formula = floor(base_ppoints * rate_percent / 100)` 计算,发放统一走 `CfBalanceService.earnByRule`(幂等键复用 F-2 `uk_ref_type`),只写 `cf_balance` + `cf_balance_log`。
+> 滚动一年口径:消费支付成功时落 `ppoint_consumption_log` 一行(唯一键 `order_type + order_id` 幂等),按 `created_at >= now() - 365d` 聚合(迁移419)。
 
 ### 4.34 CF 成员间转让(`/api/cf/transfer/*`)
 
@@ -2105,4 +2122,32 @@ W0.2 双配置中心合并:`product_config` 已并入 `service_config`(唯
 
 幂等键为 `(member_id, type='adjust', ref_type, ref_id)`(对齐 F-2 迁移416 升级后的唯一键 `uk_ref_type`);重复启动按三类 `ref_type` 逐笔跳过,余额与流水同事务提交。三套旧账本保留为只读历史,写接口 410 Gone 由 F-8 收口。
 
-*文档更新:2026-10-24(CF 值体系统一 F-1 数据层 + W0.1 配置归口:8 张新表/迁移407-412 + service_config 与 cf_earn_rule 管理端接口);同日补登 W0.2 双配置中心合并(service_config 收编 product_config:迁移413 + product_key 维度 + product-config 接口 410 Gone + 配置中心统一入口);同日补登 F-6 存量迁移(迁移417 + 三套旧账本幂等迁入 cf_balance)*
+### 6.5 F-4 分佣收口 + F03a/F03b 发放接入(迁移418-419,无新增接口)
+
+> 口径:计划 §四 / §3.1 / §4.2 / §4.4。发放链路由 `CfCommissionService` 统一承载,无新增 REST 接口;`/api/commission/cf/rate` 返回值扩展两级档位字段(见 4.33)。
+
+**迁移内容:**
+
+| 迁移 | 内容 | 幂等键 |
+|------|------|--------|
+| 418 | `cf_direct_sales_tier` / `cf_indirect_sales_tier` 各补 5 档种子(青铜/白银/黄金/铂金/钻石,缺行才补、不覆盖)+ `cf_earn_rule.dedup_ref_type` 对齐为规则编码(F03a→'F03a'、F03b→'F03b') | 按 `min_annual_ppoints` / `min_self_ppoints` 判重;UPDATE 带 `WHERE dedup_ref_type <> code` |
+| 419 | 新建 `ppoint_consumption_log`(滚动一年档位聚合数据源:消费支付成功落一行,`created_at >= now()-365d` 聚合) | `UNIQUE uk_order (order_type, order_id)` 同一订单重复触发只记一行 |
+
+**发放链路(`CfCommissionService.settle` / `settleReferrerOnly`):**
+
+```
+本单 P 点基数(商品: 元×有效P点/100;非商品: 元×service_rate bps/10000)
+  → 落 ppoint_consumption_log(幂等)
+  → referral_tree 按 level 升序上溯,跳过同家庭(D5)
+  → 第1个非同家庭引荐人 → getTeamAnnualPpoints(referrerId, 8) → matchDirectSalesTier → F03a
+  → 第2个非同家庭引荐人 → getSelfAnnualPpoints(referrerId)      → matchIndirectSalesTier → F03b
+  → cfBalanceService.earnByRule(memberId, "F03a"/"F03b", orderId, {base_ppoints, rate_percent})
+      (公式 floor(base_ppoints*rate_percent/100),幂等键复用 F-2 uk_ref_type,只写 cf_balance + cf_balance_log)
+  → F03a 且非幂等命中时 autoCreateGratitudeEffect(感恩记录)
+```
+
+- 数量与比例零硬编码:比例来自档位表,上限来自 `cf_earn_rule.params_json.max_rate_percent`,金额由公式计算。
+- 旧分佣写接口 `CommissionDistService.distribute` 已废弃:抛 `IllegalStateException`(消息含 "410 Gone",经 `GlobalExceptionHandler` 转 `Result.error(400, ...)`),无调用方;`commission_dist_log` 转只读历史表,管理端读取接口(`/api/admin/commission-dist/*`)保留不变。
+- `cf_rate_tier` 表保留只读,仅供等级展示(`PromotionTierService` / 管理端档位配置)使用。
+
+*文档更新:2026-10-24(CF 值体系统一 F-1 数据层 + W0.1 配置归口:8 张新表/迁移407-412 + service_config 与 cf_earn_rule 管理端接口);同日补登 W0.2 双配置中心合并(service_config 收编 product_config:迁移413 + product_key 维度 + product-config 接口 410 Gone + 配置中心统一入口);同日补登 F-6 存量迁移(迁移417 + 三套旧账本幂等迁入 cf_balance);2026-10-25 补登 F-4 分佣收口(迁移418-419 + 两级销售奖档位 + /rate 扩展字段 + CommissionDistService.distribute 410 Gone)*