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