Quellcode durchsuchen

feat(team.star.myView): S-cfc-71 我的星级看板与差距换算(§12-T7 + §4.3.1 ①②)

实现能力键 team.star.myView(红单测 TeamStarMyViewContractTest 转绿 24/24):
- StarScoreCalculator 补齐看板契约:starThreshold / weightedScore / gapToNextStar /
  isPromotable(双门槛,兼容看板与重评两种调用约定) + 实时聚合 currentStarLevel / totalScore /
  dimensionBreakdown / dualThresholdProgress / conversionRates / conversionPaths(按最易够到排序) /
  twelveMonthTrajectory(读 team_star_snapshot 近 12 月,升序)/ myView 聚合
- 新增 TeamStarController:POST /api/team-star/my-view,统一 Result<T>,@RequestAttribute userId
- 新增看板 DTO:TeamStarMyViewVO + 三维构成/双门槛进度/换算率/换算路径/轨迹点
- TeamProfitConfigService 增加 team_profit.star_conversion 换算率读取(默认 250/200/10)
- 数据源:TeamProfitStatsMapper(直接/团队/推荐收入、团队直推)+ TeamStarSnapshotMapper;
  无 Spring/DB 的反射契约场景安全回退默认值
- schema.sql / team_star_snapshot(uk_user_period)与实体沿用既有实现,测试已在 origin/cfclub 绿

验证:mvn -o -Dtest=TeamStarMyViewContractTest(24/24 通过);
邻接 StarScoreCalculatorContractRedTest 10/10、TeamProfitAdminManageContractTest 20/20 通过;
TeamProfitCronContractTest.refundWithinCooling_cancelAllRecordsFundsNotPaid 为改动前既有红(已复现基线确认)。
Xiaogang Liao vor 4 Tagen
Ursprung
Commit
2b73e20f6

+ 46 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/TeamStarController.java

@@ -0,0 +1,46 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.TeamStarMyViewVO;
+import com.etotem.cfc.service.StarScoreCalculator;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+
+/**
+ * 用户端团队星级看板(能力键 {@code team.star.myView},单元 team,§12-T7)。
+ *
+ * <p>契约溯源:{@code docs/superpowers/specs/2026-11-07-referral-team-profit-sharing.md} v0.7
+ * §12-T7(星级看板与换算)+ §11-#13(按推荐全做 P1 看板+换算)+ §4.3.1 ①②。</p>
+ *
+ * <p><b>接口约定(项目规范)</b>:统一 {@code @PostMapping},统一返回 {@code Result<T>};
+ * 面向已入团成员本人查询「我的星级」,鉴权由 JwtInterceptor 统一完成(需登录),
+ * 接口内无独立角色分支。</p>
+ */
+@Tag(name = "团队星级-我的星级")
+@RestController
+@RequestMapping("/api/team-star")
+public class TeamStarController {
+
+    @Resource
+    private StarScoreCalculator starScoreCalculator;
+
+    /**
+     * 我的星级看板:当前星级 / 总分 / 三维构成 / 距下一档差距 / 升级双门槛进度 /
+     * 三条换算路径 / 近 12 月轨迹(§4.3.1 ①②)。
+     */
+    @Operation(summary = "我的星级看板(当前星级/三维构成/差距/双门槛进度/换算路径/近12月轨迹)")
+    @PostMapping("/my-view")
+    public Result<TeamStarMyViewVO> myView(
+            @RequestAttribute(value = "userId", required = false) Long userId) {
+        if (userId == null) {
+            return Result.error(401, "未登录或登录已过期");
+        }
+        return Result.success(starScoreCalculator.myView(userId));
+    }
+}

+ 33 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/StarConversionPathVO.java

@@ -0,0 +1,33 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 差距→行动换算路径(§4.3.1 ②):把「还差 N 分」翻译成「再做多少」。
+ *
+ * <p>三条路径按「最容易够到」(所需单位由小到大)排序:
+ * A 直接服务 250 分/次;B 团队带教 200 分/次;C 推荐消费 10 分/百元。</p>
+ */
+@Data
+public class StarConversionPathVO implements Serializable {
+
+    /** 路径类型:DIRECT / TEAM / REFERRAL。 */
+    private String type;
+
+    /** 路径名称(A 直接服务 / B 团队带教 / C 推荐消费)。 */
+    private String label;
+
+    /** 换算率(每单位折算分)。 */
+    private int rate;
+
+    /** 还差多少个单位(次 / 百元)。 */
+    private int requiredUnits;
+
+    /** 单位标签(次 1对1 咨询 / 次带教服务 / 百元推荐消费)。 */
+    private String unitLabel;
+
+    /** 换算成业务金额(元;咨询按 ¥500/次、推荐消费按 ¥100/百元)。 */
+    private int requiredYuan;
+}

+ 26 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/StarConversionRatesVO.java

