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

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

jiapu 2 недель назад
Родитель
Сommit
62004d8c22
31 измененных файлов с 1057 добавлено и 277 удалено
  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) {
             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;
 
 import com.baomidou.mybatisplus.annotation.IdType;
-import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
@@ -20,21 +19,14 @@ public class PromotionTier implements Serializable {
 
     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 totalReferralEarnings;
 
     private Integer totalShareEarnings;
 
+    private Integer ratePercent;
+
     private Date promotedAt;
 
     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.util.Date;
 
+/**
+ * 已废弃,改用 cf_rate_tier
+ */
+@Deprecated
 @Data
 @TableName("promotion_tier_config")
 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.entity.AssessmentOrder;
 import com.etotem.cfc.entity.AssessmentAppointment;
+import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.AssessmentOrderMapper;
 import com.etotem.cfc.mapper.AssessmentAppointmentMapper;
+import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.CommissionService;
 import org.springframework.stereotype.Service;
 
@@ -33,6 +35,12 @@ public class AssessmentOrderService extends ServiceImpl<AssessmentOrderMapper, A
     @Resource
     private CommissionService commissionService;
 
+    @Resource
+    private CfCommissionService cfCommissionService;
+
+    @Resource
+    private UserMapper userMapper;
+
     public AssessmentOrder createOrder(Long familyId, Long userId, Long memberId,
                                        Long guideId, Long packageId,
                                        String guideName, String packageName,
@@ -73,8 +81,11 @@ public class AssessmentOrderService extends ServiceImpl<AssessmentOrderMapper, A
         order.setUpdatedAt(new Date());
         boolean updated = this.updateById(order);
         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;
     }

+ 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
     private PromotionTierConfigMapper configMapper;
 
+    @Resource
+    private CfReferralService cfReferralService;
+
     // ==================== 工具方法 ====================
 
     /**
@@ -129,6 +132,9 @@ public class CommissionService {
         user.setReferrerId(referrer.getId());
         userMapper.updateById(user);
 
+        // 物化推荐树(全层级)
+        try { cfReferralService.materializeReferralTree(userId); } catch (Exception e) { log.warn("推荐树物化失败 userId={}", userId, e); }
+
         // 更新推荐人直接推荐人数
         referrer.setDirectCount(referrer.getDirectCount() != null ? referrer.getDirectCount() + 1 : 1);
         userMapper.updateById(referrer);
@@ -175,6 +181,7 @@ public class CommissionService {
     /**
      * 原有settle方法 - 兼容旧调用
      * 内部委托给settleTwoLevel实现L1+L2佣金
+     * 已废弃(CF值分佣替代)。保留历史数据读取与兼容旧调用方,不再被业务订单调用。
      */
     @Deprecated
     public void settle(Long orderId, String orderType, Long buyerId,
@@ -184,6 +191,7 @@ public class CommissionService {
 
     /**
      * 两级佣金结算(L1+L2)
+     * 已废弃(CF值分佣替代)。保留历史数据读取与兼容旧调用方,不再被业务订单调用。
      * @deprecated 佣金体系已废弃,由 CF 值(平台积分)替代。保留兼容旧调用方。
      * - L1: 买家的直接推荐人获得佣金
      * - 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
     private CommissionService commissionService;
 
+    @Resource
+    private CfCommissionService cfCommissionService;
+
     @Resource
     private PromotionTierService promotionTierService;
 
@@ -171,7 +174,8 @@ public class MemberSubscriptionService {
 
         Family family = familyMapper.selectById(familyId);
         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());
             if (buyer != null && buyer.getReferrerId() != null) {
@@ -252,7 +256,8 @@ public class MemberSubscriptionService {
 
         Family family = familyMapper.selectById(familyId);
         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());
             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
     private CommissionService commissionService;
 
+    @Resource
+    private CfCommissionService cfCommissionService;
+
     @Resource
     private SysConfigService sysConfigService;
 
@@ -780,14 +783,10 @@ public class MembershipService implements MembershipServiceInterface {
                 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={}",
                     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
     private CommissionService commissionService;
 
+    @Resource
+    private CfCommissionService cfCommissionService;
+
     @Resource
     private CouponService couponService;
 
@@ -217,8 +220,8 @@ public class PackagePaymentService implements PackagePaymentServiceInterface {
                     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(幂等)
                 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
     private CommissionService commissionService;
 
+    @Resource
+    private CfCommissionService cfCommissionService;
+
     @Lazy
     @Resource
     private ProductOrderService productOrderService;
@@ -237,8 +240,8 @@ public class PaymentService implements PaymentServiceInterface {
         order.setUpdatedAt(new Date());
         boolean updated = packageOrderMapper.updateById(order) > 0;
         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;
     }

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

@@ -74,6 +74,17 @@ public class PlatformPointsService {
         if (amount <= 0) {
             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);
         b.setTotalEarned(safeInt(b.getTotalEarned()) + 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
     private CommissionService commissionService;
 
+    @Resource
+    private CfCommissionService cfCommissionService;
+
     @Resource
     private PaymentService paymentService;
 
@@ -114,21 +117,9 @@ public class ProductOrderService {
     @Resource
     private SupplySystemMemberMapper supplySystemMemberMapper;
 
-    @Resource
-    private PpointConfigService ppointConfigService;
-
-    @Resource
-    private CommissionDistService commissionDistService;
-
-    @Resource
-    private PlatformPointsService platformPointsService;
-
     @Resource
     private FamilyPlatformPointsService familyPlatformPointsService;
 
-    @Resource
-    private SysConfigService sysConfigService;
-
     @Resource
     private SupplyHierarchyService supplyHierarchyService;
 
@@ -619,32 +610,13 @@ public class ProductOrderService {
         order.setUpdatedAt(new Date());
         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 {
-            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) {
-            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;
 
-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.PromotionTierConfig;
 import com.etotem.cfc.entity.User;
-import com.etotem.cfc.mapper.PromotionTierConfigMapper;
 import com.etotem.cfc.mapper.PromotionTierMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import org.slf4j.Logger;
@@ -27,15 +25,15 @@ public class PromotionTierEvalService {
     @Resource
     private PromotionTierMapper tierMapper;
 
-    @Resource
-    private PromotionTierConfigMapper configMapper;
-
     @Resource
     private PromotionTierService promotionTierService;
 
     @Resource
     private UserMapper userMapper;
 
+    @Resource
+    private CfReferralService cfReferralService;
+
     @Resource
     private WechatService wechatService;
 
@@ -43,45 +41,17 @@ public class PromotionTierEvalService {
     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) {
-        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;
 
 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.PromotionTierChangeLog;
 import com.etotem.cfc.entity.PromotionTierConfig;
 import com.etotem.cfc.mapper.PromotionTierChangeLogMapper;
 import com.etotem.cfc.mapper.PromotionTierConfigMapper;
 import com.etotem.cfc.mapper.PromotionTierMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
@@ -16,6 +19,8 @@ import java.util.List;
 @Service
 public class PromotionTierService {
 
+    private static final Logger log = LoggerFactory.getLogger(PromotionTierService.class);
+
     @Resource
     private PromotionTierMapper tierMapper;
 
@@ -25,6 +30,9 @@ public class PromotionTierService {
     @Resource
     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);
         if (tier == null) {
             tier = new PromotionTier();
             tier.setUserId(userId);
             tier.setTier("R0");
-            tier.setTeamSize1st(0);
-            tier.setTeamSize2nd(0);
-            tier.setTeamSize3rd(0);
             tier.setTotalTeamSize(0);
             tier.setTotalReferralEarnings(0);
             tier.setTotalShareEarnings(0);
@@ -77,22 +82,34 @@ public class PromotionTierService {
             tier.setUpdatedAt(new Date());
             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.setUpdatedAt(new Date());
         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.setUserId(userId);
             tier.setTier(tierCode);
-            tier.setTeamSize1st(0);
-            tier.setTeamSize2nd(0);
-            tier.setTeamSize3rd(0);
             tier.setTotalTeamSize(0);
             tier.setTotalReferralEarnings(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;
 
-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 org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 
 import javax.annotation.Resource;
+import java.util.List;
 
 @Slf4j
 @Component
 public class PromotionTierCheckScheduledTask {
 
     @Resource
-    private PromotionTierEvalService evalService;
+    private PromotionTierService promotionTierService;
+
+    @Resource
+    private PromotionTierMapper promotionTierMapper;
 
     /**
      * 每天 1:00 AM 执行
-     * 遍历所有有推广等级记录的用户,检查是否需要晋级/降级
+     * 遍历所有有推广等级记录的用户,按全层级团队规模刷新等级与返佣比例
      */
     @Scheduled(cron = "0 0 1 * * ?")
     public void checkAndUpdateAllTiers() {
-        log.info("开始执行推广等级定时检查任务");
+        log.info("开始执行推广等级定时刷新任务");
         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) {
-            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,
     discount_rate INT COMMENT '折扣券折扣率(千分比,9000=9折)',
     product_id BIGINT COMMENT '绑定商品ID,NULL=全场通用',
+    family_id BIGINT DEFAULT NULL COMMENT '绑定家庭ID(NULL=不绑定)',
     points_price INT DEFAULT 0 COMMENT '积分兑换价,0=不可积分兑换',
     grant_type VARCHAR(16) COMMENT '发放规则:JOIN/PERIODIC/POPULATION,NULL=不自动发放',
     grant_level_code VARCHAR(32) COMMENT '发放规则对应会员等级',
@@ -2771,12 +2772,10 @@ CREATE TABLE IF NOT EXISTS promotion_tier (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     user_id BIGINT NOT NULL COMMENT '用户ID',
     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_referral_earnings INT DEFAULT 0 COMMENT '累计推荐佣金(分)',
     total_share_earnings INT DEFAULT 0 COMMENT '累计消费返利(分)',
+    rate_percent INT DEFAULT 0 COMMENT '当前返佣比例(%)',
     promoted_at DATETIME DEFAULT NULL COMMENT '最近晋级时间',
     last_change_at DATETIME DEFAULT NULL COMMENT '最后变更时间',
     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)',
     path VARCHAR(255) DEFAULT NULL COMMENT 'materialized path如/1/2/3/',
     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_level (level)
 ) 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 '忽略时间',
     INDEX idx_user_id (user_id)
 ) 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-row">
         <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 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 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>
 
-    <!-- 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-item" v-for="item in list" :key="item.id">
         <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 class="item-desc">{{ getOrderDesc(item) }}</text>
+          <text class="item-desc">{{ item.remark || 'CF值变动' }}</text>
         </view>
         <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>
         </view>
       </view>
       <view class="empty-state" v-if="!loading && list.length === 0">
-        <text>暂无佣金记录</text>
+        <text>暂无 CF 流水记录</text>
       </view>
       <view class="loading-state" v-if="loading">
         <text>加载中...</text>
       </view>
+      <view class="loadmore-state" v-if="!loading && list.length > 0 && !hasMore">
+        <text>没有更多了</text>
+      </view>
     </view>
   </view>
 </template>
 
 <script>
-import { getCommissionList, getCommissionSummary, getCommissionStats } from '../../utils/api.js'
+import { getCfSummary, getCfList } from '../../utils/api.js'
 import { parseDate } from '../../utils/format.js'
 
 export default {
@@ -70,8 +60,7 @@ export default {
       summary: {},
       page: 1,
       hasMore: true,
-      loading: false,
-      currentTab: 'all'
+      loading: false
     }
   },
   onLoad() {
@@ -85,35 +74,20 @@ export default {
     }
   },
   methods: {
-    switchTab(tab) {
-      if (this.currentTab === tab) return
-      this.currentTab = tab
-      this.page = 1
-      this.list = []
-      this.loadList()
-    },
     async loadSummary() {
       try {
-        const res = await getCommissionSummary()
+        var res = await getCfSummary()
         if (res.data) {
           this.summary = res.data
         }
       } catch (e) {
-        console.error('获取佣金总览失败', e)
+        console.error('获取 CF 总览失败', e)
       }
     },
     async loadList() {
       this.loading = true
       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) {
           this.list = this.page === 1 ? res.data.records : this.list.concat(res.data.records)
           this.hasMore = res.data.records.length >= 20
@@ -121,33 +95,39 @@ export default {
           this.hasMore = false
         }
       } catch (e) {
-        console.error('获取佣金明细失败', e)
+        console.error('获取 CF 流水失败', e)
       }
       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) {
       if (!time) return ''
       var d = parseDate(time)
       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.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 {
   background: #fff;
   border-radius: 20rpx;
@@ -258,35 +211,27 @@ export default {
 .item-amount {
   font-size: 30rpx;
   font-weight: bold;
-  color: #F97316;
   display: block;
 }
+.amount-positive {
+  color: #F97316;
+}
+.amount-negative {
+  color: #94A3B8;
+}
 .item-status {
   font-size: 20rpx;
-  padding: 2rpx 10rpx;
-  border-radius: 6rpx;
+  color: #94A3B8;
   display: inline-block;
   margin-top: 4rpx;
 }
-.status-settled {
-  color: #10B981;
-  background: #D1FAE5;
-}
-.status-pending {
-  color: #D97706;
-  background: #FEF3C7;
-}
-.status-cancelled {
-  color: #94A3B8;
-  background: #F1F5F9;
-}
 .item-time {
   font-size: 20rpx;
   color: #94A3B8;
   display: block;
   margin-top: 4rpx;
 }
-.empty-state, .loading-state {
+.empty-state, .loading-state, .loadmore-state {
   text-align: center;
   padding: 60rpx;
   color: #94A3B8;

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

@@ -115,21 +115,21 @@
       </view>
       <view class="earnings-body">
         <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 class="earning-divider"></view>
         <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 class="earning-divider"></view>
         <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>
@@ -206,7 +206,7 @@
 </template>
 
 <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'
 
 export default {
@@ -313,13 +313,13 @@ export default {
         console.error('获取邀请统计失败', e)
       }
       try {
-        var comRes = await getCommissionSummary()
+        var comRes = await getCfSummary()
         if (comRes.data) {
           this.summary = comRes.data
-          this.availableAmount = comRes.data.availableAmount || '0.00'
+          this.availableAmount = comRes.data.available || '0.00'
         }
       } catch (e) {
-        console.error('获取佣金总览失败', e)
+        console.error('获取 CF 总览失败', e)
       }
     },
     async loadFamilyData() {
@@ -414,12 +414,11 @@ export default {
       if (isNaN(num)) return '0.00'
       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)
       if (isNaN(num)) return '¥0.00'
-      // val 已经是元单位(后端返回的 commission summary 是元)
-      return '¥' + Number(num).toFixed(2)
+      return '¥' + (num / 100).toFixed(2)
     },
     // 管理员操作
     toggleMemberAccess(m) {

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

@@ -3,21 +3,21 @@
     <!-- 团队统计 -->
     <view class="stats-card">
       <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-label">直推(L1)</text>
+        <text class="stats-label">团队规模</text>
       </view>
       <view class="stats-divider"></view>
       <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 class="stats-divider"></view>
       <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>
 
@@ -64,7 +64,7 @@
 </template>
 
 <script>
-import { getCommissionTeam, getTeamList } from '../../utils/api.js'
+import { getCfRate, getTeamList } from '../../utils/api.js'
 import { parseDate } from '../../utils/format.js'
 
 export default {
@@ -76,7 +76,7 @@ export default {
       level: 'L1',
       loading: false,
       hasMore: true,
-      teamStats: null
+      cfRate: null
     }
   },
   onLoad() {
@@ -92,9 +92,9 @@ export default {
   methods: {
     async loadTeamStats() {
       try {
-        var res = await getCommissionTeam(1, 1)
+        var res = await getCfRate()
         if (res.data) {
-          this.teamStats = res.data
+          this.cfRate = res.data
         }
       } catch (e) {
         console.error('获取团队统计失败', e)

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

@@ -1829,6 +1829,14 @@ export const getCommissionBalance = () => {
   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) => {
   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` | 文章 | — |
 | `NotificationController` | `/api/notification` | 通知 | — |
 | `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 | 废弃接口 | 替代方案 | 当前状态 | 清理条件 |
@@ -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**
 
-在 `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:编译验证**