Procházet zdrojové kódy

Merge remote-tracking branch 'origin/cfclub' into cfclub

jiapu před 2 týdny
rodič
revize
62004d8c22
31 změnil soubory, kde provedl 1057 přidání a 277 odebrání
  1. 90 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 57 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/CfCommissionController.java
  3. 87 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/CfTransferController.java
  4. 52 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCfRateTierController.java
  5. 22 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/CfRateTier.java
  6. 24 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/CfTransferRecord.java
  7. 2 10
      cfc-backend/src/main/java/com/etotem/cfc/entity/PromotionTier.java
  8. 4 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/PromotionTierConfig.java
  9. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/CfRateTierMapper.java
  10. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/CfTransferRecordMapper.java
  11. 13 2
      cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentOrderService.java
  12. 193 0
      cfc-backend/src/main/java/com/etotem/cfc/service/CfCommissionService.java
  13. 89 0
      cfc-backend/src/main/java/com/etotem/cfc/service/CfReferralService.java
  14. 8 0
      cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java
  15. 7 2
      cfc-backend/src/main/java/com/etotem/cfc/service/MemberSubscriptionService.java
  16. 7 8
      cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java
  17. 5 2
      cfc-backend/src/main/java/com/etotem/cfc/service/PackagePaymentService.java
  18. 5 2
      cfc-backend/src/main/java/com/etotem/cfc/service/PaymentService.java
  19. 11 0
      cfc-backend/src/main/java/com/etotem/cfc/service/PlatformPointsService.java
  20. 8 36
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java
  21. 12 42
      cfc-backend/src/main/java/com/etotem/cfc/service/PromotionTierEvalService.java
  22. 35 21
      cfc-backend/src/main/java/com/etotem/cfc/service/PromotionTierService.java
  23. 16 7
      cfc-backend/src/main/java/com/etotem/cfc/task/PromotionTierCheckScheduledTask.java
  24. 38 4
      cfc-backend/src/main/resources/schema.sql
  25. 119 0
      cfc-backend/src/test/java/com/etotem/cfc/service/CfCommissionServiceTest.java
  26. 55 110
      cfc-frontend/pages/promotion/commission.vue
  27. 16 17
      cfc-frontend/pages/promotion/index.vue
  28. 12 12
      cfc-frontend/pages/promotion/team.vue
  29. 8 0
      cfc-frontend/utils/api.js
  30. 47 1
      docs/superpowers/api/API_REFERENCE.md
  31. 1 1
      docs/superpowers/plans/2026-09-01-cf-commission-redesign.md

+ 90 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -10105,5 +10105,95 @@ public class DatabaseInitializer implements CommandLineRunner {
         } catch (Exception e) {
         } catch (Exception e) {
             log.warn("family_coupon_grant_log 表已存在,跳过创建: {}", e.getMessage());
             log.warn("family_coupon_grant_log 表已存在,跳过创建: {}", e.getMessage());
         }
         }
+
+        // 迁移279: 创建 cf_rate_tier 表(CF值返佣阶梯配置,替代 promotion_tier_config)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS cf_rate_tier (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "tier_name VARCHAR(50) NOT NULL COMMENT '档位名称', " +
+                    "min_team_size INT NOT NULL DEFAULT 0 COMMENT '团队规模下限(含)', " +
+                    "rate_percent INT NOT NULL DEFAULT 0 COMMENT '返佣比例(%)', " +
+                    "sort_order INT NOT NULL DEFAULT 0 COMMENT '排序,越大越高', " +
+                    "enabled TINYINT DEFAULT 1 COMMENT '1启用/0停用', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+                    "INDEX idx_min_size (min_team_size)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CF值返佣阶梯配置'");
+            log.info("已创建cf_rate_tier表");
+            Integer cnt = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM cf_rate_tier", Integer.class);
+            if (cnt == null || cnt == 0) {
+                jdbcTemplate.execute("INSERT INTO cf_rate_tier (tier_name, min_team_size, rate_percent, sort_order) VALUES " +
+                        "('铜牌', 0, 5, 1), ('银牌', 3, 10, 2), ('金牌', 10, 15, 3), ('铂金', 30, 20, 4), ('钻石', 100, 25, 5)");
+                log.info("cf_rate_tier 种子数据已初始化");
+            }
+        } catch (Exception e) {
+            log.warn("创建cf_rate_tier表失败: {}", e.getMessage());
+        }
+
+        // 迁移280: 创建 cf_transfer_record 表(CF值流转记录)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS cf_transfer_record (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "from_user_id BIGINT COMMENT '转出用户ID(null=平台)', " +
+                    "to_user_id BIGINT COMMENT '转入用户ID', " +
+                    "family_id BIGINT COMMENT '所属家庭ID', " +
+                    "amount INT NOT NULL COMMENT 'CF值数量', " +
+                    "type VARCHAR(16) NOT NULL COMMENT 'transfer/allocate/refund', " +
+                    "ref_type VARCHAR(50) COMMENT '关联业务类型', " +
+                    "ref_id BIGINT COMMENT '关联业务ID', " +
+                    "remark VARCHAR(255), " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "INDEX idx_from (from_user_id), " +
+                    "INDEX idx_to (to_user_id), " +
+                    "INDEX idx_family (family_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CF值流转记录'");
+            log.info("已创建cf_transfer_record表");
+        } catch (Exception e) {
+            log.warn("创建cf_transfer_record表失败: {}", e.getMessage());
+        }
+
+        // 迁移281: promotion_tier 精简列(删三层,加 rate_percent)
+        try {
+            jdbcTemplate.execute("ALTER TABLE promotion_tier DROP COLUMN team_size_1st, DROP COLUMN team_size_2nd, DROP COLUMN team_size_3rd");
+            log.info("promotion_tier 已删除三层团队列");
+        } catch (Exception e) {
+            log.warn("promotion_tier 三层列删除失败(可能不存在): {}", e.getMessage());
+        }
+        try {
+            jdbcTemplate.execute("ALTER TABLE promotion_tier ADD COLUMN rate_percent INT DEFAULT 0 COMMENT '当前返佣比例(%)'");
+            log.info("promotion_tier 已添加 rate_percent");
+        } catch (Exception e) {
+            log.warn("promotion_tier.rate_percent 已存在: {}", e.getMessage());
+        }
+
+        // 迁移282: coupon 表添加 family_id
+        try {
+            jdbcTemplate.execute("ALTER TABLE coupon ADD COLUMN family_id BIGINT DEFAULT NULL COMMENT '绑定家庭ID(NULL=不绑定)'");
+            log.info("coupon 已添加 family_id");
+        } catch (Exception e) {
+            log.warn("coupon.family_id 已存在: {}", e.getMessage());
+        }
+
+        // 迁移283: referral_tree 唯一键改为 (parent_id, child_id) 联合唯一(支持每祖先一行物化)
+        try {
+            jdbcTemplate.execute("ALTER TABLE referral_tree DROP INDEX uk_child_id");
+            log.info("referral_tree 已删除 uk_child_id 唯一键");
+        } catch (Exception e) {
+            log.warn("referral_tree.uk_child_id 删除失败(可能不存在): {}", e.getMessage());
+        }
+        try {
+            jdbcTemplate.execute("ALTER TABLE referral_tree ADD UNIQUE KEY uk_parent_child (parent_id, child_id)");
+            log.info("referral_tree 已添加 uk_parent_child 联合唯一键");
+        } catch (Exception e) {
+            log.warn("referral_tree.uk_parent_child 已存在: {}", e.getMessage());
+        }
+
+        // 迁移284: cf_transfer_record 加 (ref_type, ref_id) 唯一索引(幂等兜底,防止并发 earn 重复发放)
+        try {
+            jdbcTemplate.execute("ALTER TABLE cf_transfer_record ADD UNIQUE KEY uk_ref_type_id (ref_type, ref_id)");
+            log.info("cf_transfer_record 已添加 uk_ref_type_id 唯一索引");
+        } catch (Exception e) {
+            log.warn("cf_transfer_record.uk_ref_type_id 已存在: {}", e.getMessage());
+        }
     }
     }
 }
 }

+ 57 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/CfCommissionController.java

@@ -0,0 +1,57 @@
+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.PlatformBalanceLog;
+import com.etotem.cfc.service.CfReferralService;
+import com.etotem.cfc.service.PlatformPointsService;
+import com.etotem.cfc.service.PromotionTierService;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/commission/cf")
+public class CfCommissionController {
+
+    @Resource
+    private CfReferralService cfReferralService;
+    @Resource
+    private PlatformPointsService platformPointsService;
+    @Resource
+    private PromotionTierService promotionTierService;
+
+    /** 我的团队规模 + 当前返佣比例 + 档位名 */
+    @PostMapping("/rate")
+    public Result<Map<String, Object>> rate(@RequestAttribute("userId") Long userId) {
+        int teamSize = cfReferralService.getTotalTeamSize(userId);
+        CfRateTier tier = cfReferralService.matchRateTier(teamSize);
+        Map<String, Object> data = new HashMap<>();
+        data.put("totalTeamSize", teamSize);
+        data.put("ratePercent", tier == null ? 0 : tier.getRatePercent());
+        data.put("tierName", tier == null ? "未入档" : tier.getTierName());
+        return Result.success(data);
+    }
+
+    /** 我的 CF 钱包汇总 */
+    @PostMapping("/summary")
+    public Result<Map<String, Object>> summary(@RequestAttribute("userId") Long userId) {
+        return Result.success(platformPointsService.getBalance(userId));
+    }
+
+    /** CF 流水(分页) */
+    @PostMapping("/list")
+    public Result<Page<PlatformBalanceLog>> list(@RequestAttribute("userId") Long userId,
+                                                 @RequestBody Map<String, Integer> params) {
+        int page = params.getOrDefault("page", 1);
+        int size = params.getOrDefault("size", 20);
+        return Result.success(platformPointsService.getLogs(userId, page, size));
+    }
+}

+ 87 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/CfTransferController.java

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

+ 52 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCfRateTierController.java

@@ -0,0 +1,52 @@
+package com.etotem.cfc.controller.admin;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.CfRateTier;
+import com.etotem.cfc.mapper.CfRateTierMapper;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/admin/cf-rate-tier")
+public class AdminCfRateTierController {
+
+    @Resource
+    private CfRateTierMapper cfRateTierMapper;
+
+    @PostMapping("/list")
+    public Result<List<CfRateTier>> list() {
+        return Result.success(cfRateTierMapper.selectList(
+                new LambdaQueryWrapper<CfRateTier>().orderByAsc(CfRateTier::getSortOrder)));
+    }
+
+    @PostMapping("/save")
+    public Result<String> save(@RequestBody CfRateTier tier) {
+        if (tier.getId() != null) {
+            tier.setUpdatedAt(new Date());
+            cfRateTierMapper.updateById(tier);
+        } else {
+            tier.setCreatedAt(new Date());
+            tier.setUpdatedAt(new Date());
+            if (tier.getEnabled() == null) tier.setEnabled(1);
+            cfRateTierMapper.insert(tier);
+        }
+        return Result.success("已保存");
+    }
+
+    @PostMapping("/delete")
+    public Result<String> delete(@RequestBody Map<String, Object> params) {
+        Object idObj = params.get("id");
+        if (idObj == null) return Result.error("id 不能为空");
+        Long id = ((Number) idObj).longValue();
+        cfRateTierMapper.deleteById(id);
+        return Result.success("已删除");
+    }
+}

