|
@@ -1,13 +1,34 @@
|
|
|
package com.etotem.cfc.service;
|
|
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 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}
|
|
* <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>
|
|
* <p><b>业绩分</b>(§4.3 公式,金额单位为元口径):</p>
|
|
|
* <pre>
|
|
* <pre>
|
|
@@ -28,53 +49,102 @@ import org.springframework.stereotype.Service;
|
|
|
* 且 <b>本窗口团队直推 ≥ 1</b>,两条件缺一不可;一星为入团基线,无直推门槛;
|
|
* 且 <b>本窗口团队直推 ≥ 1</b>,两条件缺一不可;一星为入团基线,无直推门槛;
|
|
|
* 降星自动(滚动窗口滑出,不保留历史级别、不固化)。</p>
|
|
* 降星自动(滚动窗口滑出,不保留历史级别、不固化)。</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
|
|
@Service
|
|
|
public class StarScoreCalculator {
|
|
public class StarScoreCalculator {
|
|
|
|
|
|
|
|
|
|
+ private static final Logger log = LoggerFactory.getLogger(StarScoreCalculator.class);
|
|
|
|
|
+
|
|
|
/** 最低/最高星级 */
|
|
/** 最低/最高星级 */
|
|
|
public static final int MIN_STAR = 1;
|
|
public static final int MIN_STAR = 1;
|
|
|
public static final int MAX_STAR = 6;
|
|
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)。 */
|
|
/** 业绩分权重:直接服务 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),团队直推数以「人」为量级(个位数),
|
|
|
|
|
+ * 故 >= 1000 的第二参数视为门槛、<=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)。金额单位为元口径。
|
|
* 业绩分:直接×50% + 团队×40% + 推荐×10%(§4.3)。金额单位为元口径。
|
|
|
*/
|
|
*/
|
|
|
public long calculateScore(long directServiceAmount, long teamServiceAmount, long referralConsumeAmount) {
|
|
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。
|
|
* 星级业绩分门槛(§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) {
|
|
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)}。
|
|
* 业绩分 → 星级档位映射(入团即一星)。等价于 {@link #resolveStarLevel(long)}。
|
|
|
*/
|
|
*/
|
|
|
public int starForScore(long score) {
|
|
public int starForScore(long score) {
|
|
|
|
|
+ int[] tiers = loadStarTiers();
|
|
|
int star = MIN_STAR;
|
|
int star = MIN_STAR;
|
|
|
for (int s = MIN_STAR + 1; s <= MAX_STAR; s++) {
|
|
for (int s = MIN_STAR + 1; s <= MAX_STAR; s++) {
|
|
|
- if (score >= STAR_THRESHOLDS[s]) {
|
|
|
|
|
|
|
+ if (score >= tiers[s - 1]) {
|
|
|
star = s;
|
|
star = s;
|
|
|
} else {
|
|
} else {
|
|
|
break;
|
|
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 差距分(>= 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>判别规则:第二参数为「分」量级(>= {@value #SCORE_THRESHOLD_FLOOR})且第三参数
|
|
|
|
|
+ * 为小整数(<= 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) {
|
|
if (target <= MIN_STAR) {
|
|
|
return true;
|
|
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)
|
|
* @return 重评后星级(1-6)
|
|
|
*/
|
|
*/
|
|
|
public int reEvaluateStar(long score, int directValidNewRefs, int currentStar) {
|
|
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);
|
|
int natural = starForScore(score);
|
|
|
if (natural > current) {
|
|
if (natural > current) {
|
|
|
// 升星需过升级双门槛;不达标维持当前档
|
|
// 升星需过升级双门槛;不达标维持当前档
|
|
|
- return isPromotable(score, directValidNewRefs, natural) ? natural : current;
|
|
|
|
|
|
|
+ return isPromotableByStar(score, directValidNewRefs, natural) ? natural : current;
|
|
|
}
|
|
}
|
|
|
// 降星自动(含持平)
|
|
// 降星自动(含持平)
|
|
|
return natural;
|
|
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 距下一档差距分(<= 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);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|