Переглянути джерело

feat(team.profit.cron): S-cfc-43 §8 定时结算与重评 GREEN - 冷静期7天自动settled入余额 + 月度星级重评升级双门槛落快照 + 退款冲正(§9#7) + StarScoreCalculator 档位/重评 + 迁移461 team_star_snapshot;修正契约测试档位边界笔误(139999=4★/140000=5★)

E2E Test Bot 5 днів тому
батько
коміт
0bf1815cf

+ 29 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3481,6 +3481,8 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         createTeamShareRecordTable();
         // 迁移460: 团队奖流水表(§7.4,uk_order_team 幂等)
         createTeamAwardRecordTable();
+        // 迁移461: 团队成员星级月度快照表(§7.6,uk_user_period 幂等)
+        createTeamStarSnapshotTable();
     }
 
     /**
@@ -3574,6 +3576,33 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         }
     }
 
+    /**
+     * 迁移461: 创建团队成员星级月度快照表(§7.6)。
+     *
+     * <p>月度重评落快照(§4.3 重评周期:每月 1 日 00:30),{@code uk_user_period(user_id, period)}
+     * 唯一键保证同一周期幂等(重复重评不重复落行)。</p>
+     */
+    private void createTeamStarSnapshotTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS team_star_snapshot ("
+                    + "id BIGINT AUTO_INCREMENT PRIMARY KEY,"
+                    + "user_id BIGINT NOT NULL COMMENT '成员用户ID',"
+                    + "team_id BIGINT NULL COMMENT '所属服务团队根节点',"
+                    + "star_level INT NOT NULL COMMENT '星级(1-6)',"
+                    + "score INT NOT NULL COMMENT '加权业绩分',"
+                    + "direct_rev_cent INT NULL COMMENT '直接服务收入(分)',"
+                    + "team_rev_cent INT NULL COMMENT '团队服务收入(分)',"
+                    + "referral_consume_cent INT NULL COMMENT '推荐用户平台消费(分)',"
+                    + "period VARCHAR(16) NOT NULL COMMENT 'YYYYMM',"
+                    + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',"
+                    + "UNIQUE KEY uk_user_period (user_id, period)"
+                    + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团队成员星级月度快照'");
+            log.info("迁移461: team_star_snapshot 表已就绪");
+        } catch (Exception e) {
+            log.warn("迁移461: team_star_snapshot 创建失败: {}", e.getMessage());
+        }
+    }
+
     /**
      * 迁移450: 会员权益文案与实际成交口径对齐(DML,幂等)。
      *

+ 48 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/TeamStarSnapshot.java

@@ -0,0 +1,48 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 团队成员星级月度快照(§7.6 team_star_snapshot)。
+ *
+ * <p>规格:{@code docs/superpowers/specs/2026-11-07-referral-team-profit-sharing.md}
+ * §4.3 重评机制 + §7.6;唯一键 {@code uk_user_period(user_id, period)}。</p>
+ */
+@Data
+@TableName("team_star_snapshot")
+public class TeamStarSnapshot implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long userId;
+
+    /** 所属服务团队根节点 */
+    private Long teamId;
+
+    /** 1-6 */
+    private Integer starLevel;
+
+    /** 加权业绩分 */
+    private Long score;
+
+    /** 直接服务收入(分) */
+    private Long directRevCent;
+
+    /** 团队服务收入(分) */
+    private Long teamRevCent;
+
+    /** 推荐用户平台消费收入(分) */
+    private Long referralConsumeCent;
+
+    /** YYYYMM */
+    private String period;
+
+    private Date createdAt;
+}

+ 64 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/TeamProfitStatsMapper.java

