Jelajahi Sumber

test(team.star.myView): S-cfc-46 RED 契约级单测 TeamStarMyViewContractTest(§12-T7 + §4.3.1 ①② 星级看板与换算,24 用例 23 红)

Sisyphus 5 hari lalu
induk
melakukan
f3d9d15bb9

+ 586 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TeamStarMyViewContractTest.java

@@ -0,0 +1,586 @@
+package com.etotem.cfc.unit;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Scanner;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * 契约级单测:团队星级看板与换算(能力键 {@code team.star.myView},单元 team)。
+ *
+ * <p><b>需求/设计锚点(规格 v0.7)</b></p>
+ * <ul>
+ *   <li>需求:{@code docs/superpowers/specs/2026-11-07-referral-team-profit-sharing.md}
+ *       §12-T7(星级看板与换算)+ §11-#13(已拍板:按推荐全做 P1 看板+换算)</li>
+ *   <li>设计:§4.3.1 ① 实时星级看板(当前星级/总分/距下一档差距/升级双门槛进度/三维构成/
+ *       滚动窗口说明/近 12 月星级轨迹)+ §4.3.1 ② 差距→行动换算(三条达标路径按「最容易够到」排序)</li>
+ *   <li>接口:§4.3.1「数据与接口」+ §12-T7 —— {@code POST /api/team-star/my-view} →
+ *       当前星级 / 总分 / 三维构成 / 距下一档差距 / 三条换算路径 / 近 12 月轨迹;
+ *       实时聚合抽为可查询组件 {@code StarScoreCalculator},月度轨迹读 {@code team_star_snapshot}(§7.6)</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约行为</b></p>
+ * <ul>
+ *   <li>星级档位门槛(§4.3):一星≥0 / 二星≥8,000 / 三星≥25,000 / 四星≥70,000 /
+ *       五星≥140,000 / 六星≥250,000(可配 {@code team_profit.star_tiers})</li>
+ *   <li>业绩分公式(§4.3):{@code Score = 直接服务收入×50% + 团队服务收入×40% + 推荐消费收入×10%}
+ *       —— 规格 ① 示例 直接 10 万×50% + 团队 5 万×40% + 推荐 2 万×10% = 50,000+20,000+2,000 = 72,000</li>
+ *   <li>距下一档差距(§4.3.1 ①):低于下一档门槛的部分(示例「距四星还差 12,000 分」= 70,000−58,000)</li>
+ *   <li>升级双门槛判定(§4.3):{@code score ≥ 目标档门槛 AND 滚动12个月团队直推 ≥ 1} 两个条件缺一不可
+ *       (§4.3 判定规则 {@code isPromotable})</li>
+ *   <li>换算率(§4.3.1 ②,¥500/次咨询基准,可配 {@code team_profit.star_conversion}):
+ *       直接服务 1 次 = 250 分;团队服务 1 次 = 200 分;推荐消费 ¥100 = 10 分</li>
+ *   <li>三条达标路径(§4.3.1 ② 表格示例,还差 12,000 分):
+ *       A 直接服务 → 再 48 次;B 团队带教 → 再 60 次;C 推荐消费 → 再 ¥120,000;按「最容易够到」排序</li>
+ *   <li>近 12 月轨迹:读 {@code team_star_snapshot} 月度快照(§7.6,唯一键 {@code uk_user_period})</li>
+ *   <li>接口:{@code TeamStarController} 提供 {@code POST /api/team-star/my-view},统一返回
+ *       {@code Result<T>}(§12-T7)</li>
+ * </ul>
+ *
+ * <p><b>宿主契约(TDD-RED 锚点)</b>:{@code com.etotem.cfc.service.StarScoreCalculator} ——
+ * 规格 §4.3 判定规则 / §4.3.1「数据与接口」规划的实时聚合可查询组件;以及
+ * {@code com.etotem.cfc.controller.TeamStarController}(§12-T7 接口)、
+ * {@code com.etotem.cfc.dto.TeamStarMyViewVO}(看板聚合视图)、
+ * {@code com.etotem.cfc.entity.TeamStarSnapshot}(§7.6 月度快照实体)。
+ * 当前(2026-11-08)<b>尚未实现</b>(src/main 全库 0 引用,schema.sql 无 {@code team_star_snapshot})。
+ * 本测试通过反射锚定宿主契约(避免直接 import 不存在类导致编译失败),
+ * 运行时宿主类缺失即断言失败(红),暴露「实现未落地」这一真实缺陷;实现落地后转为验证真实行为(GREEN)。</p>
+ *
+ * <p><b>契约方法签名(实现方按此落地)</b></p>
+ * <ul>
+ *   <li>{@code int starThreshold(int starLevel)} —— 星级业绩分门槛(1-6,默认 0/8千/2.5万/7万/14万/25万)</li>
+ *   <li>{@code int weightedScore(long directRevenueYuan, long teamRevenueYuan, long referralConsumeYuan)}
+ *       —— 业绩分 = 直接×50% + 团队×40% + 推荐×10%</li>
+ *   <li>{@code int gapToNextStar(int totalScore, int currentStarLevel)} —— 距下一档差距(下一档门槛 − 总分)</li>
+ *   <li>{@code boolean isPromotable(int totalScore, int scoreThreshold, int directValidNewRefs)}
+ *       —— 升级双门槛判定:score ≥ 门槛 且 团队直推 ≥ 1</li>
+ *   <li>{@code int currentStarLevel(long userId)} / {@code int totalScore(long userId)} —— 实时星级 / 总分</li>
+ *   <li>{@code Object dimensionBreakdown(long userId)} —— 三维构成(directScore/teamScore/referralConsumeScore…)</li>
+ *   <li>{@code Object dualThresholdProgress(long userId)} —— 升级双门槛进度(业绩分✓ + 团队直推✓ 分项打勾)</li>
+ *   <li>{@code Object conversionRates()} —— 换算率(directPerSession=250 / teamPerSession=200 / referralPer100Yuan=10)</li>
+ *   <li>{@code List<Object> conversionPaths(int gap)} —— 三条达标路径(type/rate/requiredUnits/unitLabel/requiredYuan),
+ *       按「最容易够到」排序</li>
+ *   <li>{@code Object twelveMonthTrajectory(long userId)} —— 近 12 月星级轨迹(读 team_star_snapshot)</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:纯契约级单测,Mockito/反射,无 Spring / 无 DB / 无真实微信,无外部服务依赖。</p>
+ * <p><b>权限流说明</b>:本能力面向已入团成员查询「我的星级」,鉴权由 JWT 拦截器统一完成
+ * (需登录),接口内无独立角色分支,权限流不适用,以说明性用例标注。</p>
+ *
+ * <p><b>RED 预期</b>:宿主 {@code StarScoreCalculator} / {@code TeamStarController} 未实现,
+ * 全部用例在反射探测处失败(红),断言原文为「RED[team.star.myView] 契约宿主 … 未实现」。</p>
+ */
+class TeamStarMyViewContractTest {
+
+    /** 规格设计宿主类(未实现):实时聚合可查询组件(§4.3 判定规则 / §4.3.1 数据与接口)。 */
+    private static final String HOST_CALCULATOR = "com.etotem.cfc.service.StarScoreCalculator";
+
+    /** 规格设计宿主类(未实现):§12-T7 星级看板接口。 */
+    private static final String HOST_CONTROLLER = "com.etotem.cfc.controller.TeamStarController";
+
+    /** 规格设计宿主类(未实现):看板聚合视图 DTO。 */
+    private static final String HOST_VIEW_VO = "com.etotem.cfc.dto.TeamStarMyViewVO";
+
+    /** 规格设计宿主类(未实现):§7.6 月度快照实体。 */
+    private static final String HOST_SNAPSHOT_ENTITY = "com.etotem.cfc.entity.TeamStarSnapshot";
+
+    /** §4.3 星级业绩分门槛(推荐默认值,可配 team_profit.star_tiers)。 */
+    private static final int[] STAR_THRESHOLDS = {0, 8000, 25000, 70000, 140000, 250000};
+
+    /** §4.3.1 数值示例:三维构成 50,000 / 20,000 / 2,000 = 72,000。 */
+    private static final long EX_DIRECT_YUAN = 100000L;   // ×50% → 50,000
+    private static final long EX_TEAM_YUAN = 50000L;      // ×40% → 20,000
+    private static final long EX_REFERRAL_YUAN = 20000L;  // ×10% →  2,000
+    private static final int EX_TOTAL_SCORE = 72000;
+
+    // ==================== 宿主契约:StarScoreCalculator 类应存在 ====================
+
+    @Test
+    @DisplayName("宿主-1:StarScoreCalculator 实时聚合组件应存在(§4.3.1 数据与接口)")
+    void starScoreCalculator_classExists() {
+        assertNotNull(loadClass(HOST_CALCULATOR),
+                "RED[team.star.myView] 契约宿主 StarScoreCalculator 未实现(实现未落地):"
+                        + "§4.3.1 要求将星级重评逻辑抽为可查询组件,供 POST /api/team-star/my-view 实时聚合。");
+    }
+
+    // ==================== 星级档位门槛(§4.3 档位表) ====================
+
+    @Test
+    @DisplayName("星级-1:六档业绩分门槛应匹配规格默认值(可配 team_profit.star_tiers)")
+    void starThreshold_sixTiersMatchSpec() {
+        Object host = starHost();
+        for (int star = 1; star <= 6; star++) {
+            assertEquals(STAR_THRESHOLDS[star - 1], callInt(host, "starThreshold", star),
+                    "第 " + star + " 星门槛应 = " + STAR_THRESHOLDS[star - 1]
+                            + "(§4.3 档位表:0/8千/2.5万/7万/14万/25万)");
+        }
+    }
+
+    @Test
+    @DisplayName("星级-2:一星门槛 0 —— 入团即一星")
+    void starThreshold_oneStar_zero() {
+        Object host = starHost();
+        assertEquals(0, callInt(host, "starThreshold", 1), "一星门槛必须为 0(入团即一星,§4.3)");
+    }
+
+    @Test
+    @DisplayName("星级-3:六星门槛 25 万 —— 顶流团队长级(带教团队+强推荐)")
+    void starThreshold_sixStar_250000() {
+        Object host = starHost();
+        assertEquals(250000, callInt(host, "starThreshold", 6),
+                "六星门槛 250,000(单靠个人直接服务几乎不可达,必须是带教团队+强推荐,§4.3)");
+    }
+
+    // ==================== 业绩分公式(§4.3 加权:50/40/10) ====================
+
+    @Test
+    @DisplayName("业绩分-1:加权公式 直接×50% + 团队×40% + 推荐×10%(规格①示例 72,000)")
+    void weightedScore_specFormula_50_40_10() {
+        Object host = starHost();
+        // 直接 10 万×50% + 团队 5 万×40% + 推荐 2 万×10% = 50,000+20,000+2,000 = 72,000(§4.3.1①示例)
+        int score = callInt(host, "weightedScore", EX_DIRECT_YUAN, EX_TEAM_YUAN, EX_REFERRAL_YUAN);
+        assertEquals(EX_TOTAL_SCORE, score,
+                "业绩分 = 直接×50% + 团队×40% + 推荐×10% 应 = 72,000(规格①示例)。");
+    }
+
+    @Test
+    @DisplayName("业绩分-2:单维贡献示例 —— 直接服务 1 次 ¥500 咨询 = 250 分(§4.3.1②换算率基准)")
+    void weightedScore_directSession_500Yuan_250Points() {
+        Object host = starHost();
+        // ¥500 × 50% = 250 分:换算率「直接服务 1 次 = 250 分」的公式来源(§4.3.1 ②)
+        assertEquals(250, callInt(host, "weightedScore", 500L, 0L, 0L),
+                "¥500 咨询直接贡献 = 500×50% = 250 分");
+        assertEquals(200, callInt(host, "weightedScore", 0L, 500L, 0L),
+                "¥500 带教服务团队贡献 = 500×40% = 200 分");
+        assertEquals(10, callInt(host, "weightedScore", 0L, 0L, 100L),
+                "¥100 推荐消费贡献 = 100×10% = 10 分");
+    }
+
+    // ==================== 距下一档差距(§4.3.1 ① ≡ ② 换算的差距口径) ====================
+
+    @Test
+    @DisplayName("差距-1:距下一星差距 = 下一档门槛 − 总分(示例「距四星还差 12,000 分」)")
+    void gapToNextStar_specExample_12000() {
+        Object host = starHost();
+        // 总分 58,000 → 三星(≥25,000);距四星(≥70,000)还差 12,000 分(§4.3.1 ①/② 示例)
+        assertEquals(12000, callInt(host, "gapToNextStar", 58000, 3),
+                "距四星还差 12,000 分(70,000 − 58,000)");
+    }
+
+    @Test
+    @DisplayName("差距-2:已达六星(最高档)→ 差距 0")
+    void gapToNextStar_sixStar_zero() {
+        Object host = starHost();
+        assertEquals(0, callInt(host, "gapToNextStar", 300000, 6),
+                "已达六星(最高档)不再有下一档差距,应返回 0");
+    }
+
+    // ==================== 升级双门槛判定(§4.3:业绩分 AND 团队直推 ≥1) ====================
+
+    @Test
+    @DisplayName("双门槛-1:业绩分达标 AND 团队直推 ≥1 才可升级(两条件缺一不可)")
+    void isPromotable_dualThreshold_andRule() {
+        Object host = starHost();
+        // 四星门槛 70,000(§4.3 判定规则:score >= threshold AND direct_valid_new_refs >= 1)
+        assertTrue(callBoolean(host, "isPromotable", 72000, 70000, 2),
+                "业绩分 72,000/70,000 ✓ 且 团队直推 2/1 ✓ → 可升级(§4.3.1①示例)");
+        assertTrue(callBoolean(host, "isPromotable", 72000, 70000, 1),
+                "团队直推恰好 1 → 满足 ≥1,可升级");
+    }
+
+    @Test
+    @DisplayName("双门槛-2:业绩分不足 → 不可升级(即使直推达标)")
+    void isPromotable_scoreNotEnough_notPromotable() {
+        Object host = starHost();
+        assertTrue(!(callBoolean(host, "isPromotable", 65000, 70000, 2)),
+                "业绩分 65,000 < 70,000 ✗ → 不可升级");
+    }
+
+    @Test
+    @DisplayName("双门槛-3:直推不足(0)→ 不可升级(业绩分够也不行)")
+    void isPromotable_noDirectRef_notPromotable() {
+        Object host = starHost();
+        assertTrue(!(callBoolean(host, "isPromotable", 72000, 70000, 0)),
+                "业绩分够但团队直推 0/1 ✗ → 不可升级(本质:只吃老客户红利不满足带新门槛)");
+    }
+
+    // ==================== 实时星级/总分(DB-backed,结构探测) ====================
+
+    @Test
+    @DisplayName("实时-1:currentStarLevel(userId) 应提供(滚动12个月实时评星)")
+    void currentStarLevel_methodExists() {
+        Object host = starHost();
+        int star = callInt(host, "currentStarLevel", 1001L);
+        assertTrue(star >= 1 && star <= 6, "当前星级应在 1~6 之间,实际 " + star);
+    }
+
+    @Test
+    @DisplayName("实时-2:totalScore(userId) 应提供(滚动12个月加权总分)")
+    void totalScore_methodExists() {
+        Object host = starHost();
+        int score = callInt(host, "totalScore", 1001L);
+        assertTrue(score >= 0, "业绩分总分应 ≥ 0,实际 " + score);
+    }
+
+    // ==================== 三维构成条(§4.3.1 ①) ====================
+
+    @Test
+    @DisplayName("三维-1:dimensionBreakdown 应暴露 直接/团队/推荐 三个贡献分字段")
+    void dimensionBreakdown_exposesThreeComponents() {
+        Object host = starHost();
+        Object bd = call(host, "dimensionBreakdown", 1001L);
+        assertNotNull(bd, "dimensionBreakdown 应返回三维构成对象(直接服务/团队服务/推荐消费)");
+        // 三个成分字段必须存在(各自贡献分;规格①示例 50,000/20,000/2,000)
+        assertTrue(fieldExists(bd, "directScore"), "三维构成应含 directScore(直接服务贡献分)");
+        assertTrue(fieldExists(bd, "teamScore"), "三维构成应含 teamScore(团队服务贡献分)");
+        assertTrue(fieldExists(bd, "referralConsumeScore"), "三维构成应含 referralConsumeScore(推荐消费贡献分)");
+    }
+
+    // ==================== 升级双门槛进度(§4.3.1 ① 分项打勾) ====================
+
+    @Test
+    @DisplayName("双门槛进度-1:dualThresholdProgress 应暴露 业绩分✓ + 团队直推✓ 两项进度")
+    void dualThresholdProgress_exposesBothConditions() {
+        Object host = starHost();
+        Object p = call(host, "dualThresholdProgress", 1001L);
+        assertNotNull(p, "dualThresholdProgress 应返回升级双门槛进度(两条件并列展示)");
+        // 条件① 业绩分:current/threshold/passed;条件② 团队直推:current/required/passed
+        assertTrue(fieldExists(p, "scoreCurrent") && fieldExists(p, "scoreThreshold")
+                        && fieldExists(p, "scorePassed"),
+                "双门槛进度应含 业绩分当前/门槛/是否达标(① 业绩分 72,000/70,000 ✓ 口径)");
+        assertTrue(fieldExists(p, "directNewRefCurrent") && fieldExists(p, "directNewRefRequired")
+                        && fieldExists(p, "directNewRefPassed"),
+                "双门槛进度应含 团队直推当前/要求/是否达标(② 团队直推 2/1 ✓ 口径,载体=团队树非推荐人树)");
+    }
+
+    // ==================== 三条换算路径(§4.3.1 ② 差距→行动) ====================
+
+    @Test
+    @DisplayName("换算-1:换算率表应匹配规格(直接 250/次、团队 200/次、推荐 10/百元)")
+    void conversionRates_specMath_250_200_10() {
+        Object host = starHost();
+        Object rates = call(host, "conversionRates");
+        assertNotNull(rates, "conversionRates 应返回换算率表(¥500/次咨询基准,可配 team_profit.star_conversion)");
+        assertEquals(250, intField(rates, "directPerSession"), "直接服务 1 次 = 250 分");
+        assertEquals(200, intField(rates, "teamPerSession"), "团队服务 1 次 = 200 分");
+        assertEquals(10, intField(rates, "referralPer100Yuan"), "推荐消费 ¥100 = 10 分");
+    }
+
+    @Test
+    @DisplayName("换算-2:还差 12,000 分 → A 再 48 次 / B 再 60 次 / C 再 ¥120,000(②表格示例)")
+    void conversionPaths_specExample_12000Gap() {
+        Object host = starHost();
+        List<?> paths = callList(host, "conversionPaths", 12000);
+        assertNotNull(paths, "conversionPaths 应返回三条达标路径(A/B/C)");
+        assertEquals(3, paths.size(), "看板展示 3 条达标路径(§4.3.1 ②)");
+
+        // A 直接服务:250 分/次 → 还差 48 次 1对1 咨询
+        assertEquals(48, intField(paths.get(0), "requiredUnits"),
+                "A 直接服务:12,000/250 = 48 次 1对1 咨询(规格②表)");
+        assertEquals(250, intField(paths.get(0), "rate"), "A 换算率 250 分/次");
+
+        // B 团队带教:200 分/次 → 还差 60 次带教服务
+        assertEquals(60, intField(paths.get(1), "requiredUnits"),
+                "B 团队带教:12,000/200 = 60 次带教服务(规格②表)");
+        assertEquals(200, intField(paths.get(1), "rate"), "B 换算率 200 分/次");
+
+        // C 推荐消费:10 分/百元 → 还差 ¥120,000 推荐消费
+        assertEquals(1200, intField(paths.get(2), "requiredUnits"),
+                "C 推荐消费:12,000/10 = 1,200 个百元(规格②表)");
+        assertEquals(10, intField(paths.get(2), "rate"), "C 换算率 10 分/百元");
+        assertEquals(120000, intField(paths.get(2), "requiredYuan"),
+                "C 推荐消费:还差 ¥120,000 推荐消费(规格②表)");
+    }
+
+    @Test
+    @DisplayName("换算-3:三条路径按「最容易够到」排序(所需次数由小到大)")
+    void conversionPaths_sortedByEasiest() {
+        Object host = starHost();
+        List<?> paths = callList(host, "conversionPaths", 12000);
+        assertEquals(3, paths.size());
+        int u0 = intField(paths.get(0), "requiredUnits");
+        int u1 = intField(paths.get(1), "requiredUnits");
+        int u2 = intField(paths.get(2), "requiredUnits");
+        assertTrue(u0 <= u1 && u1 <= u2,
+                "三条路径应按「最容易够到」排序(所需次数/单位由小到大),实际 " + u0 + "/" + u1 + "/" + u2);
+    }
+
+    // ==================== 近 12 月星级轨迹(§4.3.1 ① + §7.6 team_star_snapshot) ====================
+
+    @Test
+    @DisplayName("轨迹-1:twelveMonthTrajectory(userId) 应提供(近12月月度快照曲线)")
+    void twelveMonthTrajectory_methodExists() {
+        Object host = starHost();
+        Object traj = call(host, "twelveMonthTrajectory", 1001L);
+        assertNotNull(traj, "twelveMonthTrajectory 应返回近 12 月星级轨迹(读 team_star_snapshot 月度快照)");
+    }
+
+    @Test
+    @DisplayName("轨迹-2:team_star_snapshot 实体应存在(§7.6 月度快照)")
+    void snapshotEntity_exists() {
+        assertNotNull(loadClass(HOST_SNAPSHOT_ENTITY),
+                "RED[team.star.myView] TeamStarSnapshot 实体未实现:§7.6 要求 team_star_snapshot 表"
+                        + "(user_id/star_level/score/period)供近 12 月轨迹与月度重评快照。");
+    }
+
+    @Test
+    @DisplayName("轨迹-3:schema.sql 应含 team_star_snapshot(§7.6,唯一键 uk_user_period)")
+    void schemaSql_containsTeamStarSnapshot() {
+        String schema = readSchemaSql();
+        assertTrue(schema.contains("team_star_snapshot"),
+                "RED[team.star.myView] schema.sql 缺少 team_star_snapshot 建表(§7.6 月度星级快照)。");
+        assertTrue(schema.contains("uk_user_period"),
+                "team_star_snapshot 应含 uk_user_period 唯一键(user_id,period 每月一份,§7.6)。");
+    }
+
+    // ==================== 接口契约:POST /api/team-star/my-view(§12-T7) ====================
+
+    @Test
+    @DisplayName("接口-1:TeamStarController 应存在且提供 POST /api/team-star/my-view")
+    void controller_myViewEndpoint_existAndPostMapping() {
+        Class<?> clazz = loadClass(HOST_CONTROLLER);
+        if (clazz == null) {
+            fail("RED[team.star.myView] 契约宿主 TeamStarController 未实现:§12-T7 要求"
+                    + " POST /api/team-star/my-view 返回当前星级/总分/三维构成/距下一档差距/三条换算路径/近12月轨迹。");
+        }
+        // 类级映射路径:@RequestMapping("/api/team-star")
+        List<String> classPaths = mappingValues(clazz.getAnnotations());
+        assertTrue(classPaths.contains("/api/team-star"),
+                "TeamStarController 类级映射应为 /api/team-star(§12-T7),实际 " + classPaths);
+        // 方法级映射:存在 @PostMapping("/my-view")(本项目统一 @PostMapping)
+        boolean found = false;
+        for (Method m : clazz.getDeclaredMethods()) {
+            List<String> methodPaths = mappingValues(m.getAnnotations());
+            boolean postMapped = hasAnnotationType(m.getAnnotations(),
+                    "org.springframework.web.bind.annotation.PostMapping");
+            boolean returnsResult = m.getReturnType().getName()
+                    .equals("com.etotem.cfc.common.Result");
+            if (postMapped && methodPaths.contains("/my-view")) {
+                found = true;
+                assertTrue(returnsResult,
+                        "/my-view 方法应统一返回 Result<T>(项目响应包装约定),实际 "
+                                + m.getReturnType().getName());
+            }
+        }
+        assertTrue(found,
+                "TeamStarController 应提供 @PostMapping(\"/my-view\") 方法(统一 POST,§12-T7),"
+                        + "映射 /api/team-star/my-view");
+    }
+
+    // ==================== 看板视图 DTO(§4.3.1 聚合字段) ====================
+
+    @Test
+    @DisplayName("视图-1:TeamStarMyViewVO 应承载看板全部聚合字段(§4.3.1/T7)")
+    void myViewVO_carriesSpecFields() {
+        Class<?> vo = loadClass(HOST_VIEW_VO);
+        if (vo == null) {
+            fail("RED[team.star.myView] TeamStarMyViewVO 未实现:§12-T7 要求 my-view 返回"
+                    + " 当前星级/总分/三维构成/距下一档差距/升级双门槛进度/三条换算路径/近12月轨迹。");
+        }
+        // §12-T7 + §4.3.1 ① 聚合字段(当前星级/总分/三维构成/距下一档差距/双门槛进度/三条路径/近12月轨迹)
+        assertTrue(voHasField(vo, "currentStarLevel"), "VO 应含 currentStarLevel(当前星级)");
+        assertTrue(voHasField(vo, "totalScore"), "VO 应含 totalScore(业绩分总分)");
+        assertTrue(voHasField(vo, "dimensionBreakdown"), "VO 应含 dimensionBreakdown(三维构成)");
+        assertTrue(voHasField(vo, "gapToNextStar"), "VO 应含 gapToNextStar(距下一档差距,如「还差 12,000 分」)");
+        assertTrue(voHasField(vo, "dualThresholdProgress"), "VO 应含 dualThresholdProgress(升级双门槛进度)");
+        assertTrue(voHasField(vo, "conversionPaths"), "VO 应含 conversionPaths(三条换算路径)");
+        assertTrue(voHasField(vo, "twelveMonthTrajectory"), "VO 应含 twelveMonthTrajectory(近 12 月轨迹)");
+        // 滚动窗口说明:统计期「近 12 个月」(§4.3.1 ①,如 2025-11-08 ~ 2026-11-07)
+        assertTrue(voHasField(vo, "windowStart") && voHasField(vo, "windowEnd"),
+                "VO 应含 windowStart/windowEnd(滚动窗口统计期说明)");
+    }
+
+    // ==================== 权限流(不适用) ====================
+
+    @Test
+    @DisplayName("权限流:本能力为登录成员「我的星级」查询,鉴权由 JWT 拦截器统一完成,接口内无独立角色分支")
+    void permissionFlow_notApplicable() {
+        // 说明性用例:my-view 面向已入团成员本人,鉴权走统一 JWT(需登录,公开路径除外),
+        // 接口内不拆分角色分支;仅标注「不适用」。
+        assertTrue(true, "权限流不适用:team.star.myView 鉴权统一由 JwtInterceptor 完成。");
+    }
+
+    // ==================== 反射辅助(锚定宿主契约,避免编译期依赖不存在类) ====================
+
+    private Object starHost() {
+        try {
+            Class<?> clazz = Class.forName(HOST_CALCULATOR);
+            return clazz.getDeclaredConstructor().newInstance();
+        } catch (ClassNotFoundException e) {
+            fail("RED[team.star.myView] 契约宿主 StarScoreCalculator 未实现(实现未落地):"
+                    + e.getMessage());
+            throw new IllegalStateException("unreachable");
+        } catch (Exception e) {
+            fail("契约宿主 StarScoreCalculator 实例化失败:" + e.getMessage());
+            throw new IllegalStateException("unreachable");
+        }
+    }
+
+    /** 按方法名 + 参数个数匹配并调用(宽松适配基本/包装类型),契约方法缺失即断言失败。 */
+    private Object call(Object host, String method, Object... args) {
+        for (Method m : host.getClass().getMethods()) {
+            if (m.getName().equals(method) && m.getParameterCount() == args.length) {
+                try {
+                    return m.invoke(host, args);
+                } catch (Exception e) {
+                    fail("契约方法 " + HOST_CALCULATOR + "#" + method + " 调用失败:" + e.getMessage());
+                    throw new IllegalStateException("unreachable");
+                }
+            }
+        }
+        fail("RED[team.star.myView] 契约方法 " + HOST_CALCULATOR + "#" + method + " 未实现");
+        throw new IllegalStateException("unreachable");
+    }
+
+    private int callInt(Object host, String method, Object... args) {
+        Object r = call(host, method, args);
+        if (r instanceof Number) {
+            return ((Number) r).intValue();
+        }
+        fail("契约方法 " + method + " 应返回整数,实际 " + r);
+        throw new IllegalStateException("unreachable");
+    }
+
+    private boolean callBoolean(Object host, String method, Object... args) {
+        Object r = call(host, method, args);
+        if (r instanceof Boolean) {
+            return (Boolean) r;
+        }
+        fail("契约方法 " + method + " 应返回布尔,实际 " + r);
+        throw new IllegalStateException("unreachable");
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<?> callList(Object host, String method, Object... args) {
+        Object r = call(host, method, args);
+        if (r instanceof List) {
+            return (List<Object>) r;
+        }
+        fail("契约方法 " + method + " 应返回 List,实际 " + r);
+        throw new IllegalStateException("unreachable");
+    }
+
+    /** 读取返回 POJO 的字段(先 getter,再反射字段),契约对象缺字段即断言失败。 */
+    private int intField(Object obj, String field) {
+        try {
+            Method getter = obj.getClass().getMethod("get" + capitalize(field));
+            Object v = getter.invoke(obj);
+            if (v instanceof Number) {
+                return ((Number) v).intValue();
+            }
+            fail("字段 " + obj.getClass().getSimpleName() + "#" + field + " 应返回数值,实际 " + v);
+        } catch (NoSuchMethodException e) {
+            try {
+                Field f = obj.getClass().getDeclaredField(field);
+                f.setAccessible(true);
+                Object v = f.get(obj);
+                if (v instanceof Number) {
+                    return ((Number) v).intValue();
+                }
+                fail("字段 " + obj.getClass().getSimpleName() + "#" + field + " 应返回数值,实际 " + v);
+            } catch (Exception e2) {
+                fail("契约对象 " + obj.getClass().getName() + " 缺少字段 " + field);
+            }
+        } catch (Exception e) {
+            fail("读取字段 " + field + " 失败:" + e.getMessage());
+        }
+        throw new IllegalStateException("unreachable");
+    }
+
+    private boolean fieldExists(Object obj, String field) {
+        try {
+            obj.getClass().getMethod("get" + capitalize(field));
+            return true;
+        } catch (NoSuchMethodException e) {
+            try {
+                obj.getClass().getDeclaredField(field);
+                return true;
+            } catch (NoSuchFieldException e2) {
+                return false;
+            }
+        }
+    }
+
+    private boolean voHasField(Class<?> vo, String field) {
+        try {
+            vo.getDeclaredField(field);
+            return true;
+        } catch (NoSuchFieldException e) {
+            return false;
+        }
+    }
+
+    /** 读取 Spring 映射注解(@RequestMapping/@PostMapping 等)的 value() 路径集合。 */
+    private List<String> mappingValues(Annotation[] annotations) {
+        List<String> paths = new ArrayList<>();
+        for (Annotation a : annotations) {
+            String tn = a.annotationType().getName();
+            if (tn.equals("org.springframework.web.bind.annotation.RequestMapping")
+                    || tn.equals("org.springframework.web.bind.annotation.PostMapping")
+                    || tn.equals("org.springframework.web.bind.annotation.GetMapping")
+                    || tn.equals("org.springframework.web.bind.annotation.PutMapping")
+                    || tn.equals("org.springframework.web.bind.annotation.DeleteMapping")) {
+                try {
+                    Object v = a.annotationType().getMethod("value").invoke(a);
+                    if (v instanceof String[]) {
+                        Collections.addAll(paths, (String[]) v);
+                    }
+                } catch (Exception ignored) {
+                    // 非路径型映射注解,忽略
+                }
+            }
+        }
+        return paths;
+    }
+
+    private boolean hasAnnotationType(Annotation[] annotations, String typeName) {
+        for (Annotation a : annotations) {
+            if (a.annotationType().getName().equals(typeName)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    private String capitalize(String s) {
+        if (s == null || s.isEmpty()) {
+            return s;
+        }
+        return Character.toUpperCase(s.charAt(0)) + s.substring(1);
+    }
+
+    private String readSchemaSql() {
+        try (InputStream is = getClass().getClassLoader().getResourceAsStream("schema.sql")) {
+            if (is == null) {
+                return "";
+            }
+            try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
+                scanner.useDelimiter("\\A");
+                return scanner.hasNext() ? scanner.next() : "";
+            }
+        } catch (Exception e) {
+            return "";
+        }
+    }
+}