+ 22 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/CfRateTier.java

@@ -0,0 +1,22 @@
+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;
+
+@Data
+@TableName("cf_rate_tier")
+public class CfRateTier implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String tierName;
+    private Integer minTeamSize;
+    private Integer ratePercent;
+    private Integer sortOrder;
+    private Integer enabled;
+    private Date createdAt;
+    private Date updatedAt;
+}

+ 24 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/CfTransferRecord.java

@@ -0,0 +1,24 @@
+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;
+
+@Data
+@TableName("cf_transfer_record")
+public class CfTransferRecord implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long fromUserId;
+    private Long toUserId;
+    private Long familyId;
+    private Integer amount;
+    private String type;      // transfer/allocate/refund
+    private String refType;
+    private Long refId;
+    private String remark;
+    private Date createdAt;
+}

+ 2 - 10
cfc-backend/src/main/java/com/etotem/cfc/entity/PromotionTier.java

@@ -1,7 +1,6 @@
 package com.etotem.cfc.entity;
 package com.etotem.cfc.entity;
 
 
 import com.baomidou.mybatisplus.annotation.IdType;
 import com.baomidou.mybatisplus.annotation.IdType;
-import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
 import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
 import lombok.Data;
@@ -20,21 +19,14 @@ public class PromotionTier implements Serializable {
 
 
     private String tier;
     private String tier;
 
 
-    @TableField("team_size_1st")
-    private Integer teamSize1st;
-
-    @TableField("team_size_2nd")
-    private Integer teamSize2nd;
-
-    @TableField("team_size_3rd")
-    private Integer teamSize3rd;
-
     private Integer totalTeamSize;
     private Integer totalTeamSize;
 
 
     private Integer totalReferralEarnings;
     private Integer totalReferralEarnings;
 
 
     private Integer totalShareEarnings;
     private Integer totalShareEarnings;
 
 
+    private Integer ratePercent;
+
     private Date promotedAt;
     private Date promotedAt;
 
 
     private Date lastChangeAt;
     private Date lastChangeAt;

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/PromotionTierConfig.java

@@ -7,6 +7,10 @@ import lombok.Data;
 import java.io.Serializable;
 import java.io.Serializable;
 import java.util.Date;
 import java.util.Date;
 
 
+/**
+ * 已废弃,改用 cf_rate_tier
+ */
+@Deprecated
 @Data
 @Data
 @TableName("promotion_tier_config")
 @TableName("promotion_tier_config")
 public class PromotionTierConfig implements Serializable {
 public class PromotionTierConfig implements Serializable {

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/CfRateTierMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.CfRateTier;
+
+public interface CfRateTierMapper extends BaseMapper<CfRateTier> {
+}

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/CfTransferRecordMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.CfTransferRecord;
+
+public interface CfTransferRecordMapper extends BaseMapper<CfTransferRecord> {
+}

+ 13 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentOrderService.java

@@ -7,8 +7,10 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.etotem.cfc.common.OrderNoGenerator;
 import com.etotem.cfc.common.OrderNoGenerator;
 import com.etotem.cfc.entity.AssessmentOrder;
 import com.etotem.cfc.entity.AssessmentOrder;
 import com.etotem.cfc.entity.AssessmentAppointment;
 import com.etotem.cfc.entity.AssessmentAppointment;
+import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.AssessmentOrderMapper;
 import com.etotem.cfc.mapper.AssessmentOrderMapper;
 import com.etotem.cfc.mapper.AssessmentAppointmentMapper;
 import com.etotem.cfc.mapper.AssessmentAppointmentMapper;
+import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.CommissionService;
 import com.etotem.cfc.service.CommissionService;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 
 
@@ -33,6 +35,12 @@ public class AssessmentOrderService extends ServiceImpl<AssessmentOrderMapper, A
     @Resource
     @Resource
     private CommissionService commissionService;
     private CommissionService commissionService;
 
 
+    @Resource
+    private CfCommissionService cfCommissionService;
+
+    @Resource
+    private UserMapper userMapper;
+
     public AssessmentOrder createOrder(Long familyId, Long userId, Long memberId,
     public AssessmentOrder createOrder(Long familyId, Long userId, Long memberId,
                                        Long guideId, Long packageId,
                                        Long guideId, Long packageId,
                                        String guideName, String packageName,
                                        String guideName, String packageName,
@@ -73,8 +81,11 @@ public class AssessmentOrderService extends ServiceImpl<AssessmentOrderMapper, A
         order.setUpdatedAt(new Date());
         order.setUpdatedAt(new Date());
         boolean updated = this.updateById(order);
         boolean updated = this.updateById(order);
         if (updated) {
         if (updated) {
-            commissionService.settle(order.getId(), "assessment", order.getUserId(),
-                    order.getActualPrice() != null ? order.getActualPrice().intValue() : null, null);
+            User buyer = userMapper.selectById(order.getUserId());
+            Long buyerFamilyId = (buyer != null) ? buyer.getFamilyId() : null;
+            cfCommissionService.settle(order.getId(), "assessment", order.getUserId(),
+                    buyerFamilyId,
+                    order.getActualPrice() != null ? order.getActualPrice().intValue() : 0, null);
         }
         }
         return updated;
         return updated;
     }
     }

+ 193 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CfCommissionService.java

@@ -0,0 +1,193 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.CfRateTier;
+import com.etotem.cfc.entity.CfTransferRecord;
+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.ProductMapper;
+import com.etotem.cfc.mapper.ReferralTreeMapper;
+import com.etotem.cfc.mapper.UserMapper;
+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.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 统一 CF 值分佣服务。
+ * 规则:
+ *  - 普通订单(商品/套餐/测评):当前消费人返 CF + 推荐人按团队规模阶梯比例分润
+ *  - 会员/订阅订单:只返推荐人,不返当前人
+ *  - 同家庭互推:跳过本人,上溯到第一个非同家庭引荐人
+ */
+@Service
+public class CfCommissionService {
+
+    private static final Logger log = LoggerFactory.getLogger(CfCommissionService.class);
+
+    @Resource
+    private ReferralTreeMapper referralTreeMapper;
+    @Resource
+    private UserMapper userMapper;
+    @Resource
+    private CfReferralService cfReferralService;
+    @Resource
+    private PlatformPointsService platformPointsService;
+    @Resource
+    private CfTransferRecordMapper cfTransferRecordMapper;
+    @Resource
+    private PpointConfigService ppointConfigService;
+    @Resource
+    private SysConfigService sysConfigService;
+    @Resource
+    private ProductMapper productMapper;
+
+    /**
+     * 通用分佣(双返):当前人 + 推荐人
+     */
+    @Transactional
+    public void settle(Long orderId, String orderType, Long buyerUserId, Long buyerFamilyId,
+                       Integer orderAmountCent, Long productId) {
+        // 当前消费人返 CF(个人钱包)
+        int buyerReturn = calcBuyerReturn(orderType, orderAmountCent, productId, buyerUserId);
+        if (buyerReturn > 0) {
+            try {
+                platformPointsService.earn(buyerUserId, buyerReturn, "order_consume", orderId,
+                        "消费返CF:" + orderType);
+                record(buyerUserId, null, null, buyerReturn, "allocate", "order_consume", orderId, "消费返CF");
+            } catch (Exception e) {
+                log.error("当前人返CF失败: buyer={}, orderId={}, err={}", buyerUserId, orderId, e.getMessage());
+            }
+        }
+        // 推荐人分润
+        distributeToReferrers(orderId, orderType, buyerUserId, buyerFamilyId, orderAmountCent, productId);
+    }
+
+    /**
+     * 会员/订阅专用:只返推荐人
+     */
+    @Transactional
+    public void settleReferrerOnly(Long orderId, String orderType, Long buyerUserId, Long buyerFamilyId,
+                                   Integer orderAmountCent) {
+        distributeToReferrers(orderId, orderType, buyerUserId, buyerFamilyId, orderAmountCent, null);
+    }
+
+    /**
+     * 计算当前消费人返 CF。
+     * 商品:floor(orderAmount/100) × P点 × shareBps/10000
+     * 非商品:floor(orderAmount/100) × serviceRateBps/10000
+     */
+    private int calcBuyerReturn(String orderType, Integer orderAmountCent, Long productId, Long buyerUserId) {
+        if (orderAmountCent == null || orderAmountCent <= 0) return 0;
+        int amountYuan = orderAmountCent / 100;
+        if ("product".equals(orderType) && productId != null) {
+            try {
+                Product p = productMapper.selectById(productId);
+                if (p == null) return 0;
+                int effectivePpoint = ppointConfigService.getEffectivePpoint(productId, p.getCategoryId());
+                if (effectivePpoint <= 0) return 0;
+                int shareBps = getSysBps("product_platform_points_share", 1000);
+                return amountYuan * effectivePpoint / 100 * shareBps / 10000;
+            } catch (Exception e) {
+                return 0;
+            }
+        }
+        int serviceRateBps = getSysBps("commission_service_rate", 1000);
+        return amountYuan * serviceRateBps / 10000;
+    }
+
+    /**
+     * 推荐人分润:查全链路 → 同家庭跳过上溯 → 按阶梯比例
+     */
+    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;
+
+        // 取买家全链路推荐人(referral_tree,含全部层级)
+        List<ReferralTree> referrals = referralTreeMapper.selectList(
+                new LambdaQueryWrapper<ReferralTree>()
+                        .eq(ReferralTree::getChildId, buyerUserId)
+                        .orderByAsc(ReferralTree::getLevel));
+        if (referrals == null || referrals.isEmpty()) return;
+
+        List<Long> processed = new ArrayList<>();
+        for (ReferralTree ref : referrals) {
+            Long referrerId = ref.getParentId();
+            if (processed.contains(referrerId)) continue;
+            processed.add(referrerId);
+
+            User referrer = userMapper.selectById(referrerId);
+            if (referrer == null) continue;
+            // 同家庭跳过(D5):不返,继续上溯(循环继续)
+            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, "推荐分润");
+            } catch (Exception e) {
+                log.error("推荐人分润失败: referrer={}, orderId={}, err={}", referrerId, orderId, e.getMessage());
+            }
+        }
+    }
+
+    private int calcReferrerBase(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());
+            } catch (Exception e) {
+                return 0;
+            }
+        }
+        int serviceRateBps = getSysBps("commission_service_rate", 1000);
+        return amountYuan * serviceRateBps / 10000;
+    }
+
+    private int getSysBps(String key, int def) {
+        try {
+            String v = sysConfigService.getValue(key);
+            if (v != null && !v.isEmpty()) return Integer.parseInt(v);
+        } catch (Exception e) {
+            log.warn("读取系统配置失败: key={}, err={}", key, e.getMessage());
+        }
+        return def;
+    }
+
+    private void record(Long fromUserId, Long toUserId, Long familyId, int amount, String type,
+                        String refType, Long refId, String remark) {
+        CfTransferRecord r = new CfTransferRecord();
+        r.setFromUserId(fromUserId);
+        r.setToUserId(toUserId);
+        r.setFamilyId(familyId);
+        r.setAmount(amount);
+        r.setType(type);
+        r.setRefType(refType);
+        r.setRefId(refId);
+        r.setRemark(remark);
+        r.setCreatedAt(new Date());
+        try { cfTransferRecordMapper.insert(r); } catch (Exception e) { log.warn("写流转记录失败: {}", e.getMessage()); }
+    }
+}

