Ver código fonte

feat(S-cfc-70): 实现 team.star.digest 月度星级简报(升/降/持平 + 行动建议 + 权益)

按规格 v0.7 §4.3.1 ③④ / §12-T8 落地 StarDigestService:digestTrend 三态分类(首期无上月快照按持平)、upgradeMessage(恭喜+新星级+解锁份数+权益)、downgradeMessage(滚动窗口滑出原因+补分回原级)、flatMessage(距下一星差距+行动建议,六星顶格不虚构)、starBenefits(星级徽章/培训优先/带教/领队/年度表彰,只多不少)、digestMonthly(team_star_snapshot 本月 vs 上月对比)、pushMonthlyDigests(每月1日重评后批量生成推送,微信网关未接入先落日志)。TeamProfitCronTask 在 reevaluateMonthlyStars 落快照后触发简报。
Xiaogang Liao 4 dias atrás
pai
commit
786c88e02

+ 337 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/StarDigestService.java

@@ -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;
+    }
+}

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/task/TeamProfitCronTask.java

@@ -13,6 +13,7 @@ import com.etotem.cfc.mapper.TeamAwardRecordMapper;
 import com.etotem.cfc.mapper.TeamProfitStatsMapper;
 import com.etotem.cfc.mapper.TeamShareRecordMapper;
 import com.etotem.cfc.mapper.TeamStarSnapshotMapper;
+import com.etotem.cfc.service.StarDigestService;
 import com.etotem.cfc.service.StarScoreCalculator;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.scheduling.annotation.Scheduled;
@@ -78,6 +79,9 @@ public class TeamProfitCronTask {
     @Resource
     private StarScoreCalculator starScoreCalculator;
 
+    @Resource
+    private StarDigestService starDigestService;
+
     // ==================== §8.2 冷静期常量与判定 ====================
 
     /** 冷静期天数(默认 7,§8.2 与退款窗口对齐)。 */
@@ -276,6 +280,14 @@ public class TeamProfitCronTask {
                 }
             }
             log.info("月度星级重评完成: 共 {} 人", done);
+            // §4.3.1 ③ / §12-T8:重评落快照后立即生成并推送月度星级简报(升/降/持平 + 行动建议)
+            if (starDigestService != null) {
+                try {
+                    starDigestService.pushMonthlyDigests(year, month);
+                } catch (Exception e) {
+                    log.warn("月度星级简报推送失败(不影响重评结果): {}", e.getMessage());
+                }
+            }
         } catch (Exception e) {
             log.error("月度星级重评批次执行失败", e);
         }