@@ -0,0 +1,64 @@
+package com.etotem.cfc.mapper;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.Date;
+
+/**
+ * 团队星级重评聚合统计(§4.3 业绩分数据源)。
+ *
+ * <p>规格:{@code docs/superpowers/specs/2026-11-07-referral-team-profit-sharing.md}
+ * §4.3 星级 = 直接×50% + 团队×40% + 推荐×10% + §4.3/#15 团队直推口径
+ * (直接邀请加入团队 + 成交首个服务或商品单即算 1 个直推)。</p>
+ */
+@Mapper
+public interface TeamProfitStatsMapper {
+
+    /**
+     * 直接服务收入(分):本人作为服务提供者(规划师/测评师)滚动窗口内已结算服务费。
+     */
+    @Select("SELECT COALESCE(SUM(ss.service_fee), 0) FROM service_settlements ss "
+            + "WHERE ss.provider_id = #{userId} AND ss.status = 'settled' "
+            + "AND ss.created_at >= #{windowStart}")
+    long selectDirectServiceAmountCent(@Param("userId") long userId, @Param("windowStart") Date windowStart);
+
+    /**
+     * 团队服务收入(分):本人团队(distribution_relations 子树)滚动窗口内已结算服务费,
+     * 且该服务订单实际发放了 F03a 直接销售奖(§4.3,防空转)。
+     */
+    @Select("WITH RECURSIVE team_subtree AS ("
+            + "  SELECT user_id FROM distribution_relations WHERE user_id = #{userId}"
+            + "  UNION ALL"
+            + "  SELECT r.user_id FROM distribution_relations r"
+            + "    INNER JOIN team_subtree s ON r.parent_id = s.user_id"
+            + ") "
+            + "SELECT COALESCE(SUM(ss.service_fee), 0) FROM service_settlements ss "
+            + "WHERE ss.provider_id IN (SELECT user_id FROM team_subtree) "
+            + "AND ss.status = 'settled' AND ss.created_at >= #{windowStart} "
+            + "AND EXISTS (SELECT 1 FROM cf_balance_log bl "
+            + "            WHERE bl.ref_type = 'F03a' AND bl.ref_id = ss.assessment_order_id)")
+    long selectTeamServiceAmountCent(@Param("userId") long userId, @Param("windowStart") Date windowStart);
+
+    /**
+     * 推荐用户平台消费收入(分):被本人直接/间接推荐的用户滚动窗口内平台消费额(售价口径)。
+     */
+    @Select("SELECT COALESCE(SUM(l.amount_cent), 0) FROM ppoint_consumption_log l "
+            + "INNER JOIN referral_tree t ON t.child_id = l.user_id "
+            + "WHERE t.parent_id = #{userId} AND l.created_at >= #{windowStart}")
+    long selectReferralConsumeAmountCent(@Param("userId") long userId, @Param("windowStart") Date windowStart);
+
+    /**
+     * 团队直推有效新用户数(#15 拍板口径):本人直接邀请加入团队(distribution_relations
+     * parent_id=本人)且近 12 个月成交首个服务或商品单的成员数(首单即算、无金额门槛)。
+     */
+    @Select("SELECT COUNT(DISTINCT r.user_id) FROM distribution_relations r "
+            + "WHERE r.parent_id = #{userId} AND ("
+            + "  EXISTS (SELECT 1 FROM product_orders po WHERE po.buyer_id = r.user_id "
+            + "          AND po.status IN ('paid','completed') AND po.paid_at >= #{windowStart})"
+            + "  OR EXISTS (SELECT 1 FROM service_settlements ss WHERE ss.provider_id = r.user_id "
+            + "          AND ss.status = 'settled' AND ss.created_at >= #{windowStart})"
+            + ")")
+    int countDirectValidNewRefs(@Param("userId") long userId, @Param("windowStart") Date windowStart);
+}

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/TeamStarSnapshotMapper.java

@@ -0,0 +1,12 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.TeamStarSnapshot;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 团队成员星级月度快照(§7.6)。
+ */
+@Mapper
+public interface TeamStarSnapshotMapper extends BaseMapper<TeamStarSnapshot> {
+}

+ 134 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/StarScoreCalculator.java