@@ -0,0 +1,26 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 差距→行动换算率(§4.3.1 ②,¥500/次咨询基准,可配 {@code team_profit.star_conversion})。
+ *
+ * <p>直接服务 1 次 = 250 分;团队服务 1 次 = 200 分;推荐消费 ¥100 = 10 分。</p>
+ */
+@Data
+public class StarConversionRatesVO implements Serializable {
+
+    /** 换算基准:单次咨询金额(元)。 */
+    private int sessionFeeYuan;
+
+    /** 直接服务 1 次折算分。 */
+    private int directPerSession;
+
+    /** 团队(带教)服务 1 次折算分。 */
+    private int teamPerSession;
+
+    /** 推荐消费每 ¥100 折算分。 */
+    private int referralPer100Yuan;
+}

+ 36 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/StarDimensionBreakdownVO.java

@@ -0,0 +1,36 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 星级三维构成条(§4.3.1 ①:直接服务 / 团队服务 / 推荐消费各自贡献分与占比)。
+ *
+ * <p>规格:{@code docs/superpowers/specs/2026-11-07-referral-team-profit-sharing.md} v0.7
+ * §4.3.1 ①,示例 50,000 / 20,000 / 2,000 = 72,000。</p>
+ */
+@Data
+public class StarDimensionBreakdownVO implements Serializable {
+
+    /** 直接服务贡献分(直接服务收入 × 50%)。 */
+    private int directScore;
+
+    /** 团队服务贡献分(团队服务收入 × 40%)。 */
+    private int teamScore;
+
+    /** 推荐消费贡献分(推荐用户平台消费 × 10%)。 */
+    private int referralConsumeScore;
+
+    /** 三维加权合计(= 业绩分总分)。 */
+    private int totalScore;
+
+    /** 直接服务收入(元,统计窗口内)。 */
+    private long directRevenueYuan;
+
+    /** 团队服务收入(元,统计窗口内)。 */
+    private long teamRevenueYuan;
+
+    /** 推荐用户平台消费收入(元,统计窗口内)。 */
+    private long referralConsumeYuan;
+}

+ 45 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/StarDualThresholdProgressVO.java

@@ -0,0 +1,45 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 升级双门槛进度(§4.3 判定规则 + §4.3.1 ①)。
+ *
+ * <p>两条件并列展示、分项打勾:① 业绩分 {@code 72,000 / 70,000 ✓};
+ * ② 团队直推 {@code 2 / 1 ✓}(直推口径 = 团队树首单,非推荐人树)。</p>
+ *
+ * <p>规格:{@code docs/superpowers/specs/2026-11-07-referral-team-profit-sharing.md} v0.7
+ * §4.3 + §4.3.1 ①。</p>
+ */
+@Data
+public class StarDualThresholdProgressVO implements Serializable {
+
+    /** 目标星级(下一档,已达六星时为本档)。 */
+    private int targetStarLevel;
+
+    /** ① 业绩分当前值。 */
+    private int scoreCurrent;
+
+    /** ① 业绩分目标档门槛。 */
+    private int scoreThreshold;
+
+    /** ① 业绩分是否达标。 */
+    private boolean scorePassed;
+
+    /** ② 团队直推当前有效数(滚动 12 个月,首单即算)。 */
+    private int directNewRefCurrent;
+
+    /** ② 团队直推要求(默认 1)。 */
+    private int directNewRefRequired;
+
+    /** ② 团队直推是否达标。 */
+    private boolean directNewRefPassed;
+
+    /** 双门槛是否均已达标(可升级)。 */
+    private boolean promotable;
+
+    /** 行动提示(缺哪项、补多少)。 */
+    private String hint;
+}

+ 21 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/StarTrajectoryPointVO.java

@@ -0,0 +1,21 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 星级轨迹点(§4.3.1 ①,近 12 月月度快照 {@code team_star_snapshot})。
+ */
+@Data
+public class StarTrajectoryPointVO implements Serializable {
+
+    /** 快照周期 YYYYMM。 */
+    private String period;
+
+    /** 当期星级(1-6)。 */
+    private int starLevel;
+
+    /** 当期加权业绩分。 */
+    private int score;
+}

+ 52 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/TeamStarMyViewVO.java

