|
@@ -0,0 +1,337 @@
|
|
|
|
|
+package com.etotem.cfc.service;
|
|
|
|
|
+
|
|
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
|
|
+import com.etotem.cfc.entity.TeamStarSnapshot;
|
|
|
|
|
+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.util.ArrayList;
|
|
|
|
|
+import java.util.Calendar;
|
|
|
|
|
+import java.util.List;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 月度星级简报组件(能力键 {@code team.star.digest},§4.3.1 ③④ + §12-T8)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>规格:{@code docs/superpowers/specs/2026-11-07-referral-team-profit-sharing.md} v0.7</p>
|
|
|
|
|
+ * <ul>
|
|
|
|
|
+ * <li>§4.3.1 ③ 月度星级简报(每月 1 日重评后主动推送):
|
|
|
|
|
+ * 升星=恭喜 + 新星级 + 解锁分佣份数 + 权益;
|
|
|
|
|
+ * 降星=提示 + 原因(滚动窗口滑出)+ 需补多少分回原级;
|
|
|
|
|
+ * 持平=距下一星差多少 + 本月行动建议(复用 §4.3.1 ② 差距→行动换算)。</li>
|
|
|
|
|
+ * <li>§4.3.1 ④ 星级权益可见:星级徽章 / 培训与专家课优先 / 带教与领队资格 / 年度表彰,
|
|
|
|
|
+ * 高星权益只多不少。</li>
|
|
|
|
|
+ * <li>§7.6 数据源 {@code team_star_snapshot}(唯一键 {@code uk_user_period(user_id, period)}):
|
|
|
|
|
+ * 本月快照 vs 上月快照对比得出升/降/持平。</li>
|
|
|
|
|
+ * <li>§12-T8 推送口径:每月 1 日 00:30 重评落快照后触发,复用微信订阅消息模板
|
|
|
|
|
+ * (当前 {@code WechatNotificationChannel} 网关未接入,推送先落服务端日志,网关启用后即可外发)。</li>
|
|
|
|
|
+ * </ul>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>纯契约友好</b>:本组件不依赖 Spring 容器即可实例化;未注入 Mapper / 计算器时,
|
|
|
|
|
+ * 趋势分类与稿件生成回退 §4.3 默认档位,方法仍可安全调用(供契约级单测反射验证)。</p>
|
|
|
|
|
+ */
|
|
|
|
|
+@Service
|
|
|
|
|
+public class StarDigestService {
|
|
|
|
|
+
|
|
|
|
|
+ private static final Logger log = LoggerFactory.getLogger(StarDigestService.class);
|
|
|
|
|
+
|
|
|
|
|
+ /** 最低/最高星级(§4.3 六星=顶流团队长)。 */
|
|
|
|
|
+ public static final int MIN_STAR = 1;
|
|
|
|
|
+ public static final int MAX_STAR = 6;
|
|
|
|
|
+
|
|
|
|
|
+ /** 星级业绩分门槛(§4.3 推荐默认值,index = 星级-1;与 StarScoreCalculator 默认档位一致)。 */
|
|
|
|
|
+ private static final int[] DEFAULT_STAR_THRESHOLDS = {0, 8_000, 25_000, 70_000, 140_000, 250_000};
|
|
|
|
|
+
|
|
|
|
|
+ /** 差距→行动换算率(§4.3.1 ②:直接服务 250 分/次、团队带教 200 分/次、推荐消费 10 分/百元)。 */
|
|
|
|
|
+ private static final int DIRECT_POINTS_PER_SESSION = 250;
|
|
|
|
|
+ private static final int TEAM_POINTS_PER_SESSION = 200;
|
|
|
|
|
+ private static final int REFERRAL_POINTS_PER_100_YUAN = 10;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private TeamStarSnapshotMapper teamStarSnapshotMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private StarScoreCalculator starScoreCalculator;
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 三态分类(§4.3.1 ③) ====================
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 升/降/持平分类:+1=升星 / 0=持平 / -1=降星。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>首期无上月快照({@code previousStarLevel <= 0})按持平处理,不虚报升/降。</p>
|
|
|
|
|
+ */
|
|
|
|
|
+ public int digestTrend(int currentStarLevel, int previousStarLevel) {
|
|
|
|
|
+ if (previousStarLevel <= 0) {
|
|
|
|
|
+ return 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ int current = clampStar(currentStarLevel);
|
|
|
|
|
+ int previous = clampStar(previousStarLevel);
|
|
|
|
|
+ if (current > previous) {
|
|
|
|
|
+ return 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (current < previous) {
|
|
|
|
|
+ return -1;
|
|
|
|
|
+ }
|
|
|
|
|
+ return 0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 三态简报稿件(§4.3.1 ③) ====================
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 升星稿件:恭喜 + 新星级 + 解锁分佣份数(每层按星级拿份数,§4.3 八份制)+ 星级权益。
|
|
|
|
|
+ */
|
|
|
|
|
+ public String upgradeMessage(long userId, int newStarLevel) {
|
|
|
|
|
+ int star = clampStar(newStarLevel);
|
|
|
|
|
+ StringBuilder sb = new StringBuilder(160);
|
|
|
|
|
+ sb.append("恭喜您本月升级为 ").append(star).append(" 星团队长!");
|
|
|
|
|
+ sb.append("自本月起每层分佣可解锁 ").append(star).append(" 份(八份制,最多 6 份)。");
|
|
|
|
|
+ sb.append("星级权益:").append(String.join("、", starBenefits(star))).append("。");
|
|
|
|
|
+ if (star >= MAX_STAR) {
|
|
|
|
|
+ sb.append("您已登顶六星顶流团队长,继续带教团队、稳住服务业绩即可保持顶格权益。");
|
|
|
|
|
+ } else {
|
|
|
|
|
+ sb.append("继续冲刺,向 ").append(star + 1).append(" 星迈进!");
|
|
|
|
|
+ }
|
|
|
|
|
+ return sb.toString();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 降星稿件:提示 + 原因(滚动 12 个月窗口滑出)+ 需补多少分回原级。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param oldStarLevel 上月星级
|
|
|
|
|
+ * @param currentStarLevel 本月星级
|
|
|
|
|
+ * @param currentScore 本月业绩分
|
|
|
|
|
+ */
|
|
|
|
|
+ public String downgradeMessage(long userId, int oldStarLevel, int currentStarLevel, int currentScore) {
|
|
|
|
|
+ int oldStar = clampStar(oldStarLevel);
|
|
|
|
|
+ int currentStar = clampStar(currentStarLevel);
|
|
|
|
|
+ int score = Math.max(0, currentScore);
|
|
|
|
|
+ int gap = Math.max(0, thresholdOfStar(oldStar) - score);
|
|
|
|
|
+ StringBuilder sb = new StringBuilder(200);
|
|
|
|
|
+ sb.append("很遗憾,您本月星级由 ").append(oldStar).append(" 星调整为 ").append(currentStar).append(" 星。");
|
|
|
|
|
+ sb.append("原因:滚动 12 个月统计窗口滑出,早期服务业绩已超出统计期,业绩分随之回落。");
|
|
|
|
|
+ sb.append("当前业绩分 ").append(score).append(" 分,距回到 ").append(oldStar)
|
|
|
|
|
+ .append(" 星还需补 ").append(gap).append(" 分。");
|
|
|
|
|
+ sb.append("本月行动建议:").append(actionAdvice(gap));
|
|
|
|
|
+ return sb.toString();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 持平稿件:距下一星差多少 + 本月行动建议(复用 §4.3.1 ② 差距→行动换算);
|
|
|
|
|
+ * 六星(顶流)已顶格,不再虚构「下一星」差距。
|
|
|
|
|
+ */
|
|
|
|
|
+ public String flatMessage(long userId, int currentStarLevel, int currentScore) {
|
|
|
|
|
+ int star = clampStar(currentStarLevel);
|
|
|
|
|
+ int score = Math.max(0, currentScore);
|
|
|
|
|
+ if (star >= MAX_STAR) {
|
|
|
|
|
+ return "本月星级持平,您已稳居 " + MAX_STAR + " 星顶流团队长(业绩分 " + score
|
|
|
|
|
+ + " 分),已无更高档位,距顶格保持线还差 0 分。本月行动建议:" + actionAdvice(0);
|
|
|
|
|
+ }
|
|
|
|
|
+ int gap = Math.max(0, thresholdOfStar(star + 1) - score);
|
|
|
|
|
+ return "本月星级持平,当前 " + star + " 星(业绩分 " + score + " 分),距下一星还差 "
|
|
|
|
|
+ + gap + " 分。本月行动建议:" + actionAdvice(gap);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 星级权益可见(§4.3.1 ④) ====================
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 该星级可感知权益清单(星级徽章 / 培训与专家课优先 / 带教与领队资格 / 年度表彰),
|
|
|
|
|
+ * 高星权益只多不少。
|
|
|
|
|
+ */
|
|
|
|
|
+ public List<String> starBenefits(int starLevel) {
|
|
|
|
|
+ int star = clampStar(starLevel);
|
|
|
|
|
+ List<String> benefits = new ArrayList<>(6);
|
|
|
|
|
+ benefits.add("星级徽章");
|
|
|
|
|
+ if (star >= 2) {
|
|
|
|
|
+ benefits.add("培训与专家课优先");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (star >= 3) {
|
|
|
|
|
+ benefits.add("带教资格");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (star >= 4) {
|
|
|
|
|
+ benefits.add("领队资格");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (star >= 5) {
|
|
|
|
|
+ benefits.add("年度表彰");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (star >= MAX_STAR) {
|
|
|
|
|
+ benefits.add("顶流团队长专属荣誉");
|
|
|
|
|
+ }
|
|
|
|
|
+ return benefits;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 月度简报入口(§4.3.1 ③ + §12-T8) ====================
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 月度简报正文入口:本月 vs 上月 {@code team_star_snapshot} 对比后按升/降/持平三态出稿。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param userId 成员用户 ID
|
|
|
|
|
+ * @param period 本月周期 {@code YYYYMM};为空时取当前自然月
|
|
|
|
|
+ * @return 月度简报正文(含标题与三态稿件)
|
|
|
|
|
+ */
|
|
|
|
|
+ public String digestMonthly(long userId, String period) {
|
|
|
|
|
+ String p = normalizePeriod(period);
|
|
|
|
|
+ TeamStarSnapshot current = loadSnapshot(userId, p);
|
|
|
|
|
+ TeamStarSnapshot previous = loadPreviousSnapshot(userId, p);
|
|
|
|
|
+
|
|
|
|
|
+ int currentStar;
|
|
|
|
|
+ int currentScore;
|
|
|
|
|
+ if (current != null) {
|
|
|
|
|
+ currentStar = current.getStarLevel() == null ? MIN_STAR : current.getStarLevel();
|
|
|
|
|
+ currentScore = current.getScore() == null ? 0 : current.getScore().intValue();
|
|
|
|
|
+ } else if (starScoreCalculator != null) {
|
|
|
|
|
+ currentScore = starScoreCalculator.totalScore(userId);
|
|
|
|
|
+ currentStar = starScoreCalculator.starForScore(currentScore);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ currentStar = MIN_STAR;
|
|
|
|
|
+ currentScore = 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ int previousStar = previous == null || previous.getStarLevel() == null ? 0 : previous.getStarLevel();
|
|
|
|
|
+
|
|
|
|
|
+ int trend = digestTrend(currentStar, previousStar);
|
|
|
|
|
+ String body;
|
|
|
|
|
+ if (trend > 0) {
|
|
|
|
|
+ body = upgradeMessage(userId, currentStar);
|
|
|
|
|
+ } else if (trend < 0) {
|
|
|
|
|
+ body = downgradeMessage(userId, previousStar, currentStar, currentScore);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ body = flatMessage(userId, currentStar, currentScore);
|
|
|
|
|
+ }
|
|
|
|
|
+ return "【月度星级简报】" + formatPeriodLabel(p) + "\n" + body;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 每月 1 日重评落快照后批量生成并推送月度简报(§12-T8)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>微信订阅消息网关({@code WechatNotificationChannel})当前未接入,先按用户落服务端日志,
|
|
|
|
|
+ * 网关启用后可在本方法内直接外发,无需改动简报生成逻辑。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param year 重评年份
|
|
|
|
|
+ * @param month 重评月份(1-12)
|
|
|
|
|
+ * @return 已生成简报的成员数
|
|
|
|
|
+ */
|
|
|
|
|
+ public int pushMonthlyDigests(int year, int month) {
|
|
|
|
|
+ if (teamStarSnapshotMapper == null) {
|
|
|
|
|
+ return 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ String period = String.format("%04d%02d", year, month);
|
|
|
|
|
+ List<TeamStarSnapshot> snapshots;
|
|
|
|
|
+ try {
|
|
|
|
|
+ snapshots = teamStarSnapshotMapper.selectList(
|
|
|
|
|
+ new LambdaQueryWrapper<TeamStarSnapshot>()
|
|
|
|
|
+ .eq(TeamStarSnapshot::getPeriod, period));
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("月度星级简报批量查询失败(period={}):{}", period, e.getMessage());
|
|
|
|
|
+ return 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (snapshots == null || snapshots.isEmpty()) {
|
|
|
|
|
+ return 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ int pushed = 0;
|
|
|
|
|
+ for (TeamStarSnapshot snapshot : snapshots) {
|
|
|
|
|
+ if (snapshot == null || snapshot.getUserId() == null) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ String digest = digestMonthly(snapshot.getUserId(), period);
|
|
|
|
|
+ // TODO 微信订阅消息网关接入后在此调用 WechatNotificationChannel 外发
|
|
|
|
|
+ log.info("[team.star.digest] 月度星级简报推送 userId={} period={}:{}",
|
|
|
|
|
+ snapshot.getUserId(), period, digest);
|
|
|
|
|
+ pushed++;
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("月度星级简报生成失败(单用户跳过) userId={} period={}:{}",
|
|
|
|
|
+ snapshot.getUserId(), period, e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ log.info("[team.star.digest] 月度星级简报完成 period={} 共 {} 人", period, pushed);
|
|
|
|
|
+ return pushed;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 内部辅助 ====================
|
|
|
|
|
+
|
|
|
|
|
+ private TeamStarSnapshot loadSnapshot(long userId, String period) {
|
|
|
|
|
+ if (teamStarSnapshotMapper == null) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ return teamStarSnapshotMapper.selectOne(
|
|
|
|
|
+ new LambdaQueryWrapper<TeamStarSnapshot>()
|
|
|
|
|
+ .eq(TeamStarSnapshot::getUserId, userId)
|
|
|
|
|
+ .eq(TeamStarSnapshot::getPeriod, period)
|
|
|
|
|
+ .last("LIMIT 1"));
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("本月星级快照查询失败(userId={}, period={}):{}", userId, period, e.getMessage());
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private TeamStarSnapshot loadPreviousSnapshot(long userId, String period) {
|
|
|
|
|
+ if (teamStarSnapshotMapper == null) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ return teamStarSnapshotMapper.selectOne(
|
|
|
|
|
+ new LambdaQueryWrapper<TeamStarSnapshot>()
|
|
|
|
|
+ .eq(TeamStarSnapshot::getUserId, userId)
|
|
|
|
|
+ .lt(TeamStarSnapshot::getPeriod, period)
|
|
|
|
|
+ .orderByDesc(TeamStarSnapshot::getPeriod)
|
|
|
|
|
+ .last("LIMIT 1"));
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("上月星级快照查询失败(userId={}, period={}):{}", userId, period, e.getMessage());
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 星级业绩分门槛:优先读实时计算组件(后台可配),缺失回退 §4.3 默认档位。 */
|
|
|
|
|
+ private int thresholdOfStar(int starLevel) {
|
|
|
|
|
+ int star = clampStar(starLevel);
|
|
|
|
|
+ if (starScoreCalculator != null) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ return starScoreCalculator.thresholdOfStar(star);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("读取星级档位失败,回退 §4.3 默认值:{}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return DEFAULT_STAR_THRESHOLDS[star - 1];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 差距→行动换算(§4.3.1 ②):按「最省力」优先给出三条路径所需动作量。 */
|
|
|
|
|
+ private String actionAdvice(int gap) {
|
|
|
|
|
+ if (gap <= 0) {
|
|
|
|
|
+ return "当前业绩分已达标下一档,保持直接服务与团队带教节奏,同时补齐团队直推,稳固并冲刺更高星级。";
|
|
|
|
|
+ }
|
|
|
|
|
+ int directSessions = (gap + DIRECT_POINTS_PER_SESSION - 1) / DIRECT_POINTS_PER_SESSION;
|
|
|
|
|
+ int teamSessions = (gap + TEAM_POINTS_PER_SESSION - 1) / TEAM_POINTS_PER_SESSION;
|
|
|
|
|
+ int referralYuan = (gap + REFERRAL_POINTS_PER_100_YUAN - 1) / REFERRAL_POINTS_PER_100_YUAN * 100;
|
|
|
|
|
+ return "可选路径(按最省力排序):A 直接服务再 " + directSessions + " 次 1对1 咨询("
|
|
|
|
|
+ + DIRECT_POINTS_PER_SESSION + " 分/次);B 团队带教再 " + teamSessions + " 次("
|
|
|
|
|
+ + TEAM_POINTS_PER_SESSION + " 分/次);C 推荐消费再 ¥" + referralYuan + "("
|
|
|
|
|
+ + REFERRAL_POINTS_PER_100_YUAN + " 分/百元)。";
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private int clampStar(int starLevel) {
|
|
|
|
|
+ return Math.max(MIN_STAR, Math.min(MAX_STAR, starLevel));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String normalizePeriod(String period) {
|
|
|
|
|
+ if (period == null || period.trim().isEmpty()) {
|
|
|
|
|
+ Calendar now = Calendar.getInstance();
|
|
|
|
|
+ return String.format("%04d%02d", now.get(Calendar.YEAR), now.get(Calendar.MONTH) + 1);
|
|
|
|
|
+ }
|
|
|
|
|
+ return period.trim();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String formatPeriodLabel(String period) {
|
|
|
|
|
+ if (period != null && period.length() == 6) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ int year = Integer.parseInt(period.substring(0, 4));
|
|
|
|
|
+ int month = Integer.parseInt(period.substring(4, 6));
|
|
|
|
|
+ return year + "年" + month + "月";
|
|
|
|
|
+ } catch (NumberFormatException ignored) {
|
|
|
|
|
+ // 非法 period 原样展示
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return period == null ? "" : period;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|