@@ -0,0 +1,134 @@
+package com.etotem.cfc.service;
+
+import org.springframework.stereotype.Service;
+
+/**
+ * 星级业绩分计算组件(§4.3)。
+ *
+ * <p>规格:{@code docs/superpowers/specs/2026-11-07-referral-team-profit-sharing.md}
+ * §4.3 星级定义(滚动 12 个月加权业绩分 → 档位映射 + 升级双门槛)+ §11-#15 拍板
+ * (团队直推 = 直接邀请加入团队 + 成交首个服务/商品单,首单即算无金额门槛)。</p>
+ *
+ * <p><b>业绩分</b>(§4.3 公式,金额单位为元口径):</p>
+ * <pre>
+ *   Score = 直接服务收入 × 50% + 团队服务收入 × 40% + 推荐用户平台消费收入 × 10%
+ * </pre>
+ *
+ * <p><b>星级↔档位映射</b>(推荐默认值,对应可配 {@code team_profit.star_tiers},见 §7.8):</p>
+ * <pre>
+ *   score ≥ 0       → 一星(入团即一星)  拿 1 份
+ *   score ≥ 8,000   → 二星                拿 2 份
+ *   score ≥ 25,000  → 三星                拿 3 份
+ *   score ≥ 70,000  → 四星                拿 4 份
+ *   score ≥ 140,000 → 五星                拿 5 份
+ *   score ≥ 250,000 → 六星                拿 6 份(最多)
+ * </pre>
+ *
+ * <p><b>升级双门槛</b>(主理人意见⑥ / #15 拍板):升级条件 = <b>业绩分 ≥ 目标档门槛</b>
+ * 且 <b>本窗口团队直推 ≥ 1</b>,两条件缺一不可;一星为入团基线,无直推门槛;
+ * 降星自动(滚动窗口滑出,不保留历史级别、不固化)。</p>
+ *
+ * <p><b>留意</b>:本组件为纯计算(无 DB),供月度重评({@code TeamProfitCronTask})、
+ * 实时星级看板(§4.3.1)及分润快照(§8.1 支付成功时刻星级)共用。</p>
+ */
+@Service
+public class StarScoreCalculator {
+
+    /** 最低/最高星级 */
+    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};
+
+    /** 业绩分权重:直接服务 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;
+
+    /**
+     * 业绩分:直接×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);
+    }
+
+    /**
+     * 星级业绩分门槛(§4.3 档位表):1→0 / 2→8000 / 3→25000 / 4→70000 / 5→140000 / 6→250000。
+     */
+    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];
+    }
+
+    /**
+     * 业绩分 → 星级档位映射(入团即一星)。等价于 {@link #resolveStarLevel(long)}。
+     */
+    public int starForScore(long score) {
+        int star = MIN_STAR;
+        for (int s = MIN_STAR + 1; s <= MAX_STAR; s++) {
+            if (score >= STAR_THRESHOLDS[s]) {
+                star = s;
+            } else {
+                break;
+            }
+        }
+        return star;
+    }
+
+    /**
+     * 业绩分 → 星级档位映射(§4.3 档位表,别名以匹配跨契约命名)。
+     */
+    public int resolveStarLevel(long score) {
+        return starForScore(score);
+    }
+
+    /**
+     * 升级双门槛判定(§4.3 判定规则 / #15 拍板):
+     * <pre>
+     *   isPromotable = score ≥ threshold(targetStar) AND directValidNewRefs ≥ 1
+     * </pre>
+     * 一星为入团基线(targetStar ≤ 1 恒可);两条件缺一不可。
+     *
+     * @param score              滚动 12 个月加权业绩分
+     * @param directValidNewRefs 滚动 12 个月团队直推有效新用户数(#15:首单即算)
+     * @param targetStar         目标星级(1-6)
+     */
+    public boolean isPromotable(long score, int directValidNewRefs, int targetStar) {
+        int target = Math.min(Math.max(targetStar, MIN_STAR), MAX_STAR);
+        if (target <= MIN_STAR) {
+            return true;
+        }
+        return score >= thresholdOfStar(target) && directValidNewRefs >= 1;
+    }
+
+    /**
+     * 月度重评(§4.3 升降规则):
+     * <ul>
+     *   <li>升星:业绩分达标到更高档 <b>且</b> 团队直推 ≥1 才升级(双门槛);否则维持当前档</li>
+     *   <li>降星:滚动窗口滑出业绩分下降 → 自动降级(不保留历史级别、不固化)</li>
+     * </ul>
+     *
+     * @param score              滚动 12 个月加权业绩分
+     * @param directValidNewRefs 滚动 12 个月团队直推有效新用户数
+     * @param currentStar        当前星级(上一档快照)
+     * @return 重评后星级(1-6)
+     */
+    public int reEvaluateStar(long score, int directValidNewRefs, int currentStar) {
+        int current = Math.min(Math.max(currentStar, MIN_STAR), MAX_STAR);
+        int natural = starForScore(score);
+        if (natural > current) {
+            // 升星需过升级双门槛;不达标维持当前档
+            return isPromotable(score, directValidNewRefs, natural) ? natural : current;
+        }
+        // 降星自动(含持平)
+        return natural;
+    }
+}

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

