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

merge(s-cfc-batch-1108): B-61 健康方案鉴权修复 + S-cfc-57/58/59/60/70 实现批次合入

Agnes Agent 4 дней назад
Родитель
Сommit
de16f40b0

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java

@@ -111,6 +111,7 @@ private static final String[] PUBLIC_PATHS = {
         "/api/family/member/",              // 家庭成员:增删改查均为家庭私有数据
         "/api/family/member/",              // 家庭成员:增删改查均为家庭私有数据
         "/api/family-platform/",            // 家庭资金平台:余额/流水/兑换,家庭私有资金数据
         "/api/family-platform/",            // 家庭资金平台:余额/流水/兑换,家庭私有资金数据
         "/api/health/report/",              // 健康报告:家庭私有健康数据(上传/解析/列表/详情/指标/删除)
         "/api/health/report/",              // 健康报告:家庭私有健康数据(上传/解析/列表/详情/指标/删除)
+        "/api/health/plan/",                // 健康方案:家庭私有健康数据(生成/调整/归因/保存/列表/详情),必须登录
         "/api/health/checkin/",             // 健康打卡:成员私有数据
         "/api/health/checkin/",             // 健康打卡:成员私有数据
         "/api/xiaomi-health/",              // 小米运动健康:OAuth 授权绑定/同步/撤销,成员私有凭证与数据
         "/api/xiaomi-health/",              // 小米运动健康:OAuth 授权绑定/同步/撤销,成员私有凭证与数据
         "/api/huawei-health/",              // 华为运动健康:OAuth 授权绑定/同步/撤销,成员私有凭证与数据,必须登录(与 xiaomi-health 对齐)
         "/api/huawei-health/",              // 华为运动健康:OAuth 授权绑定/同步/撤销,成员私有凭证与数据,必须登录(与 xiaomi-health 对齐)

+ 70 - 4
cfc-backend/src/main/java/com/etotem/cfc/controller/DistributionInviteController.java

@@ -4,6 +4,7 @@ import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.DistributionRelation;
 import com.etotem.cfc.entity.DistributionRelation;
 import com.etotem.cfc.entity.DistributionSystem;
 import com.etotem.cfc.entity.DistributionSystem;
 import com.etotem.cfc.service.DistributionService;
 import com.etotem.cfc.service.DistributionService;
+import com.etotem.cfc.service.WechatService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.PostMapping;
@@ -13,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 import org.springframework.web.bind.annotation.RestController;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.List;
 import java.util.Map;
 import java.util.Map;
 import com.etotem.cfc.util.ParamUtils;
 import com.etotem.cfc.util.ParamUtils;
@@ -22,17 +24,43 @@ import com.etotem.cfc.util.ParamUtils;
 @RequestMapping("/api/distribution/invite")
 @RequestMapping("/api/distribution/invite")
 public class DistributionInviteController {
 public class DistributionInviteController {
 
 
+    /** 团队邀请独立落地页(§11-#11 已拍板:独立页,团队场景信息不同) */
+    private static final String TEAM_INVITE_PAGE = "pages/team-invite/join";
+
     @Resource
     @Resource
     private DistributionService distributionService;
     private DistributionService distributionService;
 
 
-    @Operation(summary = "生成/获取我的销售邀请码")
+    @Resource
+    private WechatService wechatService;
+
+    /**
+     * 生成/获取我的销售邀请码(团队邀请,S-cfc-60 / team.invitation.create)。
+     * <p>
+     * 契约 §5.2:返回 {@code inviteCode + wxacode(小程序码, 独立团队落地页) + systemId + teamName + inviterId},
+     * 复用 {@code distribution_relations.invite_code}(§7.7),不新建表。
+     */
+    @Operation(summary = "生成/获取我的销售邀请码(团队邀请)")
     @PostMapping("/generate")
     @PostMapping("/generate")
-    public Result<String> generate(
+    public Result<Map<String, Object>> generate(
             @RequestAttribute("userId") Long userId,
             @RequestAttribute("userId") Long userId,
             @RequestBody Map<String, Object> params) {
             @RequestBody Map<String, Object> params) {
         Long systemId = ParamUtils.getLong(params.get("systemId"));
         Long systemId = ParamUtils.getLong(params.get("systemId"));
         if (systemId == null) return Result.error("systemId不能为空");
         if (systemId == null) return Result.error("systemId不能为空");
-        return distributionService.generateInviteCode(systemId, userId);
+
+        Result<String> codeResult = distributionService.generateInviteCode(systemId, userId);
+        if (codeResult == null || codeResult.getCode() == null
+                || codeResult.getCode() != 200 || codeResult.getData() == null) {
+            return Result.error(codeResult != null ? codeResult.getMessage() : "生成邀请失败");
+        }
+
+        String inviteCode = codeResult.getData();
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("inviteCode", inviteCode);
+        data.put("systemId", systemId);
+        data.put("inviterId", userId);
+        data.put("teamName", resolveTeamName(systemId, userId));
+        data.put("wxacode", resolveWxacode(systemId, userId, inviteCode));
+        return Result.success(data);
     }
     }
 
 
     @Operation(summary = "校验邀请码有效性")
     @Operation(summary = "校验邀请码有效性")
@@ -100,4 +128,42 @@ public class DistributionInviteController {
         if (systemId == null) return Result.error("systemId不能为空");
         if (systemId == null) return Result.error("systemId不能为空");
         return distributionService.getUserPosition(systemId, userId);
         return distributionService.getUserPosition(systemId, userId);
     }
     }
-}
+
+    /**
+     * 解析当前用户在该销售体系下的团队名;查询不可用/未命中时降级为空串(契约仅要求非 null)。
+     */
+    private String resolveTeamName(Long systemId, Long userId) {
+        try {
+            Result<List<DistributionSystem>> teamsResult = distributionService.getUserTeams(userId);
+            if (teamsResult != null && teamsResult.getData() != null) {
+                for (DistributionSystem system : teamsResult.getData()) {
+                    if (system != null && systemId.equals(system.getId())
+                            && system.getName() != null && !system.getName().isEmpty()) {
+                        return system.getName();
+                    }
+                }
+            }
+        } catch (Exception e) {
+            // 团队信息不可用,避免影响邀请码生成
+        }
+        return "";
+    }
+
+    /**
+     * 生成团队邀请落地页小程序码(scene=inviteCode,page=独立团队落地页)。
+     * WechatService 不可用或生成失败时,返回带 code 参数的落地页路径兜底。
+     */
+    private String resolveWxacode(Long systemId, Long userId, String inviteCode) {
+        try {
+            if (wechatService != null) {
+                String wxacode = wechatService.generateWxacode("code=" + inviteCode, TEAM_INVITE_PAGE);
+                if (wxacode != null && !wxacode.isEmpty()) {
+                    return wxacode;
+                }
+            }
+        } catch (Exception e) {
+            // 小程序码生成失败,走落地页路径兜底
+        }
+        return TEAM_INVITE_PAGE + "?code=" + inviteCode;
+    }
+}