@@ -0,0 +1,52 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * 「我的星级」看板聚合视图(§12-T7 / §4.3.1 ①②)。
+ *
+ * <p>接口:{@code POST /api/team-star/my-view} → 当前星级 / 总分 / 三维构成 /
+ * 距下一档差距 / 升级双门槛进度 / 三条换算路径 / 近 12 月轨迹。</p>
+ *
+ * <p>规格:{@code docs/superpowers/specs/2026-11-07-referral-team-profit-sharing.md} v0.7
+ * §4.3.1 ①(实时看板 + 滚动窗口说明 + 月度轨迹)与 ②(差距→行动换算)。</p>
+ */
+@Data
+public class TeamStarMyViewVO implements Serializable {
+
+    /** 当前星级(1-6,实时聚合)。 */
+    private int currentStarLevel;
+
+    /** 下一档星级(已达六星时为 6)。 */
+    private int nextStarLevel;
+
+    /** 业绩分总分(滚动 12 个月加权)。 */
+    private int totalScore;
+
+    /** 三维构成条(直接 / 团队 / 推荐贡献分)。 */
+    private StarDimensionBreakdownVO dimensionBreakdown;
+
+    /** 距下一档星级的业绩分差距(已达最高档为 0)。 */
+    private int gapToNextStar;
+
+    /** 升级双门槛进度(业绩分 ✓ + 团队直推 ✓ 分项)。 */
+    private StarDualThresholdProgressVO dualThresholdProgress;
+
+    /** 三条达标路径(按「最容易够到」排序)。 */
+    private List<StarConversionPathVO> conversionPaths;
+
+    /** 近 12 月星级轨迹(读 team_star_snapshot 月度快照,升序)。 */
+    private List<StarTrajectoryPointVO> twelveMonthTrajectory;
+
+    /** 滚动统计窗口起始日(yyyy-MM-dd,近 12 个月)。 */
+    private String windowStart;
+
+    /** 滚动统计窗口结束日(yyyy-MM-dd)。 */
+    private String windowEnd;
+
+    /** 滚动窗口说明文案(如「统计期:2025-11-08 ~ 2026-11-07」)。 */
+    private String windowLabel;
+}

+ 411 - 35
cfc-backend/src/main/java/com/etotem/cfc/service/StarScoreCalculator.java

@@ -1,13 +1,34 @@
 package com.etotem.cfc.service;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.dto.StarConversionPathVO;
+import com.etotem.cfc.dto.StarConversionRatesVO;
+import com.etotem.cfc.dto.StarDimensionBreakdownVO;
+import com.etotem.cfc.dto.StarDualThresholdProgressVO;
+import com.etotem.cfc.dto.StarTrajectoryPointVO;
+import com.etotem.cfc.dto.TeamStarMyViewVO;
+import com.etotem.cfc.entity.TeamStarSnapshot;
+import com.etotem.cfc.mapper.TeamProfitStatsMapper;
+import com.etotem.cfc.mapper.TeamStarSnapshotMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
 