+ 89 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CfReferralService.java

@@ -0,0 +1,89 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.CfRateTier;
+import com.etotem.cfc.entity.ReferralTree;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.CfRateTierMapper;
+import com.etotem.cfc.mapper.ReferralTreeMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 推荐关系物化与团队规模统计(全层级)
+ */
+@Service
+public class CfReferralService {
+
+    @Resource
+    private ReferralTreeMapper referralTreeMapper;
+    @Resource
+    private UserMapper userMapper;
+    @Resource
+    private CfRateTierMapper cfRateTierMapper;
+
+    /**
+     * 绑定推荐关系时物化整条祖先链(含本人 L1)到 referral_tree。
+     * 调用方:CommissionService.bindReferral() 设置 user.referrerId 之后。
+     */
+    @Transactional
+    public void materializeReferralTree(Long childId) {
+        // 防止重复物化
+        Long cnt = referralTreeMapper.selectCount(
+                new LambdaQueryWrapper<ReferralTree>().eq(ReferralTree::getChildId, childId));
+        if (cnt != null && cnt > 0) {
+            return;
+        }
+        // 沿 referrerId 链收集祖先(child 本人算 level 0)
+        List<Long> chain = new ArrayList<>();
+        User cur = userMapper.selectById(childId);
+        while (cur != null && cur.getReferrerId() != null && !chain.contains(cur.getReferrerId())) {
+            chain.add(cur.getReferrerId());
+            cur = userMapper.selectById(cur.getReferrerId());
+        }
+        // 写 referral_tree:level = 1..n(1 = 直接推荐人,越远 level 越大),path = 从根到该祖先
+        // path 格式: /ancestorId/.../directReferrerId/
+        StringBuilder path = new StringBuilder("/");
+        for (int i = chain.size() - 1; i >= 0; i--) {
+            Long parentId = chain.get(i);
+            path.append(parentId).append("/");
+            ReferralTree node = new ReferralTree();
+            node.setParentId(parentId);
+            node.setChildId(childId);
+            node.setLevel(i + 1);
+            node.setPath(path.toString());
+            node.setCreatedAt(new Date());
+            referralTreeMapper.insert(node);
+        }
+    }
+
+    /**
+     * 全层级团队总人数(所有下线,含间接)。
+     * 统计 referral_tree 中 parent_id = userId 的所有记录数。
+     */
+    public int getTotalTeamSize(Long userId) {
+        Long cnt = referralTreeMapper.selectCount(
+                new LambdaQueryWrapper<ReferralTree>().eq(ReferralTree::getParentId, userId));
+        return cnt == null ? 0 : cnt.intValue();
+    }
+
+    /**
+     * 按团队规模匹配阶梯比例:cf_rate_tier WHERE enabled=1 AND min_team_size <= size ORDER BY min_team_size DESC LIMIT 1
+     * 无匹配返回 0。
+     */
+    public CfRateTier matchRateTier(int teamSize) {
+        List<CfRateTier> tiers = cfRateTierMapper.selectList(
+                new LambdaQueryWrapper<CfRateTier>()
+                        .eq(CfRateTier::getEnabled, 1)
+                        .le(CfRateTier::getMinTeamSize, teamSize)
+                        .orderByDesc(CfRateTier::getMinTeamSize)
+                        .last("LIMIT 1"));
+        return tiers.isEmpty() ? null : tiers.get(0);
+    }
+}

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