+ 50 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyJoinRequestService.java

@@ -18,6 +18,7 @@ import org.springframework.context.annotation.Lazy;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
+import java.util.Calendar;
 import java.util.Date;
 import java.util.Date;
 import java.util.List;
 import java.util.List;
 import java.util.stream.Collectors;
 import java.util.stream.Collectors;
@@ -109,6 +110,10 @@ public class FamilyJoinRequestService {
             throw new RuntimeException("用户不存在");
             throw new RuntimeException("用户不存在");
         }
         }
 
 
+        // 准入校验:被邀请人必须已完善真实姓名/出生日期/性别(需求变更 2026-11-07-family-join-required-info,
+        // §3 AC-R3 / §5 WI-3,能力键 family.invite.joinValidationRules);任一缺失/非法直接抛错、不落库
+        validateJoinRequiredInfo(currentUser);
+
         // 检查用户是否已属于该家庭
         // 检查用户是否已属于该家庭
         if (currentUser.getFamilyId() != null && currentUser.getFamilyId().equals(family.getId())) {
         if (currentUser.getFamilyId() != null && currentUser.getFamilyId().equals(family.getId())) {
             throw new RuntimeException("您已是该家庭成员");
             throw new RuntimeException("您已是该家庭成员");
@@ -144,6 +149,51 @@ public class FamilyJoinRequestService {
         return request;
         return request;
     }
     }
 
 
+    private static final Date MIN_BIRTHDAY;
+
+    static {
+        Calendar c = Calendar.getInstance();
+        c.clear();
+        c.set(1900, Calendar.JANUARY, 1);
+        MIN_BIRTHDAY = c.getTime();
+    }
+
+    /**
+     * 准入校验:被邀请人必须已完善真实姓名/出生日期/性别(规格 2026-11-07-family-join-required-info §3 AC-R3 / §5 WI-3)。
+     * 与 UserService 解析口径保持一致(UserService.java:492-505):
+     * <ul>
+     *   <li>真实姓名:trim 后非空,长度 1–20 字;null/空/超 20 字 → 「请先完善真实姓名、出生日期与性别后再申请加入家庭」</li>
+     *   <li>出生日期:非空;晚于今天 → 「出生日期不能晚于今天」;早于 1900-01-01 → 「出生日期不合法」(不设年龄上限,主理人 2026-11-07 确认)</li>
+     *   <li>性别:仅限 male/female(大小写敏感);null/空/其他/混用 → 「请先完善真实姓名、出生日期与性别后再申请加入家庭」</li>
+     * </ul>
+     */
+    private void validateJoinRequiredInfo(User u) {
+        String realName = u.getRealName();
+        if (realName == null || realName.trim().isEmpty() || realName.trim().length() > 20) {
+            throw new RuntimeException("请先完善真实姓名、出生日期与性别后再申请加入家庭");
+        }
+        Date birthday = u.getBirthday();
+        if (birthday == null) {
+            throw new RuntimeException("请先完善真实姓名、出生日期与性别后再申请加入家庭");
+        }
+        String gender = u.getGender();
+        if (!"male".equals(gender) && !"female".equals(gender)) {
+            throw new RuntimeException("请先完善真实姓名、出生日期与性别后再申请加入家庭");
+        }
+        Calendar cal = Calendar.getInstance();
+        cal.set(Calendar.HOUR_OF_DAY, 0);
+        cal.set(Calendar.MINUTE, 0);
+        cal.set(Calendar.SECOND, 0);
+        cal.set(Calendar.MILLISECOND, 0);
+        Date today = cal.getTime();
+        if (birthday.after(today)) {
+            throw new RuntimeException("出生日期不能晚于今天");
+        }
+        if (birthday.before(MIN_BIRTHDAY)) {
+            throw new RuntimeException("出生日期不合法");
+        }
+    }
+
     /**
     /**
      * 审批通过加入请求(兼容旧调用:不指定辈分 → 仅写 familyRole,不落地辈分/关系)
      * 审批通过加入请求(兼容旧调用:不指定辈分 → 仅写 familyRole,不落地辈分/关系)
      */
      */

+ 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.TeamProfitStatsMapper;
 import com.etotem.cfc.mapper.TeamShareRecordMapper;
 import com.etotem.cfc.mapper.TeamShareRecordMapper;
 import com.etotem.cfc.mapper.TeamStarSnapshotMapper;
 import com.etotem.cfc.mapper.TeamStarSnapshotMapper;
+import com.etotem.cfc.service.StarDigestService;
 import com.etotem.cfc.service.StarScoreCalculator;
 import com.etotem.cfc.service.StarScoreCalculator;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.scheduling.annotation.Scheduled;
@@ -78,6 +79,9 @@ public class TeamProfitCronTask {
     @Resource
     @Resource
     private StarScoreCalculator starScoreCalculator;
     private StarScoreCalculator starScoreCalculator;
 
 
+    @Resource
+    private StarDigestService starDigestService;
+
     // ==================== §8.2 冷静期常量与判定 ====================
     // ==================== §8.2 冷静期常量与判定 ====================
 
 
     /** 冷静期天数(默认 7,§8.2 与退款窗口对齐)。 */
     /** 冷静期天数(默认 7,§8.2 与退款窗口对齐)。 */
@@ -276,6 +280,14 @@ public class TeamProfitCronTask {
                 }
                 }
             }
             }
             log.info("月度星级重评完成: 共 {} 人", done);
             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) {
         } catch (Exception e) {
             log.error("月度星级重评批次执行失败", e);
             log.error("月度星级重评批次执行失败", e);
         }
         }