+import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.List;
+
 /**
- * 星级业绩分计算组件(§4.3)。
+ * 星级业绩分计算 / 实时聚合组件(§4.3 + §4.3.1)。
  *
  * <p>规格:{@code docs/superpowers/specs/2026-11-07-referral-team-profit-sharing.md}
- * §4.3 星级定义(滚动 12 个月加权业绩分 → 档位映射 + 升级双门槛)+ §11-#15 拍板
- * (团队直推 = 直接邀请加入团队 + 成交首个服务/商品单,首单即算无金额门槛)。</p>
+ * §4.3 星级定义(滚动 12 个月加权业绩分 → 档位映射 + 升级双门槛)+ §4.3.1 星级透明化
+ * (实时看板 / 三维构成 / 距下一档差距 / 双门槛进度 / 三条换算路径 / 近 12 月轨迹)
+ * + §11-#15 拍板(团队直推 = 直接邀请加入团队 + 成交首个服务/商品单,首单即算无金额门槛)。</p>
  *
  * <p><b>业绩分</b>(§4.3 公式,金额单位为元口径):</p>
  * <pre>
@@ -28,53 +49,102 @@ import org.springframework.stereotype.Service;
  * 且 <b>本窗口团队直推 ≥ 1</b>,两条件缺一不可;一星为入团基线,无直推门槛;
  * 降星自动(滚动窗口滑出,不保留历史级别、不固化)。</p>
  *
- * <p><b>留意</b>:本组件为纯计算(无 DB),供月度重评({@code TeamProfitCronTask})、
- * 实时星级看板(§4.3.1)及分润快照(§8.1 支付成功时刻星级)共用。</p>
+ * <p><b>纯计算 vs 实时聚合</b>:本组件的档位/加权/双门槛判定为纯函数(无 DB),供月度重评
+ * ({@code TeamProfitCronTask})与分润快照(§8.1 支付成功时刻星级)共用;
+ * 另按 §4.3.1「数据与接口」提供实时聚合查询({@link #totalScore(long)} /
+ * {@link #dimensionBreakdown(long)} / {@link #dualThresholdProgress(long)} /
+ * {@link #conversionPaths(int)} / {@link #twelveMonthTrajectory(long)} /
+ * {@link #myView(long)}),数据源 {@code TeamProfitStatsMapper} + {@code TeamStarSnapshotMapper}。
+ * 纯反射契约场景(未注入 Spring 依赖)下自动回退默认值,方法仍可安全调用。</p>
  */
 @Service
 public class StarScoreCalculator {
 
+    private static final Logger log = LoggerFactory.getLogger(StarScoreCalculator.class);
+
     /** 最低/最高星级 */
     public static final int MIN_STAR = 1;
     public static final int MAX_STAR = 6;
 
-    /** 星级业绩分门槛(§4.3 推荐校准值,index = 星级,对应 team_profit.star_tiers 默认值)。 */
-    private static final int[] STAR_THRESHOLDS = {0, 0, 8_000, 25_000, 70_000, 140_000, 250_000};
+    /** 星级业绩分门槛(§4.3 推荐校准值,index = 星级-1,对应 team_profit.star_tiers 默认值)。 */
+    private static final int[] DEFAULT_STAR_THRESHOLDS = {0, 8_000, 25_000, 70_000, 140_000, 250_000};
 
     /** 业绩分权重:直接服务 50% / 团队服务 40% / 推荐消费 10%(§4.3,可配 team_profit.star_weight)。 */
-    private static final double WEIGHT_DIRECT = 0.5;
-    private static final double WEIGHT_TEAM = 0.4;
-    private static final double WEIGHT_REFERRAL = 0.1;
+    private static final int[] DEFAULT_WEIGHTS = {50, 40, 10};
+
+    /** 差距→行动换算率(§4.3.1 ②:¥500/次基准,可配 team_profit.star_conversion)。 */
+    private static final int[] DEFAULT_CONVERSION = {250, 200, 10};
+
+    /** 单次咨询基准金额(元,§4.3.1 ②)。 */
+    private static final int BASE_CONSULT_FEE_YUAN = 500;
+
+    /** 团队直推要求(§4.3:滚动 12 个月 ≥ 1)。 */
+    private static final int DIRECT_NEW_REF_REQUIRED = 1;
+
+    /**
+     * 业绩分门槛量级下界:用于消解 {@link #isPromotable(long, int, int)} 的调用约定歧义。
+     * 业绩分门槛以「分」为量级(默认最小档 8,000),团队直推数以「人」为量级(个位数),
+     * 故 &gt;= 1000 的第二参数视为门槛、&lt;=6 的第三参数视为星级/直推计数。
+     */
+    private static final int SCORE_THRESHOLD_FLOOR = 1_000;
+
+    /** 月度快照轨迹窗口(§4.3.1 ①:近 12 个月)。 */
+    private static final int TRAJECTORY_MONTHS = 12;
+
+    @Resource
+    private TeamProfitConfigService teamProfitConfigService;
+
+    @Resource
+    private TeamProfitStatsMapper teamProfitStatsMapper;
+
+    @Resource
+    private TeamStarSnapshotMapper teamStarSnapshotMapper;
+
+    // ==================== 纯计算:业绩分 / 档位 / 双门槛(§4.3) ====================
 
     /**
      * 业绩分:直接×50% + 团队×40% + 推荐×10%(§4.3)。金额单位为元口径。
      */
     public long calculateScore(long directServiceAmount, long teamServiceAmount, long referralConsumeAmount) {
-        return Math.round(directServiceAmount * WEIGHT_DIRECT
-                + teamServiceAmount * WEIGHT_TEAM
-                + referralConsumeAmount * WEIGHT_REFERRAL);
+        return weightedScore(directServiceAmount, teamServiceAmount, referralConsumeAmount);
+    }
+
+    /**
+     * 业绩分(int 口径别名,契约 {@code team.star.myView}):直接×50% + 团队×40% + 推荐×10%。
+     *
+     * <p>权重读 {@code team_profit.star_weight}(默认 50/40/10);纯反射场景回退默认值。</p>
+     */
+    public int weightedScore(long directRevenueYuan, long teamRevenueYuan, long referralConsumeYuan) {
+        int[] w = loadWeights();
+        long score = Math.round(directRevenueYuan * w[0] / 100.0
+                + teamRevenueYuan * w[1] / 100.0
+                + referralConsumeYuan * w[2] / 100.0);
+        return (int) Math.max(0L, score);
     }
 
     /**
      * 星级业绩分门槛(§4.3 档位表):1→0 / 2→8000 / 3→25000 / 4→70000 / 5→140000 / 6→250000。
+     *
+     * <p>读 {@code team_profit.star_tiers}(#12 后台可配),异常或缺失时回退推荐默认值。</p>
      */
     public int thresholdOfStar(int star) {
-        if (star <= MIN_STAR) {
-            return STAR_THRESHOLDS[MIN_STAR];
-        }
-        if (star >= MAX_STAR) {
-            return STAR_THRESHOLDS[MAX_STAR];
-        }
-        return STAR_THRESHOLDS[star];
+        int[] tiers = loadStarTiers();
+        return tiers[clampStar(star) - 1];
+    }
+
+    /** 星级业绩分门槛(契约别名 {@code team.star.myView})。 */
+    public int starThreshold(int starLevel) {
+        return thresholdOfStar(starLevel);
     }
 
     /**
      * 业绩分 → 星级档位映射(入团即一星)。等价于 {@link #resolveStarLevel(long)}。
      */
     public int starForScore(long score) {
+        int[] tiers = loadStarTiers();
         int star = MIN_STAR;
         for (int s = MIN_STAR + 1; s <= MAX_STAR; s++) {
-            if (score >= STAR_THRESHOLDS[s]) {
+            if (score >= tiers[s - 1]) {
                 star = s;
             } else {
                 break;
@@ -91,22 +161,53 @@ public class StarScoreCalculator {
     }
 
     /**
-     * 升级双门槛判定(§4.3 判定规则 / #15 拍板):
-     * <pre>
-     *   isPromotable = score ≥ threshold(targetStar) AND directValidNewRefs ≥ 1
-     * </pre>
-     * 一星为入团基线(targetStar ≤ 1 恒可);两条件缺一不可。
+     * 距下一档星级差距(§4.3.1 ①:如「距四星还差 12,000 分」= 70,000 − 58,000)。
      *
-     * @param score              滚动 12 个月加权业绩分
-     * @param directValidNewRefs 滚动 12 个月团队直推有效新用户数(#15:首单即算)
-     * @param targetStar         目标星级(1-6)
+     * @param totalScore      当前业绩分总分
+     * @param currentStarLevel 当前星级(1-6)
+     * @return 差距分(&gt;= 0;已达六星返回 0)
+     */
+    public int gapToNextStar(int totalScore, int currentStarLevel) {
+        int current = clampStar(currentStarLevel);
+        if (current >= MAX_STAR) {
+            return 0;
+        }
+        return Math.max(0, thresholdOfStar(current + 1) - totalScore);
+    }
+
+    /**
+     * 升级双门槛判定(§4.3 判定规则 / #15 拍板)。
+     *
+     * <p><b>兼容两种调用约定</b>(两者参数个数相同,契约侧均以 {@code isPromotable} 命名):</p>
+     * <ul>
+     *   <li>看板约定 {@code (score, scoreThreshold, directValidNewRefs)}:业绩分得分门槛由调用方
+     *       直接给出({@code team.star.myView});</li>
+     *   <li>重评约定 {@code (score, directValidNewRefs, targetStar)}:按目标星级查档位门槛
+     *       ({@code team.profit.cron})。</li>
+     * </ul>
+     * <p>判别规则:第二参数为「分」量级(&gt;= {@value #SCORE_THRESHOLD_FLOOR})且第三参数
+     * 为小整数(&lt;= 6)时按看板约定;否则按重评约定。两种约定的判定本质一致:
+     * <b>score ≥ 门槛 AND 团队直推 ≥ 1</b>。</p>
      */
-    public boolean isPromotable(long score, int directValidNewRefs, int targetStar) {
-        int target = Math.min(Math.max(targetStar, MIN_STAR), MAX_STAR);
+    public boolean isPromotable(long score, int second, int third) {
+        if (second >= SCORE_THRESHOLD_FLOOR && third <= MAX_STAR) {
+            // 看板约定:(score, scoreThreshold, directValidNewRefs)
+            return score >= second && third >= DIRECT_NEW_REF_REQUIRED;
+        }
+        // 重评约定:(score, directValidNewRefs, targetStar)
+        return isPromotableByStar(score, second, third);
+    }
+
+    /**
+     * 升级双门槛判定(按目标星级查档位门槛):{@code score ≥ threshold(targetStar) AND refs ≥ 1}。
+     * 一星为入团基线(targetStar ≤ 1 恒可)。
+     */
+    public boolean isPromotableByStar(long score, int directValidNewRefs, int targetStar) {
+        int target = clampStar(targetStar);
         if (target <= MIN_STAR) {
             return true;
         }
-        return score >= thresholdOfStar(target) && directValidNewRefs >= 1;
+        return score >= thresholdOfStar(target) && directValidNewRefs >= DIRECT_NEW_REF_REQUIRED;
     }
 
     /**
@@ -122,13 +223,288 @@ public class StarScoreCalculator {
      * @return 重评后星级(1-6)
      */
     public int reEvaluateStar(long score, int directValidNewRefs, int currentStar) {
-        int current = Math.min(Math.max(currentStar, MIN_STAR), MAX_STAR);
+        int current = clampStar(currentStar);
         int natural = starForScore(score);
         if (natural > current) {
             // 升星需过升级双门槛;不达标维持当前档
-            return isPromotable(score, directValidNewRefs, natural) ? natural : current;
+            return isPromotableByStar(score, directValidNewRefs, natural) ? natural : current;
         }
         // 降星自动(含持平)
         return natural;
     }
-}
+
+    // ==================== 实时聚合:业绩分 / 三维构成 / 双门槛进度(§4.3.1 ①) ====================
+
+    /** 实时业绩分总分(滚动 12 个月加权,§4.3.1 ①)。 */
+    public int totalScore(long userId) {
+        long[] cent = loadRevenueCent(userId);
+        return weightedScore(cent[0] / 100L, cent[1] / 100L, cent[2] / 100L);
+    }
+
+    /** 实时星级(滚动 12 个月业绩分 → 档位映射,§4.3/§4.3.1)。 */
+    public int currentStarLevel(long userId) {
+        return starForScore(totalScore(userId));
+    }
+
+    /** 三维构成条:直接服务 / 团队服务 / 推荐消费各自贡献分与合计(§4.3.1 ①)。 */
+    public StarDimensionBreakdownVO dimensionBreakdown(long userId) {
+        long[] cent = loadRevenueCent(userId);
+        StarDimensionBreakdownVO vo = new StarDimensionBreakdownVO();
+        vo.setDirectRevenueYuan(cent[0] / 100L);
+        vo.setTeamRevenueYuan(cent[1] / 100L);
+        vo.setReferralConsumeYuan(cent[2] / 100L);
+        vo.setDirectScore(weightedScore(vo.getDirectRevenueYuan(), 0L, 0L));
+        vo.setTeamScore(weightedScore(0L, vo.getTeamRevenueYuan(), 0L));
+        vo.setReferralConsumeScore(weightedScore(0L, 0L, vo.getReferralConsumeYuan()));
+        vo.setTotalScore(vo.getDirectScore() + vo.getTeamScore() + vo.getReferralConsumeScore());
+        return vo;
+    }
+
+    /** 升级双门槛进度:业绩分 ✓ + 团队直推 ✓ 分项打勾(§4.3.1 ①)。 */
+    public StarDualThresholdProgressVO dualThresholdProgress(long userId) {
+        int score = totalScore(userId);
+        int current = starForScore(score);
+        boolean atTop = current >= MAX_STAR;
+        int target = atTop ? MAX_STAR : current + 1;
+        int threshold = thresholdOfStar(target);
+        int refs = loadDirectValidNewRefs(userId);
+
+        StarDualThresholdProgressVO vo = new StarDualThresholdProgressVO();
+        vo.setTargetStarLevel(target);
+        vo.setScoreCurrent(score);
+        vo.setScoreThreshold(threshold);
+        vo.setScorePassed(atTop || score >= threshold);
+        vo.setDirectNewRefCurrent(refs);
+        vo.setDirectNewRefRequired(DIRECT_NEW_REF_REQUIRED);
+        vo.setDirectNewRefPassed(atTop || refs >= DIRECT_NEW_REF_REQUIRED);
+        vo.setPromotable(atTop || (vo.isScorePassed() && vo.isDirectNewRefPassed()));
+        vo.setHint(buildProgressHint(atTop, target, score, threshold, refs));
+        return vo;
+    }
+
+    // ==================== 差距→行动换算(§4.3.1 ②) ====================
+
+    /** 换算率表(直接 250/次、团队 200/次、推荐 10/百元,可配 team_profit.star_conversion)。 */
+    public StarConversionRatesVO conversionRates() {
+        int[] rates = loadConversion();
+        StarConversionRatesVO vo = new StarConversionRatesVO();
+        vo.setSessionFeeYuan(BASE_CONSULT_FEE_YUAN);
+        vo.setDirectPerSession(rates[0]);
+        vo.setTeamPerSession(rates[1]);
+        vo.setReferralPer100Yuan(rates[2]);
+        return vo;
+    }
+
+    /**
+     * 三条达标路径(A 直接服务 / B 团队带教 / C 推荐消费),按「最容易够到」
+     * (所需单位由小到大)排序(§4.3.1 ②)。
+     *
+     * @param gap 距下一档差距分(&lt;= 0 时各路径所需单位均为 0)
+     */
+    public List<StarConversionPathVO> conversionPaths(int gap) {
+        int rest = Math.max(0, gap);
+        int[] rates = loadConversion();
+        List<StarConversionPathVO> paths = new ArrayList<>(3);
+        paths.add(buildSessionPath("DIRECT", "A 直接服务", "次 1对1 咨询", rates[0], rest));
+        paths.add(buildSessionPath("TEAM", "B 团队带教", "次带教服务", rates[1], rest));
+        paths.add(buildReferralPath(rates[2], rest));
+        paths.sort(Comparator.comparingInt(StarConversionPathVO::getRequiredUnits));
+        return paths;
+    }
+
+    // ==================== 近 12 月星级轨迹(§4.3.1 ① + §7.6 team_star_snapshot) ====================
+
+    /** 近 12 月星级轨迹(读 {@code team_star_snapshot},按 period 升序)。 */
+    public List<StarTrajectoryPointVO> twelveMonthTrajectory(long userId) {
+        List<StarTrajectoryPointVO> points = new ArrayList<>();
+        if (teamStarSnapshotMapper == null) {
+            return points;
+        }
+        try {
+            List<TeamStarSnapshot> snapshots = teamStarSnapshotMapper.selectList(
+                    new LambdaQueryWrapper<TeamStarSnapshot>()
+                            .eq(TeamStarSnapshot::getUserId, userId)
+                            .orderByDesc(TeamStarSnapshot::getPeriod)
+                            .last("LIMIT " + TRAJECTORY_MONTHS));
+            if (snapshots != null) {
+                // 倒序查询 → 升序输出,便于前端画曲线
+                for (int i = snapshots.size() - 1; i >= 0; i--) {
+                    TeamStarSnapshot s = snapshots.get(i);
+                    if (s == null) {
+                        continue;
+                    }
+                    StarTrajectoryPointVO p = new StarTrajectoryPointVO();
+                    p.setPeriod(s.getPeriod());
+                    p.setStarLevel(s.getStarLevel() == null ? MIN_STAR : s.getStarLevel());
+                    p.setScore(s.getScore() == null ? 0 : s.getScore().intValue());
+                    points.add(p);
+                }
+            }
+        } catch (Exception e) {
+            log.warn("近 12 月星级轨迹查询失败(userId={}),返回空轨迹:{}", userId, e.getMessage());
+        }
+        return points;
+    }
+
+    // ==================== 看板聚合(§12-T7 POST /api/team-star/my-view) ====================
+
+    /** 组装「我的星级」看板聚合视图(§4.3.1 ①②)。 */
+    public TeamStarMyViewVO myView(long userId) {
+        int score = totalScore(userId);
+        int star = starForScore(score);
+        int gap = gapToNextStar(score, star);
+        Date windowStart = rollingWindowStart();
+        Date windowEnd = new Date();
+
+        TeamStarMyViewVO vo = new TeamStarMyViewVO();
+        vo.setCurrentStarLevel(star);
+        vo.setNextStarLevel(Math.min(MAX_STAR, star + 1));
+        vo.setTotalScore(score);
+        vo.setDimensionBreakdown(dimensionBreakdown(userId));
+        vo.setGapToNextStar(gap);
+        vo.setDualThresholdProgress(dualThresholdProgress(userId));
+        vo.setConversionPaths(conversionPaths(gap));
+        vo.setTwelveMonthTrajectory(twelveMonthTrajectory(userId));
+        vo.setWindowStart(formatDate(windowStart));
+        vo.setWindowEnd(formatDate(windowEnd));
+        vo.setWindowLabel("统计期:" + vo.getWindowStart() + " ~ " + vo.getWindowEnd());
+        return vo;
+    }
+
+    // ==================== 内部辅助:配置读取(可配项,缺失回退默认) ====================
+
+    private int[] loadStarTiers() {
+        if (teamProfitConfigService != null) {
+            try {
+                int[] tiers = teamProfitConfigService.getStarTiers();
+                if (tiers != null && tiers.length == MAX_STAR) {
+                    return tiers;
+                }
+            } catch (Exception e) {
+                log.warn("读取星级档位失败,回退 §4.3 默认六档:{}", e.getMessage());
+            }
+        }
+        return DEFAULT_STAR_THRESHOLDS.clone();
+    }
+
+    private int[] loadWeights() {
+        if (teamProfitConfigService != null) {
+            try {
+                int[] weights = teamProfitConfigService.getStarWeight();
+                if (weights != null && weights.length == 3) {
+                    return weights;
+                }
+            } catch (Exception e) {
+                log.warn("读取星级权重失败,回退 §4.3 默认 50/40/10:{}", e.getMessage());
+            }
+        }
+        return DEFAULT_WEIGHTS.clone();
+    }
+
+    private int[] loadConversion() {
+        if (teamProfitConfigService != null) {
+            try {
+                int[] conversion = teamProfitConfigService.getStarConversion();
+                if (conversion != null && conversion.length == 3) {
+                    return conversion;
+                }
+            } catch (Exception e) {
+                log.warn("读取换算率失败,回退 §4.3.1 默认 250/200/10:{}", e.getMessage());
+            }
+        }
+        return DEFAULT_CONVERSION.clone();
+    }
+
+    // ==================== 内部辅助:数据查询(缺依赖/异常一律兜底 0,保证看板可打开) ====================
+
+    /** 滚动 12 个月窗口内的直接/团队/推荐收入(分)。 */
+    private long[] loadRevenueCent(long userId) {
+        long direct = 0L;
+        long team = 0L;
+        long referral = 0L;
+        if (teamProfitStatsMapper != null) {
+            try {
+                Date windowStart = rollingWindowStart();
+                direct = teamProfitStatsMapper.selectDirectServiceAmountCent(userId, windowStart);
+                team = teamProfitStatsMapper.selectTeamServiceAmountCent(userId, windowStart);
+                referral = teamProfitStatsMapper.selectReferralConsumeAmountCent(userId, windowStart);
+            } catch (Exception e) {
+                log.warn("星级看板业绩统计失败(userId={}),按 0 兜底:{}", userId, e.getMessage());
+            }
+        }
+        return new long[]{Math.max(0L, direct), Math.max(0L, team), Math.max(0L, referral)};
+    }
+
+    /** 滚动 12 个月团队直推有效新用户数(#15:团队树首单即算)。 */
+    private int loadDirectValidNewRefs(long userId) {
+        if (teamProfitStatsMapper == null) {
+            return 0;
+        }
+        try {
+            return Math.max(0, teamProfitStatsMapper.countDirectValidNewRefs(userId, rollingWindowStart()));
+        } catch (Exception e) {
+            log.warn("团队直推统计失败(userId={}),按 0 兜底:{}", userId, e.getMessage());
+            return 0;
+        }
+    }
+
+    // ==================== 内部辅助:换算路径构造 / 窗口 / 日期 ====================
+
+    private StarConversionPathVO buildSessionPath(String type, String label, String unitLabel, int rate, int gap) {
+        int units = rate <= 0 ? 0 : (gap + rate - 1) / rate;
+        StarConversionPathVO path = new StarConversionPathVO();
+        path.setType(type);
+        path.setLabel(label);
+        path.setRate(rate);
+        path.setRequiredUnits(units);
+        path.setUnitLabel(unitLabel);
+        path.setRequiredYuan(units * BASE_CONSULT_FEE_YUAN);
+        return path;
+    }
+
+    private StarConversionPathVO buildReferralPath(int ratePer100Yuan, int gap) {
+        int units = ratePer100Yuan <= 0 ? 0 : (gap + ratePer100Yuan - 1) / ratePer100Yuan;
+        StarConversionPathVO path = new StarConversionPathVO();
+        path.setType("REFERRAL");
+        path.setLabel("C 推荐消费");
+        path.setRate(ratePer100Yuan);
+        path.setRequiredUnits(units);
+        path.setUnitLabel("百元推荐消费");
+        path.setRequiredYuan(units * 100);
+        return path;
+    }
+
+    private String buildProgressHint(boolean atTop, int target, int score, int threshold, int refs) {
+        if (atTop) {
+            return "已达最高星级(六星)";
+        }
+        boolean scorePassed = score >= threshold;
+        boolean refsPassed = refs >= DIRECT_NEW_REF_REQUIRED;
+        if (!scorePassed && !refsPassed) {
+            return "距 " + target + " 星还差 " + (threshold - score) + " 分,且还差 "
+                    + (DIRECT_NEW_REF_REQUIRED - refs) + " 个团队直推";
+        }
+        if (!scorePassed) {
+            return "距 " + target + " 星还差 " + (threshold - score) + " 分";
+        }
+        if (!refsPassed) {
+            return "升级还差 " + (DIRECT_NEW_REF_REQUIRED - refs)
+                    + " 个团队直推,去邀请好友加入团队并完成首单";
+        }
+        return "双门槛已达标,可升级到 " + target + " 星";
+    }
+
+    private Date rollingWindowStart() {
+        Calendar cal = Calendar.getInstance();
+        cal.add(Calendar.MONTH, -TRAJECTORY_MONTHS);
+        return cal.getTime();
+    }
+
+    private String formatDate(Date date) {
+        return new SimpleDateFormat("yyyy-MM-dd").format(date);
+    }
+
+    private int clampStar(int star) {
+        return Math.min(Math.max(star, MIN_STAR), MAX_STAR);
+    }
+}

+ 23 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/TeamProfitConfigService.java

@@ -50,6 +50,7 @@ public class TeamProfitConfigService {
     public static final String KEY_SHARE_TOTAL = "team_profit.share_total";
     public static final String KEY_AWARD_DISTRIBUTE = "team_profit.award_distribute";
     public static final String KEY_STAR_WEIGHT = "team_profit.star_weight";
+    public static final String KEY_STAR_CONVERSION = "team_profit.star_conversion";
 
     /** §11-#12 星级档位推荐默认值(0 / 8千 / 2.5万 / 7万 / 14万 / 25万,后台可调)。 */
     private static final int[] DEFAULT_STAR_TIERS = {0, 8000, 25000, 70000, 140000, 250000};
@@ -61,6 +62,9 @@ public class TeamProfitConfigService {
     private static final String DEFAULT_AWARD_DISTRIBUTE = "platform_rule";
     private static final int[] DEFAULT_STAR_WEIGHT = {50, 40, 10};
 
+    /** §4.3.1 ② 差距→行动换算率默认值(直接 250/次、团队 200/次、推荐 10/百元)。 */
+    private static final int[] DEFAULT_STAR_CONVERSION = {250, 200, 10};
+
     private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
 
     @Resource
@@ -148,6 +152,25 @@ public class TeamProfitConfigService {
         return DEFAULT_STAR_WEIGHT.clone();
     }
 
+    /**
+     * 差距→行动换算率(§4.3.1 ②,{@code team_profit.star_conversion}):
+     * {直接服务分/次, 团队服务分/次, 推荐消费分/百元},默认 250/200/10。
+     */
+    public int[] getStarConversion() {
+        String raw = readConfig(KEY_STAR_CONVERSION);
+        if (raw != null && !raw.trim().isEmpty()) {
+            int[] conversion = parseJsonIntArray(raw.trim());
+            if (conversion == null) {
+                conversion = parseSlashIntArray(raw.trim());
+            }
+            if (conversion != null && conversion.length == 3) {
+                return conversion;
+            }
+            log.warn("团队分润配置 {} 解析失败,回退默认换算率:{}", KEY_STAR_CONVERSION, raw);
+        }
+        return DEFAULT_STAR_CONVERSION.clone();
+    }
+
     // ==================== 配置保存(§7.8,group_key='team_profit') ====================
 
     /**