@@ -0,0 +1,364 @@
+package com.etotem.cfc.task;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.etotem.cfc.entity.DistributionRelation;
+import com.etotem.cfc.entity.ProfitPoolOrder;
+import com.etotem.cfc.entity.TeamAwardRecord;
+import com.etotem.cfc.entity.TeamShareRecord;
+import com.etotem.cfc.entity.TeamStarSnapshot;
+import com.etotem.cfc.mapper.DistributionRelationMapper;
+import com.etotem.cfc.mapper.ProfitPoolOrderMapper;
+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.StarScoreCalculator;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 团队分润定时任务(§12-T4 + §8)。
+ *
+ * <p>规格:{@code docs/superpowers/specs/2026-11-07-referral-team-profit-sharing.md}
+ * v0.7 §8.1 状态机(pending → 冷静期 7 天 → 定时任务批量置 settled 入可提现余额;
+ * 冷静期内退款 → 全部 cancelled、金额回流平台)+ §8.2 冷静期与退款(7 天退款全退、
+ * 7 天后自动 settled)+ §4.3 月度星级重评(升级双门槛:业绩分 ≥ 目标档门槛 且
+ * 滚动 12 个月团队直推 ≥1,见 §11-#15 拍板;落 {@code team_star_snapshot} 快照)
+ * + §9 边界 #7(退款跨冷静期 → 已 settled 需冲正)。</p>
+ *
+ * <p><b>批次入口</b>:</p>
+ * <ul>
+ *   <li>{@link #settleDuePendingRecords()} 每日 03:00 冷静期满结算</li>
+ *   <li>{@link #reevaluateMonthlyStars()} 每月 1 日 00:30 星级重评落快照</li>
+ *   <li>{@link #cancelTeamRecordsForRefund(long)} 冷静期内退款冲销(退款服务回调)</li>
+ *   <li>{@link #reverseSettledBalanceForCrossCoolingRefund(long)} 跨冷静期退款冲正(§9#7)</li>
+ * </ul>
+ */
+@Slf4j
+@Component
+public class TeamProfitCronTask {
+
+    /** 冷静期 7 天(§8.2,与退款窗口对齐)。 */
+    public static final int COOLING_PERIOD_DAYS = 7;
+
+    /** 月度星级重评 cron:每月 1 日 00:30(§4.3 重评周期)。 */
+    public static final String MONTHLY_STAR_REEVAL_CRON = "0 30 0 1 * ?";
+
+    /** 冷静期结算 cron:每日 03:00(低峰)。 */
+    public static final String SETTLE_CRON = "0 0 3 * * ?";
+
+    private static final long DAY_MS = 24L * 60 * 60 * 1000;
+
+    @Resource
+    private TeamShareRecordMapper teamShareRecordMapper;
+
+    @Resource
+    private TeamAwardRecordMapper teamAwardRecordMapper;
+
+    @Resource
+    private ProfitPoolOrderMapper profitPoolOrderMapper;
+
+    @Resource
+    private TeamStarSnapshotMapper teamStarSnapshotMapper;
+
+    @Resource
+    private TeamProfitStatsMapper teamProfitStatsMapper;
+
+    @Resource
+    private DistributionRelationMapper distributionRelationMapper;
+
+    @Resource
+    private StarScoreCalculator starScoreCalculator;
+
+    // ==================== §8.2 冷静期常量与判定 ====================
+
+    /** 冷静期天数(默认 7,§8.2 与退款窗口对齐)。 */
+    public int coolingPeriodDays() {
+        return COOLING_PERIOD_DAYS;
+    }
+
+    /** 支付时刻距今是否仍在冷静期内(now &lt; paidAt + 7 天)。 */
+    public boolean isWithinCoolingPeriod(long paidAtEpochMs, long nowEpochMs) {
+        return nowEpochMs < paidAtEpochMs + (long) COOLING_PERIOD_DAYS * DAY_MS;
+    }
+
+    // ==================== §8.1 冷静期满 → 批量结算入可提现余额 ====================
+
+    /**
+     * 冷静期满批量结算:支付满 7 天的 pending 分润(team_share_record / team_award_record)
+     * 置为 settled,入「可提现余额」(货币账本,余额由 settled 记录聚合派生);同步推进
+     * profit_pool_order 主单状态(§8.1 状态机)。
+     *
+     * @return 处理条数(team_share + team_award)
+     */
+    @Scheduled(cron = SETTLE_CRON)
+    @Transactional(rollbackFor = Exception.class)
+    public int settleDuePendingRecords() {
+        Date cutoff = new Date(System.currentTimeMillis() - (long) COOLING_PERIOD_DAYS * DAY_MS);
+        int processed = 0;
+        if (teamShareRecordMapper != null) {
+            List<TeamShareRecord> shares = teamShareRecordMapper.selectList(
+                    new LambdaQueryWrapper<TeamShareRecord>()
+                            .eq(TeamShareRecord::getStatus, "pending")
+                            .le(TeamShareRecord::getCreatedAt, cutoff));
+            for (TeamShareRecord r : shares) {
+                r.setStatus("settled");
+                teamShareRecordMapper.updateById(r);
+            }
+            processed += shares.size();
+            log.info("团队分润冷静期结算: team_share_record 结算 {} 条", shares.size());
+        }
+        if (teamAwardRecordMapper != null) {
+            List<TeamAwardRecord> awards = teamAwardRecordMapper.selectList(
+                    new LambdaQueryWrapper<TeamAwardRecord>()
+                            .eq(TeamAwardRecord::getStatus, "pending")
+                            .le(TeamAwardRecord::getCreatedAt, cutoff));
+            for (TeamAwardRecord r : awards) {
+                r.setStatus("settled");
+                teamAwardRecordMapper.updateById(r);
+            }
+            processed += awards.size();
+            log.info("团队分润冷静期结算: team_award_record 结算 {} 条", awards.size());
+        }
+        if (profitPoolOrderMapper != null) {
+            List<ProfitPoolOrder> pools = profitPoolOrderMapper.selectList(
+                    new LambdaQueryWrapper<ProfitPoolOrder>()
+                            .eq(ProfitPoolOrder::getStatus, "pending")
+                            .le(ProfitPoolOrder::getCreatedAt, cutoff));
+            for (ProfitPoolOrder p : pools) {
+                p.setStatus("settled");
+                p.setSettledAt(new Date());
+                profitPoolOrderMapper.updateById(p);
+            }
+            log.info("团队分润冷静期结算: profit_pool_order 主单同步 {} 条", pools.size());
+        }
+        log.info("团队分润冷静期结算完成: 处理 {} 条", processed);
+        return processed;
+    }
+
+    // ==================== §8.2 冷静期内退款:全部 cancelled、资金不发放 ====================
+
+    /**
+     * 冷静期内订单退款:对应 team_share_record / team_award_record 全部置为 cancelled
+     * (资金不发放,金额回流平台见 {@link #refundRefluxAmountCent(long)}),
+     * profit_pool_order 主单同步 cancelled(§8.1/§8.2)。
+     *
+     * @return 取消条数(team_share + team_award)
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public int cancelTeamRecordsForRefund(long orderId) {
+        int cancelled = 0;
+        if (teamShareRecordMapper != null) {
+            List<TeamShareRecord> shares = teamShareRecordMapper.selectList(
+                    new LambdaQueryWrapper<TeamShareRecord>()
+                            .eq(TeamShareRecord::getOrderId, orderId)
+                            .ne(TeamShareRecord::getStatus, "cancelled"));
+            for (TeamShareRecord r : shares) {
+                r.setStatus("cancelled");
+                teamShareRecordMapper.updateById(r);
+            }
+            cancelled += shares.size();
+        }
+        if (teamAwardRecordMapper != null) {
+            List<TeamAwardRecord> awards = teamAwardRecordMapper.selectList(
+                    new LambdaQueryWrapper<TeamAwardRecord>()
+                            .eq(TeamAwardRecord::getOrderId, orderId)
+                            .ne(TeamAwardRecord::getStatus, "cancelled"));
+            for (TeamAwardRecord r : awards) {
+                r.setStatus("cancelled");
+                teamAwardRecordMapper.updateById(r);
+            }
+            cancelled += awards.size();
+        }
+        if (profitPoolOrderMapper != null) {
+            List<ProfitPoolOrder> pools = profitPoolOrderMapper.selectList(
+                    new LambdaQueryWrapper<ProfitPoolOrder>()
+                            .eq(ProfitPoolOrder::getOrderId, orderId)
+                            .ne(ProfitPoolOrder::getStatus, "cancelled"));
+            for (ProfitPoolOrder p : pools) {
+                p.setStatus("cancelled");
+                profitPoolOrderMapper.updateById(p);
+            }
+        }
+        log.info("订单 {} 冷静期内退款: 取消分润记录 {} 条", orderId, cancelled);
+        return cancelled;
+    }
+
+    // ==================== §9 边界 #7 退款跨冷静期:已 settled 冲正 ====================
+
+    /**
+     * 退款跨冷静期(已 settled)冲正(§9 边界 #7):把订单对应已 settled / pending 的
+     * team_share_record / team_award_record 全部置 cancelled —— settled 余额由记录聚合派生,
+     * 取消即等价于「cancelled + 余额扣减」,资金不再可提现,回流平台。
+     *
+     * @return 冲正操作是否成功完成
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public boolean reverseSettledBalanceForCrossCoolingRefund(long orderId) {
+        int reversed = cancelTeamRecordsForRefund(orderId);
+        log.info("订单 {} 跨冷静期退款冲正: 冲销 {} 条分润记录(已 settled 余额扣减)", orderId, reversed);
+        return true;
+    }
+
+    /**
+     * 取消/冲正后分润金额回流平台(分,≥0):订单对应 team_share_record + team_award_record
+     * 原分润金额合计(含本次置 cancelled 的记录)。
+     */
+    public long refundRefluxAmountCent(long orderId) {
+        long reflux = 0L;
+        if (teamShareRecordMapper != null) {
+            List<TeamShareRecord> shares = teamShareRecordMapper.selectList(
+                    new LambdaQueryWrapper<TeamShareRecord>()
+                            .eq(TeamShareRecord::getOrderId, orderId));
+            for (TeamShareRecord r : shares) {
+                reflux += r.getShareCent() == null ? 0L : r.getShareCent();
+            }
+        }
+        if (teamAwardRecordMapper != null) {
+            List<TeamAwardRecord> awards = teamAwardRecordMapper.selectList(
+                    new LambdaQueryWrapper<TeamAwardRecord>()
+                            .eq(TeamAwardRecord::getOrderId, orderId));
+            for (TeamAwardRecord r : awards) {
+                reflux += r.getAwardCent() == null ? 0L : r.getAwardCent();
+            }
+        }
+        return reflux;
+    }
+
+    // ==================== §4.3 月度星级重评:调度与快照 ====================
+
+    /** 月度重评 cron 表达式(每月 1 日 00:30)。 */
+    public String monthlyStarReevalCron() {
+        return MONTHLY_STAR_REEVAL_CRON;
+    }
+
+    /** 快照周期 YYYYMM(对应 §7.6 team_star_snapshot.period,唯一键 uk_user_period)。 */
+    public String snapshotPeriod(int year, int month) {
+        return String.format("%04d%02d", year, month);
+    }
+
+    /**
+     * 月度星级重评批次:对团队树全部成员重评并落 {@code team_star_snapshot} 快照。
+     * 每月 1 日 00:30 执行(§4.3 重评周期)。
+     */
+    @Scheduled(cron = MONTHLY_STAR_REEVAL_CRON)
+    public void reevaluateMonthlyStars() {
+        if (distributionRelationMapper == null || teamStarSnapshotMapper == null) {
+            return;
+        }
+        Calendar now = Calendar.getInstance();
+        int year = now.get(Calendar.YEAR);
+        int month = now.get(Calendar.MONTH) + 1;
+        try {
+            List<Object> userIds = distributionRelationMapper.selectObjs(
+                    new QueryWrapper<DistributionRelation>().select("DISTINCT user_id"));
+            int done = 0;
+            for (Object id : userIds) {
+                if (id == null) {
+                    continue;
+                }
+                try {
+                    long userId = ((Number) id).longValue();
+                    int star = snapshotMonthlyStar(userId, year, month);
+                    done++;
+                    log.info("月度星级重评: userId={} period={} 星级={}", userId, snapshotPeriod(year, month), star);
+                } catch (Exception e) {
+                    log.warn("月度星级重评失败(单用户跳过): userId={} period={}: {}", id,
+                            snapshotPeriod(year, month), e.getMessage());
+                }
+            }
+            log.info("月度星级重评完成: 共 {} 人", done);
+        } catch (Exception e) {
+            log.error("月度星级重评批次执行失败", e);
+        }
+    }
+
+    /**
+     * 单用户月度星级重评:按滚动 12 个月业绩分 + 升级双门槛重评并落快照。
+     *
+     * @return 本期星级(1-6)
+     */
+    public int snapshotMonthlyStar(long userId, int year, int month) {
+        String period = snapshotPeriod(year, month);
+        Calendar cal = Calendar.getInstance();
+        cal.clear();
+        cal.set(year, month - 1, 1, 0, 0, 0);
+        Date monthStart = cal.getTime();
+        cal.add(Calendar.MONTH, -12);
+        Date windowStart = cal.getTime();
+
+        long directCent = 0L;
+        long teamCent = 0L;
+        long referralCent = 0L;
+        int directValidNewRefs = 0;
+        if (teamProfitStatsMapper != null) {
+            try {
+                directCent = teamProfitStatsMapper.selectDirectServiceAmountCent(userId, windowStart);
+                teamCent = teamProfitStatsMapper.selectTeamServiceAmountCent(userId, windowStart);
+                referralCent = teamProfitStatsMapper.selectReferralConsumeAmountCent(userId, windowStart);
+                directValidNewRefs = teamProfitStatsMapper.countDirectValidNewRefs(userId, windowStart);
+            } catch (Exception e) {
+                log.warn("月度星级重评统计查询失败(userId={}): {}", userId, e.getMessage());
+            }
+        }
+
+        // §4.3 业绩分(金额 分 → 元口径)与升级双门槛
+        long score = 0L;
+        int newStar = StarScoreCalculator.MIN_STAR;
+        int currentStar = StarScoreCalculator.MIN_STAR;
+        if (starScoreCalculator != null) {
+            score = starScoreCalculator.calculateScore(directCent / 100L, teamCent / 100L, referralCent / 100L);
+        }
+
+        if (teamStarSnapshotMapper != null) {
+            // 上一期星级(无历史则入团基线一星)
+            TeamStarSnapshot prev = teamStarSnapshotMapper.selectOne(
+                    new LambdaQueryWrapper<TeamStarSnapshot>()
+                            .eq(TeamStarSnapshot::getUserId, userId)
+                            .orderByDesc(TeamStarSnapshot::getPeriod)
+                            .last("LIMIT 1"));
+            if (prev != null && prev.getStarLevel() != null) {
+                currentStar = prev.getStarLevel();
+            }
+            if (starScoreCalculator != null) {
+                newStar = starScoreCalculator.reEvaluateStar(score, directValidNewRefs, currentStar);
+            }
+            // 落快照(幂等 upsert:uk_user_period)
+            TeamStarSnapshot snap = teamStarSnapshotMapper.selectOne(
+                    new LambdaQueryWrapper<TeamStarSnapshot>()
+                            .eq(TeamStarSnapshot::getUserId, userId)
+                            .eq(TeamStarSnapshot::getPeriod, period));
+            boolean isNew = snap == null;
+            if (isNew) {
+                snap = new TeamStarSnapshot();
+                snap.setCreatedAt(new Date());
+            }
+            snap.setUserId(userId);
+            snap.setStarLevel(newStar);
+            snap.setScore(score);
+            snap.setDirectRevCent(directCent);
+            snap.setTeamRevCent(teamCent);
+            snap.setReferralConsumeCent(referralCent);
+            snap.setPeriod(period);
+            if (isNew) {
+                teamStarSnapshotMapper.insert(snap);
+            } else {
+                teamStarSnapshotMapper.updateById(snap);
+            }
+        } else if (starScoreCalculator != null) {
+            newStar = starScoreCalculator.reEvaluateStar(score, directValidNewRefs, StarScoreCalculator.MIN_STAR);
+        }
+
+        log.info("月度星级重评(userId={}, period={}): score={}, 直推={}, 星级 {}→{}",
+                userId, period, score, directValidNewRefs, currentStar, newStar);
+        return newStar;
+    }
+}

+ 15 - 0
cfc-backend/src/main/resources/schema.sql

@@ -7162,3 +7162,18 @@ CREATE TABLE IF NOT EXISTS team_award_record (
     UNIQUE KEY uk_order_team (order_id, team_id),
     KEY idx_owner (owner_user_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团队奖流水(货币)';
+
+-- 迁移461: 团队成员星级月度快照表(§7.6,uk_user_period 幂等)
+CREATE TABLE IF NOT EXISTS team_star_snapshot (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT NOT NULL COMMENT '成员用户ID',
+    team_id BIGINT NULL COMMENT '所属服务团队根节点',
+    star_level INT NOT NULL COMMENT '星级(1-6)',
+    score INT NOT NULL COMMENT '加权业绩分',
+    direct_rev_cent INT NULL COMMENT '直接服务收入(分)',
+    team_rev_cent INT NULL COMMENT '团队服务收入(分)',
+    referral_consume_cent INT NULL COMMENT '推荐用户平台消费(分)',
+    period VARCHAR(16) NOT NULL COMMENT 'YYYYMM',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    UNIQUE KEY uk_user_period (user_id, period)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团队成员星级月度快照';

+ 2 - 2
cfc-backend/src/test/java/com/etotem/cfc/unit/TeamProfitCronContractTest.java

@@ -220,8 +220,8 @@ class TeamProfitCronContractTest {
         Object host = contractHost(HOST_STAR_CALC, "team.profit.cron");
         assertEquals(2, callInt(host, "starForScore", 24999L), "24,999 分仍二星(未达三星 25,000)");
         assertEquals(3, callInt(host, "starForScore", 25000L), "25,000 分恰达三星");
-        assertEquals(5, callInt(host, "starForScore", 139999L), "139,999 分仍四星(未达五星 140,000)");
-        assertEquals(6, callInt(host, "starForScore", 140000L), "140,000 分达五星(六星需 250,000)");
+        assertEquals(4, callInt(host, "starForScore", 139999L), "139,999 分仍四星(未达五星 140,000)");
+        assertEquals(5, callInt(host, "starForScore", 140000L), "140,000 分达五星(六星需 250,000)");
     }
 
     // ==================== §4.3 升级双门槛(#15 拍板) ====================