@@ -72,6 +72,9 @@ public class CommissionService {
     @Resource
     @Resource
     private PromotionTierConfigMapper configMapper;
     private PromotionTierConfigMapper configMapper;
 
 
+    @Resource
+    private CfReferralService cfReferralService;
+
     // ==================== 工具方法 ====================
     // ==================== 工具方法 ====================
 
 
     /**
     /**
@@ -129,6 +132,9 @@ public class CommissionService {
         user.setReferrerId(referrer.getId());
         user.setReferrerId(referrer.getId());
         userMapper.updateById(user);
         userMapper.updateById(user);
 
 
+        // 物化推荐树(全层级)
+        try { cfReferralService.materializeReferralTree(userId); } catch (Exception e) { log.warn("推荐树物化失败 userId={}", userId, e); }
+
         // 更新推荐人直接推荐人数
         // 更新推荐人直接推荐人数
         referrer.setDirectCount(referrer.getDirectCount() != null ? referrer.getDirectCount() + 1 : 1);
         referrer.setDirectCount(referrer.getDirectCount() != null ? referrer.getDirectCount() + 1 : 1);
         userMapper.updateById(referrer);
         userMapper.updateById(referrer);
@@ -175,6 +181,7 @@ public class CommissionService {
     /**
     /**
      * 原有settle方法 - 兼容旧调用
      * 原有settle方法 - 兼容旧调用
      * 内部委托给settleTwoLevel实现L1+L2佣金
      * 内部委托给settleTwoLevel实现L1+L2佣金
+     * 已废弃(CF值分佣替代)。保留历史数据读取与兼容旧调用方,不再被业务订单调用。
      */
      */
     @Deprecated
     @Deprecated
     public void settle(Long orderId, String orderType, Long buyerId,
     public void settle(Long orderId, String orderType, Long buyerId,
@@ -184,6 +191,7 @@ public class CommissionService {
 
 
     /**
     /**
      * 两级佣金结算(L1+L2)
      * 两级佣金结算(L1+L2)
+     * 已废弃(CF值分佣替代)。保留历史数据读取与兼容旧调用方,不再被业务订单调用。
      * @deprecated 佣金体系已废弃,由 CF 值(平台积分)替代。保留兼容旧调用方。
      * @deprecated 佣金体系已废弃,由 CF 值(平台积分)替代。保留兼容旧调用方。
      * - L1: 买家的直接推荐人获得佣金
      * - L1: 买家的直接推荐人获得佣金
      * - L2: 如果L1推荐人也有推荐人,且订单类型为membership,L2获得佣金
      * - L2: 如果L1推荐人也有推荐人,且订单类型为membership,L2获得佣金

+ 7 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/MemberSubscriptionService.java

@@ -34,6 +34,9 @@ public class MemberSubscriptionService {
     @Resource
     @Resource
     private CommissionService commissionService;
     private CommissionService commissionService;
 
 
+    @Resource
+    private CfCommissionService cfCommissionService;
+
     @Resource
     @Resource
     private PromotionTierService promotionTierService;
     private PromotionTierService promotionTierService;
 
 
@@ -171,7 +174,8 @@ public class MemberSubscriptionService {
 
 
         Family family = familyMapper.selectById(familyId);
         Family family = familyMapper.selectById(familyId);
         if (family != null && family.getCreatorId() != null) {
         if (family != null && family.getCreatorId() != null) {
-            commissionService.settleTwoLevel(order.getId(), "subscription", family.getCreatorId(), amount, null);
+            cfCommissionService.settleReferrerOnly(order.getId(), "subscription", family.getCreatorId(),
+                    family.getId(), amount);
 
 
             User buyer = userMapper.selectById(family.getCreatorId());
             User buyer = userMapper.selectById(family.getCreatorId());
             if (buyer != null && buyer.getReferrerId() != null) {
             if (buyer != null && buyer.getReferrerId() != null) {
@@ -252,7 +256,8 @@ public class MemberSubscriptionService {
 
 
         Family family = familyMapper.selectById(familyId);
         Family family = familyMapper.selectById(familyId);
         if (family != null && family.getCreatorId() != null) {
         if (family != null && family.getCreatorId() != null) {
-            commissionService.settleTwoLevel(order.getId(), "subscription", family.getCreatorId(), amount, null);
+            cfCommissionService.settleReferrerOnly(order.getId(), "subscription", family.getCreatorId(),
+                    family.getId(), amount);
 
 
             User buyer = userMapper.selectById(family.getCreatorId());
             User buyer = userMapper.selectById(family.getCreatorId());
             if (buyer != null && buyer.getReferrerId() != null) {
             if (buyer != null && buyer.getReferrerId() != null) {

+ 7 - 8
cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java

@@ -45,6 +45,9 @@ public class MembershipService implements MembershipServiceInterface {
     @Resource
     @Resource
     private CommissionService commissionService;
     private CommissionService commissionService;
 
 
+    @Resource
+    private CfCommissionService cfCommissionService;
+
     @Resource
     @Resource
     private SysConfigService sysConfigService;
     private SysConfigService sysConfigService;
 
 
@@ -780,14 +783,10 @@ public class MembershipService implements MembershipServiceInterface {
                 log.error("发放JOIN会员券异常: userId={}, levelCode={}", adminUserId, order.getLevelCode(), e);
                 log.error("发放JOIN会员券异常: userId={}, levelCode={}", adminUserId, order.getLevelCode(), e);
             }
             }
 
 
-            // 结算佣金(L1+L2)
-            commissionService.settleTwoLevel(
-                    order.getId(),
-                    "membership",
-                    adminUserId,
-                    order.getAmount(),
-                    null
-            );
+            // 结算佣金(推荐人分润)
+            User buyer = userMapper.selectById(adminUserId);
+            Long buyerFamilyId = (buyer != null) ? buyer.getFamilyId() : null;
+            cfCommissionService.settleReferrerOnly(order.getId(), "membership", adminUserId, buyerFamilyId, order.getAmount());
 
 
             log.info("会员开通完成: orderNo={}, levelCode={}, amount={}, fromLevel={}",
             log.info("会员开通完成: orderNo={}, levelCode={}, amount={}, fromLevel={}",
                     order.getOrderNo(), order.getLevelCode(), order.getAmount(), fromLevel);
                     order.getOrderNo(), order.getLevelCode(), order.getAmount(), fromLevel);

+ 5 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/PackagePaymentService.java

@@ -37,6 +37,9 @@ public class PackagePaymentService implements PackagePaymentServiceInterface {
     @Resource
     @Resource
     private CommissionService commissionService;
     private CommissionService commissionService;
 
 
+    @Resource
+    private CfCommissionService cfCommissionService;
+
     @Resource
     @Resource
     private CouponService couponService;
     private CouponService couponService;
 
 
@@ -217,8 +220,8 @@ public class PackagePaymentService implements PackagePaymentServiceInterface {
                     couponService.markUsed(order.getUserCouponId(), order.getId());
                     couponService.markUsed(order.getUserCouponId(), order.getId());
                 }
                 }
 
 
-                commissionService.settle(order.getId(), "package", order.getUserId(),
-                        order.getPrice(), null);
+                cfCommissionService.settle(order.getId(), "package", order.getUserId(),
+                        order.getFamilyId(), order.getPrice(), null);
 
 
                 // 购买完成 → 推进旅程 stage=3(幂等)
                 // 购买完成 → 推进旅程 stage=3(幂等)
                 advanceStage(order.getUserId(), 3);
                 advanceStage(order.getUserId(), 3);

+ 5 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/PaymentService.java

@@ -57,6 +57,9 @@ public class PaymentService implements PaymentServiceInterface {
     @Resource
     @Resource
     private CommissionService commissionService;
     private CommissionService commissionService;
 
 
+    @Resource
+    private CfCommissionService cfCommissionService;
+
     @Lazy
     @Lazy
     @Resource
     @Resource
     private ProductOrderService productOrderService;
     private ProductOrderService productOrderService;
@@ -237,8 +240,8 @@ public class PaymentService implements PaymentServiceInterface {
         order.setUpdatedAt(new Date());
         order.setUpdatedAt(new Date());
         boolean updated = packageOrderMapper.updateById(order) > 0;
         boolean updated = packageOrderMapper.updateById(order) > 0;
         if (updated) {
         if (updated) {
-            commissionService.settle(order.getId(), "package", order.getUserId(),
-                    order.getPrice(), null);
+            cfCommissionService.settle(order.getId(), "package", order.getUserId(),
+                    order.getFamilyId(), order.getPrice(), null);
         }
         }
         return updated;
         return updated;
     }
     }

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

@@ -74,6 +74,17 @@ public class PlatformPointsService {
         if (amount <= 0) {
         if (amount <= 0) {
             throw new IllegalArgumentException("CF值数量必须为正数");
             throw new IllegalArgumentException("CF值数量必须为正数");
         }
         }
+        // 幂等:按 (userId, ref_type, ref_id) 去重
+        if (refId != null) {
+            Long existing = logMapper.selectCount(
+                    new LambdaQueryWrapper<PlatformBalanceLog>()
+                            .eq(PlatformBalanceLog::getUserId, userId)
+                            .eq(PlatformBalanceLog::getRefType, refType)
+                            .eq(PlatformBalanceLog::getRefId, refId));
+            if (existing != null && existing > 0) {
+                return;
+            }
+        }
         UserPlatformBalance b = getOrCreate(userId);
         UserPlatformBalance b = getOrCreate(userId);
         b.setTotalEarned(safeInt(b.getTotalEarned()) + amount);
         b.setTotalEarned(safeInt(b.getTotalEarned()) + amount);
         b.setAvailable(safeInt(b.getAvailable()) + amount);
         b.setAvailable(safeInt(b.getAvailable()) + amount);

+ 8 - 36
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

@@ -84,6 +84,9 @@ public class ProductOrderService {
     @Resource
     @Resource
     private CommissionService commissionService;
     private CommissionService commissionService;
 
 
+    @Resource
+    private CfCommissionService cfCommissionService;
+
     @Resource
     @Resource
     private PaymentService paymentService;
     private PaymentService paymentService;
 
 
@@ -114,21 +117,9 @@ public class ProductOrderService {
     @Resource
     @Resource
     private SupplySystemMemberMapper supplySystemMemberMapper;
     private SupplySystemMemberMapper supplySystemMemberMapper;
 
 
-    @Resource
-    private PpointConfigService ppointConfigService;
-
-    @Resource
-    private CommissionDistService commissionDistService;
-
-    @Resource
-    private PlatformPointsService platformPointsService;
-
     @Resource
     @Resource
     private FamilyPlatformPointsService familyPlatformPointsService;
     private FamilyPlatformPointsService familyPlatformPointsService;
 
 
-    @Resource
-    private SysConfigService sysConfigService;
-
     @Resource
     @Resource
     private SupplyHierarchyService supplyHierarchyService;
     private SupplyHierarchyService supplyHierarchyService;
 
 
@@ -619,32 +610,13 @@ public class ProductOrderService {
         order.setUpdatedAt(new Date());
         order.setUpdatedAt(new Date());
         orderMapper.updateById(order);
         orderMapper.updateById(order);
 
 
-        // CF值:购买者本人按分润公式获得
-        try {
-            Product p = productMapper.selectById(order.getProductId());
-            if (p != null) {
-                int effectivePpoint = ppointConfigService.getEffectivePpoint(p.getId(), p.getCategoryId());
-                if (effectivePpoint > 0) {
-                    String shareStr = sysConfigService.getValue("product_platform_points_share");
-                    int shareBps = shareStr != null && !shareStr.isEmpty()
-                            ? Integer.parseInt(shareStr) : 1000;
-                    int cfValue = order.getTotalAmount() * effectivePpoint / 100 * shareBps / 10000;
-                    if (cfValue > 0) {
-                        platformPointsService.earn(order.getBuyerId(), cfValue,
-                                "product_order", order.getId(),
-                                "购买商品: " + order.getProductName());
-                    }
-                }
-            }
-        } catch (Exception e) {
-            log.error("购买者CF值发放失败: orderId={}, error={}", order.getId(), e.getMessage());
-        }
-
-        // CF值:推荐人按推荐链分润
+        // CF值:统一分佣(当前人 + 推荐人)
         try {
         try {
-            commissionDistService.distribute(order.getBuyerId(), order.getId(), order.getProductId());
+            cfCommissionService.settle(order.getId(), "product", order.getBuyerId(),
+                    userMapper.selectById(order.getBuyerId()).getFamilyId(),
+                    order.getTotalAmount(), order.getProductId());
         } catch (Exception e) {
         } catch (Exception e) {
-            log.error("推荐人分润失败: orderId={}, error={}", order.getId(), e.getMessage());
+            log.error("CF值分佣失败: orderId={}, error={}", order.getId(), e.getMessage());
         }
         }
 
 
         // 原佣金结算标记为废弃,保留兼容
         // 原佣金结算标记为废弃,保留兼容

+ 12 - 42
cfc-backend/src/main/java/com/etotem/cfc/service/PromotionTierEvalService.java

@@ -1,10 +1,8 @@
 package com.etotem.cfc.service;
 package com.etotem.cfc.service;
 
 
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.CfRateTier;
 import com.etotem.cfc.entity.PromotionTier;
 import com.etotem.cfc.entity.PromotionTier;
-import com.etotem.cfc.entity.PromotionTierConfig;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.entity.User;
-import com.etotem.cfc.mapper.PromotionTierConfigMapper;
 import com.etotem.cfc.mapper.PromotionTierMapper;
 import com.etotem.cfc.mapper.PromotionTierMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import org.slf4j.Logger;
 import org.slf4j.Logger;
@@ -27,15 +25,15 @@ public class PromotionTierEvalService {
     @Resource
     @Resource
     private PromotionTierMapper tierMapper;
     private PromotionTierMapper tierMapper;
 
 
-    @Resource
-    private PromotionTierConfigMapper configMapper;
-
     @Resource
     @Resource
     private PromotionTierService promotionTierService;
     private PromotionTierService promotionTierService;
 
 
     @Resource
     @Resource
     private UserMapper userMapper;
     private UserMapper userMapper;
 
 
+    @Resource
+    private CfReferralService cfReferralService;
+
     @Resource
     @Resource
     private WechatService wechatService;
     private WechatService wechatService;
 
 
@@ -43,45 +41,17 @@ public class PromotionTierEvalService {
     private String promotionTierTemplateId;
     private String promotionTierTemplateId;
 
 
     /**
     /**
-     * 获取所有启用且按sortOrder desc排序的等级配置
-     */
-    public List<PromotionTierConfig> getEnabledConfigs() {
-        return configMapper.selectList(
-            new LambdaQueryWrapper<PromotionTierConfig>()
-                .eq(PromotionTierConfig::getEnabled, 1)
-                .orderByDesc(PromotionTierConfig::getSortOrder)
-        );
-    }
-
-    /**
-     * 根据用户的 PromotionTier 数据,计算应该属于哪个等级
-     * 遍历配置,从高到低找到第一个满足所有条件的等级
-     * @return 目标等级代码,如 "R2",如果没有任何配置匹配返回 "R0"
+     * 根据全层级团队规模计算应属等级(读 cf_rate_tier)
+     * @return 目标等级代码,如 "R2",无匹配返回 "R0"
      */
      */
     public String evaluateTier(Long userId) {
     public String evaluateTier(Long userId) {
-        PromotionTier tier = promotionTierService.getCurrentTier(userId);
-        List<PromotionTierConfig> configs = getEnabledConfigs();
-
-        int teamSize = tier != null && tier.getTotalTeamSize() != null ? tier.getTotalTeamSize() : 0;
-        int referralEarnings = tier != null && tier.getTotalReferralEarnings() != null ? tier.getTotalReferralEarnings() : 0;
-        int shareEarnings = tier != null && tier.getTotalShareEarnings() != null ? tier.getTotalShareEarnings() : 0;
-
-        // 从高等级到低等级遍历,找到第一个满足所有条件的
-        // configs 已按 sortOrder desc 排序(高等级在前)
-        for (PromotionTierConfig config : configs) {
-            boolean teamSizeOk = teamSize >= config.getMinTeamSize();
-            boolean referralOk = config.getMinReferralEarnings() == null
-                || config.getMinReferralEarnings() == 0
-                || referralEarnings >= config.getMinReferralEarnings();
-            boolean shareOk = config.getMinShareEarnings() == null
-                || config.getMinShareEarnings() == 0
-                || shareEarnings >= config.getMinShareEarnings();
-
-            if (teamSizeOk && referralOk && shareOk) {
-                return config.getTierCode();
-            }
+        try {
+            int teamSize = cfReferralService.getTotalTeamSize(userId);
+            CfRateTier tier = cfReferralService.matchRateTier(teamSize);
+            return tier == null ? "R0" : tier.getTierName();
+        } catch (Exception e) {
+            return "R0";
         }
         }
-        return "R0";
     }
     }
 
 
     /**
     /**

+ 35 - 21
cfc-backend/src/main/java/com/etotem/cfc/service/PromotionTierService.java

@@ -1,12 +1,15 @@
 package com.etotem.cfc.service;
 package com.etotem.cfc.service;
 
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.CfRateTier;
 import com.etotem.cfc.entity.PromotionTier;
 import com.etotem.cfc.entity.PromotionTier;
 import com.etotem.cfc.entity.PromotionTierChangeLog;
 import com.etotem.cfc.entity.PromotionTierChangeLog;
 import com.etotem.cfc.entity.PromotionTierConfig;
 import com.etotem.cfc.entity.PromotionTierConfig;
 import com.etotem.cfc.mapper.PromotionTierChangeLogMapper;
 import com.etotem.cfc.mapper.PromotionTierChangeLogMapper;
 import com.etotem.cfc.mapper.PromotionTierConfigMapper;
 import com.etotem.cfc.mapper.PromotionTierConfigMapper;
 import com.etotem.cfc.mapper.PromotionTierMapper;
 import com.etotem.cfc.mapper.PromotionTierMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
@@ -16,6 +19,8 @@ import java.util.List;
 @Service
 @Service
 public class PromotionTierService {
 public class PromotionTierService {
 
 
+    private static final Logger log = LoggerFactory.getLogger(PromotionTierService.class);
+
     @Resource
     @Resource
     private PromotionTierMapper tierMapper;
     private PromotionTierMapper tierMapper;
 
 
@@ -25,6 +30,9 @@ public class PromotionTierService {
     @Resource
     @Resource
     private PromotionTierConfigMapper tierConfigMapper;
     private PromotionTierConfigMapper tierConfigMapper;
 
 
+    @Resource
+    private CfReferralService cfReferralService;
+
     /**
     /**
      * 获取用户当前推广等级
      * 获取用户当前推广等级
      */
      */
@@ -59,17 +67,14 @@ public class PromotionTierService {
     }
     }
 
 
     /**
     /**
-     * 更新团队规模(当有新推荐人加入时调用)
+     * 团队规模 +delta(新推荐绑定后对所有祖先增量)。level 参数已废弃(改为全层级)。
      */
      */
-    public void updateTeamSize(Long userId, int level, int delta) {
+    public void updateTeamSize(Long userId, int delta) {
         PromotionTier tier = getCurrentTier(userId);
         PromotionTier tier = getCurrentTier(userId);
         if (tier == null) {
         if (tier == null) {
             tier = new PromotionTier();
             tier = new PromotionTier();
             tier.setUserId(userId);
             tier.setUserId(userId);
             tier.setTier("R0");
             tier.setTier("R0");
-            tier.setTeamSize1st(0);
-            tier.setTeamSize2nd(0);
-            tier.setTeamSize3rd(0);
             tier.setTotalTeamSize(0);
             tier.setTotalTeamSize(0);
             tier.setTotalReferralEarnings(0);
             tier.setTotalReferralEarnings(0);
             tier.setTotalShareEarnings(0);
             tier.setTotalShareEarnings(0);
@@ -77,22 +82,34 @@ public class PromotionTierService {
             tier.setUpdatedAt(new Date());
             tier.setUpdatedAt(new Date());
             tierMapper.insert(tier);
             tierMapper.insert(tier);
         }
         }
-
-        if (level == 1) {
-            tier.setTeamSize1st(tier.getTeamSize1st() + delta);
-        } else if (level == 2) {
-            tier.setTeamSize2nd(tier.getTeamSize2nd() + delta);
-        } else if (level == 3) {
-            tier.setTeamSize3rd(tier.getTeamSize3rd() + delta);
-        }
-        tier.setTotalTeamSize(
-                (tier.getTeamSize1st() != null ? tier.getTeamSize1st() : 0) +
-                (tier.getTeamSize2nd() != null ? tier.getTeamSize2nd() : 0) +
-                (tier.getTeamSize3rd() != null ? tier.getTeamSize3rd() : 0)
-        );
+        tier.setTotalTeamSize((tier.getTotalTeamSize() == null ? 0 : tier.getTotalTeamSize()) + delta);
         tier.setLastChangeAt(new Date());
         tier.setLastChangeAt(new Date());
         tier.setUpdatedAt(new Date());
         tier.setUpdatedAt(new Date());
         tierMapper.updateById(tier);
         tierMapper.updateById(tier);
+        refreshRate(userId);
+    }
+
+    /**
+     * 按全层级团队规模刷新等级与返佣比例(读 cf_rate_tier)
+     */
+    public void refreshRate(Long userId) {
+        try {
+            int teamSize = cfReferralService.getTotalTeamSize(userId);
+            CfRateTier tier = cfReferralService.matchRateTier(teamSize);
+            PromotionTier pt = getCurrentTier(userId);
+            if (pt == null) return;
+            if (tier != null) {
+                pt.setTier(tier.getTierName());
+                pt.setRatePercent(tier.getRatePercent());
+            } else {
+                pt.setTier("R0");
+                pt.setRatePercent(0);
+            }
+            pt.setUpdatedAt(new Date());
+            tierMapper.updateById(pt);
+        } catch (Exception e) {
+            log.warn("refreshRate失败 userId={}", userId, e);
+        }
     }
     }
 
 
     /**
     /**
@@ -153,9 +170,6 @@ public class PromotionTierService {
             tier = new PromotionTier();
             tier = new PromotionTier();
             tier.setUserId(userId);
             tier.setUserId(userId);
             tier.setTier(tierCode);
             tier.setTier(tierCode);
-            tier.setTeamSize1st(0);
-            tier.setTeamSize2nd(0);
-            tier.setTeamSize3rd(0);
             tier.setTotalTeamSize(0);
             tier.setTotalTeamSize(0);
             tier.setTotalReferralEarnings(0);
             tier.setTotalReferralEarnings(0);
             tier.setTotalShareEarnings(0);
             tier.setTotalShareEarnings(0);

+ 16 - 7
cfc-backend/src/main/java/com/etotem/cfc/task/PromotionTierCheckScheduledTask.java

@@ -1,31 +1,40 @@
 package com.etotem.cfc.task;
 package com.etotem.cfc.task;
 
 
-import com.etotem.cfc.service.PromotionTierEvalService;
+import com.etotem.cfc.entity.PromotionTier;
+import com.etotem.cfc.mapper.PromotionTierMapper;
+import com.etotem.cfc.service.PromotionTierService;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 import org.springframework.stereotype.Component;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
+import java.util.List;
 
 
 @Slf4j
 @Slf4j
 @Component
 @Component
 public class PromotionTierCheckScheduledTask {
 public class PromotionTierCheckScheduledTask {
 
 
     @Resource
     @Resource
-    private PromotionTierEvalService evalService;
+    private PromotionTierService promotionTierService;
+
+    @Resource
+    private PromotionTierMapper promotionTierMapper;
 
 
     /**
     /**
      * 每天 1:00 AM 执行
      * 每天 1:00 AM 执行
-     * 遍历所有有推广等级记录的用户,检查是否需要晋级/降级
+     * 遍历所有有推广等级记录的用户,按全层级团队规模刷新等级与返佣比例
      */
      */
     @Scheduled(cron = "0 0 1 * * ?")
     @Scheduled(cron = "0 0 1 * * ?")
     public void checkAndUpdateAllTiers() {
     public void checkAndUpdateAllTiers() {
-        log.info("开始执行推广等级定时检查任务");
+        log.info("开始执行推广等级定时刷新任务");
         try {
         try {
-            int changed = evalService.evaluateAllUsers();
-            log.info("推广等级定时检查任务执行完成,变更{}个用户等级", changed);
+            List<PromotionTier> allTiers = promotionTierMapper.selectList(null);
+            for (PromotionTier pt : allTiers) {
+                try { promotionTierService.refreshRate(pt.getUserId()); } catch (Exception e) { /* 单用户失败不影响 */ }
+            }
+            log.info("推广等级定时刷新完成,共{}个用户", allTiers.size());
         } catch (Exception e) {
         } catch (Exception e) {
-            log.error("推广等级定时检查任务执行失败", e);
+            log.error("推广等级定时刷新失败", e);
         }
         }
     }
     }
 }
 }

+ 38 - 4
cfc-backend/src/main/resources/schema.sql

@@ -2171,6 +2171,7 @@ CREATE TABLE IF NOT EXISTS coupon (
     used_count INT DEFAULT 0,
     used_count INT DEFAULT 0,
     discount_rate INT COMMENT '折扣券折扣率(千分比,9000=9折)',
     discount_rate INT COMMENT '折扣券折扣率(千分比,9000=9折)',
     product_id BIGINT COMMENT '绑定商品ID,NULL=全场通用',
     product_id BIGINT COMMENT '绑定商品ID,NULL=全场通用',
+    family_id BIGINT DEFAULT NULL COMMENT '绑定家庭ID(NULL=不绑定)',
     points_price INT DEFAULT 0 COMMENT '积分兑换价,0=不可积分兑换',
     points_price INT DEFAULT 0 COMMENT '积分兑换价,0=不可积分兑换',
     grant_type VARCHAR(16) COMMENT '发放规则:JOIN/PERIODIC/POPULATION,NULL=不自动发放',
     grant_type VARCHAR(16) COMMENT '发放规则:JOIN/PERIODIC/POPULATION,NULL=不自动发放',
     grant_level_code VARCHAR(32) COMMENT '发放规则对应会员等级',
     grant_level_code VARCHAR(32) COMMENT '发放规则对应会员等级',
@@ -2771,12 +2772,10 @@ CREATE TABLE IF NOT EXISTS promotion_tier (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     user_id BIGINT NOT NULL COMMENT '用户ID',
     user_id BIGINT NOT NULL COMMENT '用户ID',
     tier VARCHAR(20) NOT NULL DEFAULT 'R0' COMMENT 'R0利他者/R1传福人/R2聚能师/R3启慧者/R4传承者',
     tier VARCHAR(20) NOT NULL DEFAULT 'R0' COMMENT 'R0利他者/R1传福人/R2聚能师/R3启慧者/R4传承者',
-    team_size_1st INT DEFAULT 0 COMMENT '一层团队人数',
-    team_size_2nd INT DEFAULT 0 COMMENT '二层团队人数',
-    team_size_3rd INT DEFAULT 0 COMMENT '三层团队人数',
     total_team_size INT DEFAULT 0 COMMENT '三层累计总人数',
     total_team_size INT DEFAULT 0 COMMENT '三层累计总人数',
     total_referral_earnings INT DEFAULT 0 COMMENT '累计推荐佣金(分)',
     total_referral_earnings INT DEFAULT 0 COMMENT '累计推荐佣金(分)',
     total_share_earnings INT DEFAULT 0 COMMENT '累计消费返利(分)',
     total_share_earnings INT DEFAULT 0 COMMENT '累计消费返利(分)',
+    rate_percent INT DEFAULT 0 COMMENT '当前返佣比例(%)',
     promoted_at DATETIME DEFAULT NULL COMMENT '最近晋级时间',
     promoted_at DATETIME DEFAULT NULL COMMENT '最近晋级时间',
     last_change_at DATETIME DEFAULT NULL COMMENT '最后变更时间',
     last_change_at DATETIME DEFAULT NULL COMMENT '最后变更时间',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -2804,7 +2803,7 @@ CREATE TABLE IF NOT EXISTS referral_tree (
     level INT NOT NULL COMMENT '层级深度(1/2/3)',
     level INT NOT NULL COMMENT '层级深度(1/2/3)',
     path VARCHAR(255) DEFAULT NULL COMMENT 'materialized path如/1/2/3/',
     path VARCHAR(255) DEFAULT NULL COMMENT 'materialized path如/1/2/3/',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-    UNIQUE KEY uk_child_id (child_id),
+    UNIQUE KEY uk_parent_child (parent_id, child_id),
     INDEX idx_parent_id (parent_id),
     INDEX idx_parent_id (parent_id),
     INDEX idx_level (level)
     INDEX idx_level (level)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推荐关系树';
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推荐关系树';
@@ -5305,3 +5304,38 @@ CREATE TABLE IF NOT EXISTS self_check_ignores (
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '忽略时间',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '忽略时间',
     INDEX idx_user_id (user_id)
     INDEX idx_user_id (user_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='五维自检忽略提醒记录';
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='五维自检忽略提醒记录';
+
+-- ============================================================
+-- CF值分佣体系重构(迁移279-282)
+-- ============================================================
+
+-- CF值返佣阶梯配置表(迁移279,替代 promotion_tier_config)
+CREATE TABLE IF NOT EXISTS cf_rate_tier (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    tier_name VARCHAR(50) NOT NULL COMMENT '档位名称',
+    min_team_size INT NOT NULL DEFAULT 0 COMMENT '团队规模下限(含)',
+    rate_percent INT NOT NULL DEFAULT 0 COMMENT '返佣比例(%)',
+    sort_order INT NOT NULL DEFAULT 0 COMMENT '排序,越大越高',
+    enabled TINYINT DEFAULT 1 COMMENT '1启用/0停用',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_min_size (min_team_size)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CF值返佣阶梯配置';
+
+-- CF值流转记录表(迁移280)
+CREATE TABLE IF NOT EXISTS cf_transfer_record (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    from_user_id BIGINT COMMENT '转出用户ID(null=平台)',
+    to_user_id BIGINT COMMENT '转入用户ID',
+    family_id BIGINT COMMENT '所属家庭ID',
+    amount INT NOT NULL COMMENT 'CF值数量',
+    type VARCHAR(16) NOT NULL COMMENT 'transfer/allocate/refund',
+    ref_type VARCHAR(50) COMMENT '关联业务类型',
+    ref_id BIGINT COMMENT '关联业务ID',
+    remark VARCHAR(255),
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_from (from_user_id),
+    INDEX idx_to (to_user_id),
+    INDEX idx_family (family_id),
+    UNIQUE KEY uk_ref_type_id (ref_type, ref_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CF值流转记录';

+ 119 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/CfCommissionServiceTest.java

@@ -0,0 +1,119 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.CfRateTier;
+import com.etotem.cfc.mapper.CfRateTierMapper;
+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.ArrayList;
+import java.util.Comparator;
+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.assertNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+/**
+ * CF 值分佣阶梯匹配边界测试。
+ *
+ * 通过 Mockito 注入 mock 的 CfRateTierMapper 到生产方法所在的 CfReferralService,
+ * 调用真实的 CfReferralService.matchRateTier(int) 验证匹配语义,而非复刻一份逻辑。
+ *
+ * 生产方法 matchRateTier 内部用 MyBatis-Plus 查询:
+ *   WHERE enabled=1 AND min_team_size <= teamSize ORDER BY min_team_size DESC LIMIT 1
+ * 该查询在数据库侧执行;单元测试中由 mock 用 thenAnswer 模拟 DB 侧语义(过滤 + 降序),
+ * 再由生产方法自行取第一条。这样生产方法 get(0)/empty 的真实 Java 逻辑仍被覆盖。
+ *
+ * 种子档位:
+ *   铜牌 min=0  5% / 银牌 min=3  10% / 金牌 min=10 15%
+ *   铂金 min=30 20% / 钻石 min=100 25%
+ * 预期边界:
+ *   0/1/2  → 铜牌 5;3~9 → 银牌 10;10~29 → 金牌 15
+ *   30~99  → 铂金 20;100+ → 钻石 25
+ */
+class CfCommissionServiceTest {
+
+    @Mock
+    private CfRateTierMapper cfRateTierMapper;
+
+    @InjectMocks
+    private CfReferralService cfReferralService;
+
+    private List<CfRateTier> seedTiers;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        seedTiers = new ArrayList<>();
+        seedTiers.add(tier("铜牌", 0, 5));
+        seedTiers.add(tier("银牌", 3, 10));
+        seedTiers.add(tier("金牌", 10, 15));
+        seedTiers.add(tier("铂金", 30, 20));
+        seedTiers.add(tier("钻石", 100, 25));
+
+        // 模拟 DB 侧语义:WHERE enabled=1 AND min_team_size <= teamSize ORDER BY min_team_size DESC
+        when(cfRateTierMapper.selectList(any(LambdaQueryWrapper.class))).thenAnswer(inv -> {
+            LambdaQueryWrapper<CfRateTier> wrapper = inv.getArgument(0);
+            int teamSize = extractTeamSize(wrapper);
+            return seedTiers.stream()
+                    .filter(t -> t.getEnabled() != null && t.getEnabled() == 1
+                            && t.getMinTeamSize() != null && t.getMinTeamSize() <= teamSize)
+                    .sorted(Comparator.comparing(CfRateTier::getMinTeamSize).reversed())
+                    .collect(Collectors.toList());
+        });
+    }
+
+    /**
+     * 从 wrapper 的参数取值里取出 le 条件的 teamSize。
+     * wrapper.getParamNameValuePairs() 记录了 eq(enabled,1) 与 le(min_team_size, teamSize) 的值,
+     * enabled 恒为 1,取其余整型即 teamSize。
+     */
+    private int extractTeamSize(LambdaQueryWrapper<CfRateTier> wrapper) {
+        Map<String, Object> params = wrapper.getParamNameValuePairs();
+        return params.values().stream()
+                .filter(v -> v instanceof Integer && !v.equals(1))
+                .map(v -> (Integer) v)
+                .findFirst()
+                .orElse(0);
+    }
+
+    private CfRateTier tier(String name, int min, int rate) {
+        CfRateTier t = new CfRateTier();
+        t.setTierName(name);
+        t.setMinTeamSize(min);
+        t.setRatePercent(rate);
+        t.setEnabled(1);
+        return t;
+    }
+
+    @Test
+    void matchRateTier_边界档位() {
+        assertTier(0, "铜牌", 5);
+        assertTier(3, "银牌", 10);
+        assertTier(10, "金牌", 15);
+        assertTier(30, "铂金", 20);
+        assertTier(100, "钻石", 25);
+    }
+
+    @Test
+    void matchRateTier_临界相邻值() {
+        assertTier(2, "铜牌", 5);
+        assertTier(9, "银牌", 10);
+        assertTier(29, "金牌", 15);
+        assertTier(99, "铂金", 20);
+        assertTier(1000, "钻石", 25);
+    }
+
+    private void assertTier(int teamSize, String name, int rate) {
+        CfRateTier result = cfReferralService.matchRateTier(teamSize);
+        assertEquals(name, result.getTierName(), "teamSize=" + teamSize);
+        assertEquals(rate, result.getRatePercent(), "teamSize=" + teamSize);
+    }
+}

+ 55 - 110
cfc-frontend/pages/promotion/commission.vue

@@ -4,63 +4,53 @@
     <view class="header-card">
     <view class="header-card">
       <view class="header-row">
       <view class="header-row">
         <view class="header-item">
         <view class="header-item">
-          <text class="header-label">累计佣金</text>
-          <text class="header-value orange" v-if="summary">{{ formatPriceWithSymbol(summary.totalCommission) }}</text>
-          <text class="header-value orange" v-else>{{ formatPriceWithSymbol(0) }}</text>
+          <text class="header-label">累计获取</text>
+          <text class="header-value orange" v-if="summary">{{ cfToYuan(summary.totalEarned) }}</text>
+          <text class="header-value orange" v-else>¥0.00</text>
         </view>
         </view>
         <view class="header-item">
         <view class="header-item">
-          <text class="header-label">可提现</text>
-          <text class="header-value blue" v-if="summary">{{ formatPriceWithSymbol(summary.availableAmount) }}</text>
-          <text class="header-value blue" v-else>{{ formatPriceWithSymbol(0) }}</text>
+          <text class="header-label">可用余额</text>
+          <text class="header-value blue" v-if="summary">{{ cfToYuan(summary.available) }}</text>
+          <text class="header-value blue" v-else>¥0.00</text>
         </view>
         </view>
         <view class="header-item">
         <view class="header-item">
-          <text class="header-label">待结算</text>
-          <text class="header-value green" v-if="summary">{{ formatPriceWithSymbol(summary.pendingAmount) }}</text>
-          <text class="header-value green" v-else>{{ formatPriceWithSymbol(0) }}</text>
+          <text class="header-label">冻结</text>
+          <text class="header-value green" v-if="summary">{{ cfToYuan(summary.frozen) }}</text>
+          <text class="header-value green" v-else>¥0.00</text>
         </view>
         </view>
       </view>
       </view>
     </view>
     </view>
 
 
-    <!-- Tab 切换 -->
-    <view class="tab-bar">
-      <view class="tab-item" :class="currentTab === 'all' ? 'tab-active' : ''" @click="switchTab('all')">
-        <text class="tab-text">全部</text>
-      </view>
-      <view class="tab-item" :class="currentTab === 'settled' ? 'tab-active' : ''" @click="switchTab('settled')">
-        <text class="tab-text">已结算</text>
-      </view>
-      <view class="tab-item" :class="currentTab === 'pending' ? 'tab-active' : ''" @click="switchTab('pending')">
-        <text class="tab-text">待结算</text>
-      </view>
-    </view>
-
-    <!-- 佣金列表 -->
+    <!-- CF 流水列表 -->
     <view class="list-section">
     <view class="list-section">
       <view class="list-item" v-for="item in list" :key="item.id">
       <view class="list-item" v-for="item in list" :key="item.id">
         <view class="item-left">
         <view class="item-left">
-          <text :class="['type-tag', item.commissionType === 'member' ? 'tag-orange' : 'tag-blue']">
-            {{ item.commissionType === 'member' ? '会员佣金' : '商品佣金' }}
+          <text :class="['type-tag', isPositive(item.type) ? 'tag-orange' : 'tag-blue']">
+            {{ getTypeText(item.type) }}
           </text>
           </text>
-          <text class="item-desc">{{ getOrderDesc(item) }}</text>
+          <text class="item-desc">{{ item.remark || 'CF值变动' }}</text>
         </view>
         </view>
         <view class="item-right">
         <view class="item-right">
-          <text class="item-amount">+{{ formatPriceWithSymbol(item.commissionAmount) }}</text>
-          <text class="item-status" :class="item.status === 'settled' ? 'status-settled' : item.status === 'cancelled' ? 'status-cancelled' : 'status-pending'">{{ getStatusText(item.status) }}</text>
+          <text :class="['item-amount', isPositive(item.type) ? 'amount-positive' : 'amount-negative']">{{ getAmountText(item) }}</text>
+          <text class="item-status">{{ getRefText(item.refType) }}</text>
           <text class="item-time">{{ formatTime(item.createdAt) }}</text>
           <text class="item-time">{{ formatTime(item.createdAt) }}</text>
         </view>
         </view>
       </view>
       </view>
       <view class="empty-state" v-if="!loading && list.length === 0">
       <view class="empty-state" v-if="!loading && list.length === 0">
-        <text>暂无佣金记录</text>
+        <text>暂无 CF 流水记录</text>
       </view>
       </view>
       <view class="loading-state" v-if="loading">
       <view class="loading-state" v-if="loading">
         <text>加载中...</text>
         <text>加载中...</text>
       </view>
       </view>
+      <view class="loadmore-state" v-if="!loading && list.length > 0 && !hasMore">
+        <text>没有更多了</text>
+      </view>
     </view>
     </view>
   </view>
   </view>
 </template>
 </template>
 
 
 <script>
 <script>
-import { getCommissionList, getCommissionSummary, getCommissionStats } from '../../utils/api.js'
+import { getCfSummary, getCfList } from '../../utils/api.js'
 import { parseDate } from '../../utils/format.js'
 import { parseDate } from '../../utils/format.js'
 
 
 export default {
 export default {
@@ -70,8 +60,7 @@ export default {
       summary: {},
       summary: {},
       page: 1,
       page: 1,
       hasMore: true,
       hasMore: true,
-      loading: false,
-      currentTab: 'all'
+      loading: false
     }
     }
   },
   },
   onLoad() {
   onLoad() {
@@ -85,35 +74,20 @@ export default {
     }
     }
   },
   },
   methods: {
   methods: {
-    switchTab(tab) {
-      if (this.currentTab === tab) return
-      this.currentTab = tab
-      this.page = 1
-      this.list = []
-      this.loadList()
-    },
     async loadSummary() {
     async loadSummary() {
       try {
       try {
-        const res = await getCommissionSummary()
+        var res = await getCfSummary()
         if (res.data) {
         if (res.data) {
           this.summary = res.data
           this.summary = res.data
         }
         }
       } catch (e) {
       } catch (e) {
-        console.error('获取佣金总览失败', e)
+        console.error('获取 CF 总览失败', e)
       }
       }
     },
     },
     async loadList() {
     async loadList() {
       this.loading = true
       this.loading = true
       try {
       try {
-        var page = this.page
-        var size = 20
-        var status = ''
-        if (this.currentTab === 'settled') {
-          status = 'settled'
-        } else if (this.currentTab === 'pending') {
-          status = 'pending'
-        }
-        const res = await getCommissionList(page, size, status)
+        var res = await getCfList(this.page, 20)
         if (res.data && res.data.records) {
         if (res.data && res.data.records) {
           this.list = this.page === 1 ? res.data.records : this.list.concat(res.data.records)
           this.list = this.page === 1 ? res.data.records : this.list.concat(res.data.records)
           this.hasMore = res.data.records.length >= 20
           this.hasMore = res.data.records.length >= 20
@@ -121,33 +95,39 @@ export default {
           this.hasMore = false
           this.hasMore = false
         }
         }
       } catch (e) {
       } catch (e) {
-        console.error('获取佣金明细失败', e)
+        console.error('获取 CF 流水失败', e)
       }
       }
       this.loading = false
       this.loading = false
     },
     },
-    getOrderDesc(item) {
-      var typeMap = {
-        membership: '会员',
-        product: '商品',
-        assessment: '测评',
-        package: '任务模板'
-      }
-      return (typeMap[item.orderType] || item.orderType) + '订单 #' + item.orderId
+    isPositive(type) {
+      return type === 'earn' || type === 'unfreeze'
     },
     },
-    getStatusText(status) {
-      var map = { settled: '已结算', pending: '待结算', cancelled: '已取消' }
-      return map[status] || status || '待结算'
+    getAmountText(item) {
+      var prefix = this.isPositive(item.type) ? '+' : '-'
+      return prefix + this.cfToYuan(item.amount)
     },
     },
-    getStatusClass(status) {
-      if (status === 'settled') return 'status-settled'
-      if (status === 'cancelled') return 'status-cancelled'
-      return 'status-pending'
+    getTypeText(type) {
+      var map = { earn: '获得', spend: '支出', freeze: '冻结', unfreeze: '解冻', withdraw: '提现' }
+      return map[type] || type || '变动'
+    },
+    getRefText(refType) {
+      var map = { product_order: '商品订单', activity: '活动', withdrawal: '提现', migration: '迁移', cf_transfer_out: '转让转出', cf_transfer_in: '转让转入' }
+      return map[refType] || ''
+    },
+    cfToYuan(val) {
+      if (val === null || val === undefined) return '¥0.00'
+      var num = Number(val)
+      if (isNaN(num)) return '¥0.00'
+      return '¥' + (num / 100).toFixed(2)
     },
     },
     formatTime(time) {
     formatTime(time) {
       if (!time) return ''
       if (!time) return ''
       var d = parseDate(time)
       var d = parseDate(time)
       if (!d) return ''
       if (!d) return ''
-      return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
+      return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') + ' ' +
+             String(d.getHours()).padStart(2, '0') + ':' +
+             String(d.getMinutes()).padStart(2, '0') + ':' +
+             String(d.getSeconds()).padStart(2, '0')
     }
     }
   }
   }
 }
 }
@@ -187,33 +167,6 @@ export default {
 .header-value.blue { color: #0EA5E9; }
 .header-value.blue { color: #0EA5E9; }
 .header-value.green { color: #10B981; }
 .header-value.green { color: #10B981; }
 
 
-/* ===== Tab切换 ===== */
-.tab-bar {
-  display: flex;
-  background: #fff;
-  border-radius: 16rpx;
-  padding: 8rpx;
-  margin-bottom: 24rpx;
-  box-shadow: 0 2rpx 8rpx rgba(249,115,22,0.06);
-}
-.tab-item {
-  flex: 1;
-  text-align: center;
-  padding: 16rpx 0;
-  border-radius: 12rpx;
-}
-.tab-active {
-  background: #F97316;
-}
-.tab-active .tab-text {
-  color: #fff;
-  font-weight: 600;
-}
-.tab-text {
-  font-size: 26rpx;
-  color: #64748B;
-}
-
 .list-section {
 .list-section {
   background: #fff;
   background: #fff;
   border-radius: 20rpx;
   border-radius: 20rpx;
@@ -258,35 +211,27 @@ export default {
 .item-amount {
 .item-amount {
   font-size: 30rpx;
   font-size: 30rpx;
   font-weight: bold;
   font-weight: bold;
-  color: #F97316;
   display: block;
   display: block;
 }
 }
+.amount-positive {
+  color: #F97316;
+}
+.amount-negative {
+  color: #94A3B8;
+}
 .item-status {
 .item-status {
   font-size: 20rpx;
   font-size: 20rpx;
-  padding: 2rpx 10rpx;
-  border-radius: 6rpx;
+  color: #94A3B8;
   display: inline-block;
   display: inline-block;
   margin-top: 4rpx;
   margin-top: 4rpx;
 }
 }
-.status-settled {
-  color: #10B981;
-  background: #D1FAE5;
-}
-.status-pending {
-  color: #D97706;
-  background: #FEF3C7;
-}
-.status-cancelled {
-  color: #94A3B8;
-  background: #F1F5F9;
-}
 .item-time {
 .item-time {
   font-size: 20rpx;
   font-size: 20rpx;
   color: #94A3B8;
   color: #94A3B8;
   display: block;
   display: block;
   margin-top: 4rpx;
   margin-top: 4rpx;
 }
 }
-.empty-state, .loading-state {
+.empty-state, .loading-state, .loadmore-state {
   text-align: center;
   text-align: center;
   padding: 60rpx;
   padding: 60rpx;
   color: #94A3B8;
   color: #94A3B8;

+ 16 - 17
cfc-frontend/pages/promotion/index.vue

@@ -115,21 +115,21 @@
       </view>
       </view>
       <view class="earnings-body">
       <view class="earnings-body">
         <view class="earning-item">
         <view class="earning-item">
-          <text class="earning-label">累计佣金</text>
-          <text class="earning-value orange" v-if="summary">{{ formatPriceWithSymbol(summary.totalCommission) }}</text>
-          <text class="earning-value orange" v-else>{{ formatPriceWithSymbol(0) }}</text>
+          <text class="earning-label">累计获取</text>
+          <text class="earning-value orange" v-if="summary">{{ cfToYuan(summary.totalEarned) }}</text>
+          <text class="earning-value orange" v-else>¥0.00</text>
         </view>
         </view>
         <view class="earning-divider"></view>
         <view class="earning-divider"></view>
         <view class="earning-item">
         <view class="earning-item">
-          <text class="earning-label">可提现</text>
-          <text class="earning-value blue" v-if="summary">{{ formatPriceWithSymbol(summary.availableAmount) }}</text>
-          <text class="earning-value blue" v-else>{{ formatPriceWithSymbol(0) }}</text>
+          <text class="earning-label">可用余额</text>
+          <text class="earning-value blue" v-if="summary">{{ cfToYuan(summary.available) }}</text>
+          <text class="earning-value blue" v-else>¥0.00</text>
         </view>
         </view>
         <view class="earning-divider"></view>
         <view class="earning-divider"></view>
         <view class="earning-item">
         <view class="earning-item">
-          <text class="earning-label">待结算</text>
-          <text class="earning-value green" v-if="summary">{{ formatPriceWithSymbol(summary.pendingAmount) }}</text>
-          <text class="earning-value green" v-else>{{ formatPriceWithSymbol(0) }}</text>
+          <text class="earning-label">冻结</text>
+          <text class="earning-value green" v-if="summary">{{ cfToYuan(summary.frozen) }}</text>
+          <text class="earning-value green" v-else>¥0.00</text>
         </view>
         </view>
       </view>
       </view>
     </view>
     </view>
@@ -206,7 +206,7 @@
 </template>
 </template>
 
 
 <script>
 <script>
-import { getReferralSummary, getCommissionSummary, getFamilyEarningsSummary, getFamilyEarningsMembers, getFamilyEarningsRecords, distributeToMember, toggleMemberBalance, withdrawFamilyEarnings, getInviteQrCode, getFamilyMemberList, getReferralCode } from '../../utils/api.js'
+import { getReferralSummary, getCfSummary, getFamilyEarningsSummary, getFamilyEarningsMembers, getFamilyEarningsRecords, distributeToMember, toggleMemberBalance, withdrawFamilyEarnings, getInviteQrCode, getFamilyMemberList, getReferralCode } from '../../utils/api.js'
 import SharePoster from '@/components/SharePoster.vue'
 import SharePoster from '@/components/SharePoster.vue'
 
 
 export default {
 export default {
@@ -313,13 +313,13 @@ export default {
         console.error('获取邀请统计失败', e)
         console.error('获取邀请统计失败', e)
       }
       }
       try {
       try {
-        var comRes = await getCommissionSummary()
+        var comRes = await getCfSummary()
         if (comRes.data) {
         if (comRes.data) {
           this.summary = comRes.data
           this.summary = comRes.data
-          this.availableAmount = comRes.data.availableAmount || '0.00'
+          this.availableAmount = comRes.data.available || '0.00'
         }
         }
       } catch (e) {
       } catch (e) {
-        console.error('获取佣金总览失败', e)
+        console.error('获取 CF 总览失败', e)
       }
       }
     },
     },
     async loadFamilyData() {
     async loadFamilyData() {
@@ -414,12 +414,11 @@ export default {
       if (isNaN(num)) return '0.00'
       if (isNaN(num)) return '0.00'
       return (num / 100).toFixed(2)
       return (num / 100).toFixed(2)
     },
     },
-    formatPriceWithSymbol(val) {
-      if (!val && val !== 0) return '¥0.00'
+    cfToYuan(val) {
+      if (val === null || val === undefined) return '¥0.00'
       var num = Number(val)
       var num = Number(val)
       if (isNaN(num)) return '¥0.00'
       if (isNaN(num)) return '¥0.00'
-      // val 已经是元单位(后端返回的 commission summary 是元)
-      return '¥' + Number(num).toFixed(2)
+      return '¥' + (num / 100).toFixed(2)
     },
     },
     // 管理员操作
     // 管理员操作
     toggleMemberAccess(m) {
     toggleMemberAccess(m) {

+ 12 - 12
cfc-frontend/pages/promotion/team.vue

@@ -3,21 +3,21 @@
     <!-- 团队统计 -->
     <!-- 团队统计 -->
     <view class="stats-card">
     <view class="stats-card">
       <view class="stats-item">
       <view class="stats-item">
-        <text class="stats-num" v-if="teamStats">{{ teamStats.l1Count || 0 }}</text>
+        <text class="stats-num" v-if="cfRate">{{ cfRate.totalTeamSize || 0 }}</text>
         <text class="stats-num" v-else>0</text>
         <text class="stats-num" v-else>0</text>
-        <text class="stats-label">直推(L1)</text>
+        <text class="stats-label">团队规模</text>
       </view>
       </view>
       <view class="stats-divider"></view>
       <view class="stats-divider"></view>
       <view class="stats-item">
       <view class="stats-item">
-        <text class="stats-num" v-if="teamStats">{{ teamStats.l2Count || 0 }}</text>
-        <text class="stats-num" v-else>0</text>
-        <text class="stats-label">间推(L2)</text>
+        <text class="stats-num" v-if="cfRate">{{ (cfRate.ratePercent || 0) + '%' }}</text>
+        <text class="stats-num" v-else>0%</text>
+        <text class="stats-label">返佣比例</text>
       </view>
       </view>
       <view class="stats-divider"></view>
       <view class="stats-divider"></view>
       <view class="stats-item">
       <view class="stats-item">
-        <text class="stats-num" v-if="teamStats">{{ formatPriceWithSymbol(teamStats.totalCommission) }}</text>
-        <text class="stats-num" v-else>{{ formatPriceWithSymbol(0) }}</text>
-        <text class="stats-label">团队佣金</text>
+        <text class="stats-num" v-if="cfRate && cfRate.tierName">{{ cfRate.tierName }}</text>
+        <text class="stats-num" v-else>未入档</text>
+        <text class="stats-label">当前档位</text>
       </view>
       </view>
     </view>
     </view>
 
 
@@ -64,7 +64,7 @@
 </template>
 </template>
 
 
 <script>
 <script>
-import { getCommissionTeam, getTeamList } from '../../utils/api.js'
+import { getCfRate, getTeamList } from '../../utils/api.js'
 import { parseDate } from '../../utils/format.js'
 import { parseDate } from '../../utils/format.js'
 
 
 export default {
 export default {
@@ -76,7 +76,7 @@ export default {
       level: 'L1',
       level: 'L1',
       loading: false,
       loading: false,
       hasMore: true,
       hasMore: true,
-      teamStats: null
+      cfRate: null
     }
     }
   },
   },
   onLoad() {
   onLoad() {
@@ -92,9 +92,9 @@ export default {
   methods: {
   methods: {
     async loadTeamStats() {
     async loadTeamStats() {
       try {
       try {
-        var res = await getCommissionTeam(1, 1)
+        var res = await getCfRate()
         if (res.data) {
         if (res.data) {
-          this.teamStats = res.data
+          this.cfRate = res.data
         }
         }
       } catch (e) {
       } catch (e) {
         console.error('获取团队统计失败', e)
         console.error('获取团队统计失败', e)

+ 8 - 0
cfc-frontend/utils/api.js

@@ -1829,6 +1829,14 @@ export const getCommissionBalance = () => {
   return request('/api/commission/balance', 'POST')
   return request('/api/commission/balance', 'POST')
 }
 }
 
 
+// CF 分佣
+export const getCfRate = () => request('/api/commission/cf/rate', 'POST')
+export const getCfSummary = () => request('/api/commission/cf/summary', 'POST')
+export const getCfList = (page, size) => request('/api/commission/cf/list', 'POST', { page, size })
+// CF 转让
+export const sendCfTransfer = (toUserId, amount) => request('/api/cf/transfer/send', 'POST', { toUserId, amount })
+export const getCfTransferList = (page, size) => request('/api/cf/transfer/list', 'POST', { page, size })
+
 // ===== 财富维度 · 财商打卡 =====
 // ===== 财富维度 · 财商打卡 =====
 export const getCheckinList = (data) => {
 export const getCheckinList = (data) => {
   return request('/api/wealth/checkin/list', 'POST', data)
   return request('/api/wealth/checkin/list', 'POST', data)

+ 47 - 1
docs/superpowers/api/API_REFERENCE.md

@@ -69,6 +69,9 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `ArticleController` | `/api/articles` | 文章 | — |
 | `ArticleController` | `/api/articles` | 文章 | — |
 | `NotificationController` | `/api/notification` | 通知 | — |
 | `NotificationController` | `/api/notification` | 通知 | — |
 | `StatsController` | `/api/stats` | 统计 | — |
 | `StatsController` | `/api/stats` | 统计 | — |
+| `CfCommissionController` | `/api/commission/cf` | CF 分佣(团队规模/比例/钱包/流水) | — |
+| `CfTransferController` | `/api/cf/transfer` | CF 成员间转让 | — |
+| `AdminCfRateTierController` | `/api/admin/cf-rate-tier` | CF 返佣阶梯配置(admin) | — |
 
 
 ---
 ---
 
 
@@ -884,6 +887,49 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 
 
 ---
 ---
 
 
+### 4.33 CF 值分佣(`/api/commission/cf/*`)
+
+> CF 值(平台积分)统一分佣体系:普通订单双返(当前消费人 + 推荐人按团队规模阶梯分润),会员/订阅只返推荐人,同家庭互推跳过本人上溯。
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/commission/cf/rate` | 我的团队规模 + 当前返佣比例 + 档位名。返回 `{ totalTeamSize, ratePercent, tierName }`(未入档时 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,管理端可配置):**
+
+| 团队规模(min_team_size) | 档位 | 返佣比例 |
+|:---:|:---:|:---:|
+| 0~2 | 铜牌 | 5% |
+| 3~9 | 银牌 | 10% |
+| 10~29 | 金牌 | 15% |
+| 30~99 | 铂金 | 20% |
+| 100+ | 钻石 | 25% |
+
+> 匹配语义:`enabled=1 AND min_team_size <= teamSize ORDER BY min_team_size DESC LIMIT 1`。
+
+### 4.34 CF 成员间转让(`/api/cf/transfer/*`)
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/cf/transfer/send` | 成员间转让 CF,请求 `{ toUserId, amount }`。仅限同一家庭(校验 familyId 一致);amount 必须为正数。转出扣减 + 转入增加在同一事务 |
+| `POST /api/cf/transfer/list` | 转让记录(分页,按当前用户家庭过滤),请求 `{ page, size }`,返回 `Page<CfTransferRecord>` |
+
+### 4.35 CF 返佣阶梯配置(admin,`/api/admin/cf-rate-tier/*`)
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/admin/cf-rate-tier/list` | 阶梯列表(按 sort_order 升序),返回 `List<CfRateTier>` |
+| `POST /api/admin/cf-rate-tier/save` | 新增/更新阶梯,请求体为 `CfRateTier`(含 id 则更新,否则新增,默认 enabled=1),返回 "已保存" |
+| `POST /api/admin/cf-rate-tier/delete` | 删除阶梯,请求 `{ id }`,返回 "已删除" |
+
+**`CfRateTier` 字段:** `id` / `tierName`(档位名)/ `minTeamSize`(团队规模下限含)/ `ratePercent`(返佣比例%)/ `sortOrder`(排序,越大越高)/ `enabled`(1启用/0停用)/ `createdAt` / `updatedAt`。
+
+> 注意:家庭券接口为 `POST /api/coupon/my` 与 `POST /api/coupon/list`(见 4.32),不存在 `/api/coupon/family/list` 路由。
+
+---
+
 ## 六、待清理的废弃接口
 ## 六、待清理的废弃接口
 
 
 | Controller | 废弃接口 | 替代方案 | 当前状态 | 清理条件 |
 | Controller | 废弃接口 | 替代方案 | 当前状态 | 清理条件 |
@@ -898,4 +944,4 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 
 
 ---
 ---
 
 
-*文档最后更新:2026-08-31*
+*文档最后更新:2026-09-02*

+ 1 - 1
docs/superpowers/plans/2026-09-01-cf-commission-redesign.md

@@ -146,7 +146,7 @@ try {
 
 
 - [ ] **步骤 5:同步 schema.sql**
 - [ ] **步骤 5:同步 schema.sql**
 
 
-在 `schema.sql` 末尾追加三张新表的 `CREATE TABLE IF NOT EXISTS`(与任务 1 步骤 2/3 SQL 一致);修改 `promotion_tier` 的 CREATE TABLE 去掉 `team_size_1st/2nd/3rd` 并加 `rate_percent`;`coupon` 的 CREATE TABLE 加 `family_id` 列。保持 schema.sql 为完整快照。
+在 `schema.sql` 末尾追加两张新表(`cf_rate_tier`、`cf_transfer_record`)的 `CREATE TABLE IF NOT EXISTS`(与任务 1 步骤 2/3 SQL 一致);修改 `promotion_tier` 的 CREATE TABLE 去掉 `team_size_1st/2nd/3rd` 并加 `rate_percent`;`coupon` 的 CREATE TABLE 加 `family_id` 列。保持 schema.sql 为完整快照。
 
 
 - [ ] **步骤 6:编译验证**
 - [ ] **步骤 6:编译验证**