|
|
@@ -0,0 +1,435 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import java.lang.reflect.Method;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
|
|
+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;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 契约级单测(TDD-RED,S-cfc-44):能力键 {@code team.admin.manage}(管理端团队分润管理,单元 admin)。
|
|
|
+ *
|
|
|
+ * <p><b>需求/设计溯源(规格 v0.7)</b>:
|
|
|
+ * {@code docs/superpowers/specs/2026-11-07-referral-team-profit-sharing.md}</p>
|
|
|
+ * <ul>
|
|
|
+ * <li>需求:<b>§12-T5 管理端(P2)</b>——成本价/星级档位(后台配置 #12)/团队门槛/团队奖分档配置
|
|
|
+ * + 分润流水查询与提现审核(复用现有管理端佣金模式)。</li>
|
|
|
+ * <li>设计:<b>§7.8</b> 配置项全部入 {@code service_config},{@code group_key='team_profit'}
|
|
|
+ * (star_tiers JSON / eight_share_ratio 80% / team_award_ratio 20% / team_threshold /
|
|
|
+ * share_total 8 / award_tiers / award_distribute platform_rule / star_weight 50-40-10);
|
|
|
+ * <b>§11-#12</b> 星级档位推荐值(0 / 8千 / 2.5万 / 7万 / 14万 / 25万)放入后台配置;
|
|
|
+ * <b>§7.3/§7.4</b> {@code team_share_record}(8 份分佣流水)与 {@code team_award_record}
|
|
|
+ * (团队奖流水)分润流水查询;<b>§8.2 + §11-#7</b> 提现审核复用
|
|
|
+ * {@code withdrawal_requests},type 区分 {@code team_share} / {@code team_award}。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>被校验的契约行为(T5 管理端五类能力)</b></p>
|
|
|
+ * <ol>
|
|
|
+ * <li><b>配置读取/保存</b>:{@code TeamProfitConfigService}——星级档位(#12 六档默认值)/
|
|
|
+ * 团队门槛/比率(8 份 80% / 团队奖 20%)/8 份分母 8/团队奖归属 platform_rule(#3 拍板)/
|
|
|
+ * 权重 50-40-10,读写 {@code service_config} 的 {@code group_key='team_profit'} 组,缺省回退默认值;</li>
|
|
|
+ * <li><b>成本价配置</b>:{@code ServiceCostConfig} 实体 + Mapper + {@code ServiceCostConfigService}
|
|
|
+ * (§7.5 服务成本价载体,管理端维护并供分润计算读取);</li>
|
|
|
+ * <li><b>分润流水查询</b>:{@code TeamProfitFlowService}——按接收人/团队/状态分页查询
|
|
|
+ * {@code team_share_record} / {@code team_award_record}(§7.3/§7.4);</li>
|
|
|
+ * <li><b>提现审核</b>:{@code WithdrawalService.getAdminList} 支持按 {@code type} 过滤
|
|
|
+ * {@code team_share} / {@code team_award}(#7 拍板,复用 withdrawal_requests);</li>
|
|
|
+ * <li><b>管理端接口与权限</b>:{@code TeamProfitAdminController}(/api/admin 前缀,统一 POST)+ 管理员角色门禁。</li>
|
|
|
+ * </ol>
|
|
|
+ *
|
|
|
+ * <p><b>实现现状(src/main 核实)</b>:{@code team_profit / TeamProfit / ProfitPool / ServiceCostConfig /
|
|
|
+ * TeamShareRecord / TeamAwardRecord} 全库 0 引用(无类、无表、无迁移、无 controller)。
|
|
|
+ * {@code WithdrawalService} 已存在但 {@code getAdminList(String, int, int)} 仅按 {@code status} 过滤,
|
|
|
+ * 无按 {@code type} 过滤的重载——契约新增行为未落地。</p>
|
|
|
+ *
|
|
|
+ * <p><b>测试风格</b>:与同批 RED 契约测试一致——纯反射锚定契约宿主(宿主类缺失即断言失败/红),
|
|
|
+ * 无 Spring / 无 DB / 无外部服务。宿主缺失时全部用例红,断言原文清晰可贴;
|
|
|
+ * GREEN 阶段实现宿主后本测试自动转绿。</p>
|
|
|
+ *
|
|
|
+ * <p><b>契约方法签名(实现方据此落地)</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li>{@code TeamProfitConfigService#int[] defaultStarTiers()} —— #12 默认六档 {0,8000,25000,70000,140000,250000}</li>
|
|
|
+ * <li>{@code TeamProfitConfigService#int[] getStarTiers()} —— 读 service_config(team_profit.star_tiers),缺省回退默认</li>
|
|
|
+ * <li>{@code TeamProfitConfigService#int getEightShareRatio() / getTeamAwardRatio() / getShareTotal()} —— 80 / 20 / 8</li>
|
|
|
+ * <li>{@code TeamProfitConfigService#String getAwardDistribute()} —— "platform_rule"(§4.4 #3 拍板)</li>
|
|
|
+ * <li>{@code TeamProfitConfigService#int[] getStarWeight()} —— {50,40,10}(§4.3 加权)</li>
|
|
|
+ * <li>{@code TeamProfitConfigService#void saveStarTiers(int[]) / saveConfig(String,String)} —— 管理端保存(非 6 档拒收)</li>
|
|
|
+ * <li>{@code TeamProfitFlowService#Page<?> queryTeamShareRecords(Long,String,int,int)} —— §7.3 流水查询</li>
|
|
|
+ * <li>{@code TeamProfitFlowService#Page<?> queryTeamAwardRecords(Long,String,int,int)} —— §7.4 流水查询</li>
|
|
|
+ * <li>{@code ServiceCostConfigService#int getCostCent(String) / int saveCostCent(String,String,int)} —— §7.5 成本价</li>
|
|
|
+ * <li>{@code WithdrawalService#Page<WithdrawalRequest> getAdminList(String type, int page, int size)} —— #7 type 过滤重载</li>
|
|
|
+ * </ul>
|
|
|
+ */
|
|
|
+class TeamProfitAdminManageContractTest {
|
|
|
+
|
|
|
+ /** 契约宿主(管理端团队分润配置服务,§7.8 + §11-#12) */
|
|
|
+ private static final String CFG_SERVICE = "com.etotem.cfc.service.TeamProfitConfigService";
|
|
|
+
|
|
|
+ /** 契约宿主(分润流水查询服务,§7.3/§7.4) */
|
|
|
+ private static final String FLOW_SERVICE = "com.etotem.cfc.service.TeamProfitFlowService";
|
|
|
+
|
|
|
+ /** 契约宿主(服务成本价配置服务,§7.5) */
|
|
|
+ private static final String COST_SERVICE = "com.etotem.cfc.service.ServiceCostConfigService";
|
|
|
+
|
|
|
+ /** §7.5 成本价实体/Mapper(新表 service_cost_config) */
|
|
|
+ private static final String COST_ENTITY = "com.etotem.cfc.entity.ServiceCostConfig";
|
|
|
+ private static final String COST_MAPPER = "com.etotem.cfc.mapper.ServiceCostConfigMapper";
|
|
|
+
|
|
|
+ /** §7.3/§7.4 流水实体(查询契约锚点,避免直接 import 未落地实体) */
|
|
|
+ private static final String SHARE_ENTITY = "com.etotem.cfc.entity.TeamShareRecord";
|
|
|
+ private static final String AWARD_ENTITY = "com.etotem.cfc.entity.TeamAwardRecord";
|
|
|
+
|
|
|
+ /** 管理端控制器(T5,/api/admin 前缀) */
|
|
|
+ private static final String ADMIN_CONTROLLER = "com.etotem.cfc.controller.admin.TeamProfitAdminController";
|
|
|
+
|
|
|
+ /** 已存在类:提现审核(复用 withdrawal_requests,#7 需新增 type 过滤契约) */
|
|
|
+ private static final String WITHDRAWAL_SERVICE = "com.etotem.cfc.service.WithdrawalService";
|
|
|
+
|
|
|
+ /** service_config 配置组(§7.8) */
|
|
|
+ private static final String GROUP_TEAM_PROFIT = "team_profit";
|
|
|
+
|
|
|
+ /** §11-#12 星级档位推荐默认值(0 / 8千 / 2.5万 / 7万 / 14万 / 25万) */
|
|
|
+ private static final int[] DEFAULT_STAR_TIERS = {0, 8000, 25000, 70000, 140000, 250000};
|
|
|
+
|
|
|
+ // ==================== 正常流 1/5:星级档位后台配置(§11-#12) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-1:TeamProfitConfigService 服务类应存在(§7.8 配置读写宿主)")
|
|
|
+ void configService_classExists() {
|
|
|
+ assertNotNull(loadClass(CFG_SERVICE),
|
|
|
+ "[team.admin.manage] TeamProfitConfigService 类不存在:契约要求按 §7.8 读写"
|
|
|
+ + " service_config group_key='team_profit' 配置组(T5 管理端配置)。实现未落地。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-2:星级档位默认配置应等于 #12 推荐值六档")
|
|
|
+ void starTiers_defaultTable_matchesSpecDecision12() {
|
|
|
+ Object host = contractHost(CFG_SERVICE);
|
|
|
+ int[] tiers = callIntArray(host, "defaultStarTiers");
|
|
|
+ assertNotNull(tiers, "defaultStarTiers() 应返回 #12 六档默认星级门槛");
|
|
|
+ assertArrayEquals(DEFAULT_STAR_TIERS, tiers,
|
|
|
+ "[team.admin.manage] 星级档位默认值须为 #12 已拍板推荐值 "
|
|
|
+ + Arrays.toString(DEFAULT_STAR_TIERS) + "(0/8千/2.5万/7万/14万/25万,后台可调)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-3:星级档位应支持后台配置覆盖(读 service_config.team_profit.star_tiers,缺省回退默认)")
|
|
|
+ void starTiers_configurableViaServiceConfig() {
|
|
|
+ Object host = contractHost(CFG_SERVICE);
|
|
|
+ int[] tiers = callIntArray(host, "getStarTiers");
|
|
|
+ assertNotNull(tiers, "getStarTiers() 应返回生效中的星级门槛(#12:后台配置可调)");
|
|
|
+ assertEquals(6, tiers.length,
|
|
|
+ "[team.admin.manage] 星级档位必须恒为 6 档(1-6 星一一对应),当前生效档位缺失。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-4:配置保存应校验六档并落 service_config(后台配置 #12)")
|
|
|
+ void saveStarTiers_validatesSixTiers() {
|
|
|
+ Object host = contractHost(CFG_SERVICE);
|
|
|
+ // 非法:5 档 → 拒绝(契约:星级必为 6 档)
|
|
|
+ expectContractMethodExists(host, "saveStarTiers", int[].class);
|
|
|
+ // 合法:六档 → 接受,写入 service_config group_key='team_profit'
|
|
|
+ expectContractMethodExists(host, "saveConfig", String.class, String.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 正常流 2/5:§7.8 团队分润比率/份额/权重/归属配置 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-5:8 份分佣池默认比率应为 T×80%(§7.8 team_profit.eight_share_ratio)")
|
|
|
+ void eightShareRatio_defaultIs80() {
|
|
|
+ Object host = contractHost(CFG_SERVICE);
|
|
|
+ assertEquals(80, callInt(host, "getEightShareRatio"),
|
|
|
+ "[team.admin.manage] team_profit.eight_share_ratio 默认 80(§7.8 + §11-#2 已拍板 80/20 拆分)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-6:团队奖池默认比率应为 T×20%(§7.8 team_profit.team_award_ratio)")
|
|
|
+ void teamAwardRatio_defaultIs20() {
|
|
|
+ Object host = contractHost(CFG_SERVICE);
|
|
|
+ assertEquals(20, callInt(host, "getTeamAwardRatio"),
|
|
|
+ "[team.admin.manage] team_profit.team_award_ratio 默认 20(§7.8 + §11-#2)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-7:8 份制分母默认应为 8(§7.8 team_profit.share_total)")
|
|
|
+ void shareTotal_defaultIs8() {
|
|
|
+ Object host = contractHost(CFG_SERVICE);
|
|
|
+ assertEquals(8, callInt(host, "getShareTotal"),
|
|
|
+ "[team.admin.manage] team_profit.share_total 默认 8(§4.2 8 份分佣池)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-8:团队奖归属于平台自有团队,平台统一规则(§7.8 award_distribute=platform_rule)")
|
|
|
+ void awardDistribute_defaultIsPlatformRule() {
|
|
|
+ Object host = contractHost(CFG_SERVICE);
|
|
|
+ assertEquals("platform_rule", callString(host, "getAwardDistribute"),
|
|
|
+ "[team.admin.manage] team_profit.award_distribute 默认 platform_rule(§4.4 #3 已拍板:"
|
|
|
+ + "归属平台自有团队、平台统一制定规则,不交负责人统筹)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-9:星级业绩分权重默认应为 50/40/10(§7.8 team_profit.star_weight)")
|
|
|
+ void starWeight_defaultIs50_40_10() {
|
|
|
+ Object host = contractHost(CFG_SERVICE);
|
|
|
+ assertArrayEquals(new int[]{50, 40, 10}, callIntArray(host, "getStarWeight"),
|
|
|
+ "[team.admin.manage] team_profit.star_weight 默认 50/40/10(§4.3 业绩分加权公式)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 正常流 3/5:服务成本价配置(§7.5,T5 成本价配置) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-10:ServiceCostConfig 实体与 Mapper 应存在(§7.5 成本价载体)")
|
|
|
+ void serviceCostConfig_entityAndMapperExist() {
|
|
|
+ assertNotNull(loadClass(COST_ENTITY),
|
|
|
+ "[team.admin.manage] ServiceCostConfig 实体不存在:§7.5 要求 service_cost_config 表承载"
|
|
|
+ + " 服务成本价 C,管理端(T5)维护成本价。");
|
|
|
+ assertNotNull(loadClass(COST_MAPPER),
|
|
|
+ "[team.admin.manage] ServiceCostConfigMapper 不存在:成本价配置落库读取须有 Mapper。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-11:ServiceCostConfigService 应提供成本价读取与保存(T5 成本价配置)")
|
|
|
+ void serviceCostConfigService_getAndSave() {
|
|
|
+ Object host = contractHost(COST_SERVICE);
|
|
|
+ expectContractMethodExists(host, "getCostCent", String.class);
|
|
|
+ expectContractMethodExists(host, "saveCostCent", String.class, String.class, int.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 正常流 4/5:分润流水查询(§7.3/§7.4,T5 流水查询) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-12:TeamProfitFlowService 应支持 8 份分佣流水查询(§7.3 team_share_record)")
|
|
|
+ void flowService_queryTeamShareRecords() {
|
|
|
+ Object host = contractHost(FLOW_SERVICE);
|
|
|
+ expectContractMethodExists(host, "queryTeamShareRecords",
|
|
|
+ Long.class, String.class, int.class, int.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-13:TeamProfitFlowService 应支持团队奖流水查询(§7.4 team_award_record)")
|
|
|
+ void flowService_queryTeamAwardRecords() {
|
|
|
+ Object host = contractHost(FLOW_SERVICE);
|
|
|
+ expectContractMethodExists(host, "queryTeamAwardRecords",
|
|
|
+ Long.class, String.class, int.class, int.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-14:流水查询依赖实体应携带分润业务列(§7.3/§7.4 查询结果字段)")
|
|
|
+ void flowEntities_carryQueryColumns() {
|
|
|
+ Class<?> share = loadClass(SHARE_ENTITY);
|
|
|
+ assertNotNull(share,
|
|
|
+ "[team.admin.manage] TeamShareRecord 实体不存在:管理端流水查询(§7.3)依赖该实体。");
|
|
|
+ for (String f : new String[]{"recipientUserId", "layer", "starLevel", "shares", "shareCent", "status"}) {
|
|
|
+ assertTrue(hasField(share, f),
|
|
|
+ "[team.admin.manage] TeamShareRecord 应含字段 " + f + "(§7.3 流水查询展示列)。");
|
|
|
+ }
|
|
|
+ Class<?> award = loadClass(AWARD_ENTITY);
|
|
|
+ assertNotNull(award,
|
|
|
+ "[team.admin.manage] TeamAwardRecord 实体不存在:管理端流水查询(§7.4)依赖该实体。");
|
|
|
+ for (String f : new String[]{"teamId", "ownerUserId", "tier", "awardCent", "status"}) {
|
|
|
+ assertTrue(hasField(award, f),
|
|
|
+ "[team.admin.manage] TeamAwardRecord 应含字段 " + f + "(§7.4 流水查询展示列)。");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 正常流 5/5:提现审核(复用 withdrawal_requests,type=team_share/team_award,§11-#7) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-15:提现审核列表应支持按来源 type=team_share/team_award 过滤(§11-#7)")
|
|
|
+ void withdrawalAdminList_supportsTeamSourceTypeFilter() {
|
|
|
+ Class<?> ws = loadClass(WITHDRAWAL_SERVICE);
|
|
|
+ assertNotNull(ws, "[team.admin.manage] WithdrawalService 应存在(已落地,复用提现审核)。");
|
|
|
+ Method type3 = findMethod(ws, "getAdminList", 4);
|
|
|
+ assertNotNull(type3,
|
|
|
+ "[team.admin.manage] WithdrawalService 缺少 type 过滤重载 getAdminList(String status,"
|
|
|
+ + " String type, int page, int size):§11-#7 要求提现审核按 team_share/team_award 区分"
|
|
|
+ + " 团队货币分润提现(当前仅按 status 过滤)。");
|
|
|
+ Class<?> wr = loadClass("com.etotem.cfc.entity.WithdrawalRequest");
|
|
|
+ assertNotNull(wr, "[team.admin.manage] WithdrawalRequest 实体应存在(已落地)。");
|
|
|
+ assertTrue(hasField(wr, "type"),
|
|
|
+ "[team.admin.manage] WithdrawalRequest 应含 type 字段(提现来源,联合主链断言)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 管理端接口与权限流:T5 管理端控制器 + 管理员角色门禁 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-16:TeamProfitAdminController 管理端控制器应存在(T5)")
|
|
|
+ void adminController_classExists() {
|
|
|
+ assertNotNull(loadClass(ADMIN_CONTROLLER),
|
|
|
+ "[team.admin.manage] TeamProfitAdminController 不存在:契约要求管理端提供配置保存/流水查询/提现审核"
|
|
|
+ + " 三类接口(§12-T5)。实现未落地。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("权限流-1:管理端接口应统一 POST 且挂 /api/admin 前缀(JWT 拦截 + 管理员门禁)")
|
|
|
+ void adminEndpoints_postOnlyUnderApiAdmin() {
|
|
|
+ Class<?> ctrl = loadClass(ADMIN_CONTROLLER);
|
|
|
+ if (ctrl == null) {
|
|
|
+ fail("[team.admin.manage] TeamProfitAdminController 不存在,无法校验接口前缀与 POST 约定"
|
|
|
+ + "(T5:/api/admin 前缀统一 @PostMapping,JwtInterceptor 拦截 + 管理员角色校验)。");
|
|
|
+ }
|
|
|
+ boolean postOnly = true;
|
|
|
+ int mappingAnnot = 0;
|
|
|
+ for (Method m : ctrl.getDeclaredMethods()) {
|
|
|
+ if (m.isAnnotationPresent(org.springframework.web.bind.annotation.PostMapping.class)) {
|
|
|
+ mappingAnnot++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ assertTrue(mappingAnnot > 0,
|
|
|
+ "[team.admin.manage] TeamProfitAdminController 应提供至少一个 @PostMapping 管理接口。");
|
|
|
+ assertTrue(postOnly,
|
|
|
+ "[team.admin.manage] TeamProfitAdminController 所有接口应统一 @PostMapping(项目禁止 GET/PUT/DELETE)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("权限流-2:配置/流水/审核接口须校验管理员角色(勿漏权限门禁)")
|
|
|
+ void permissionFlow_adminRoleGateRequired() {
|
|
|
+ // T5 为平台管理端能力:配置保存/流水查询/提现审核必须限制在管理员角色(/api/admin/** + role 校验)。
|
|
|
+ // 宿主 TeamProfitAdminController 未落地 → 权限门禁随之缺位,本契约显式红。
|
|
|
+ if (loadClass(ADMIN_CONTROLLER) == null) {
|
|
|
+ fail("[team.admin.manage] TeamProfitAdminController 未实现:管理端配置/流水/审核接口的"
|
|
|
+ + " 管理员角色门禁(JwtInterceptor 拦截 /api/admin/** + role=admin)随之缺位。");
|
|
|
+ }
|
|
|
+ // 宿主已落地时:断言任意管理接口所在类均可被 /api/admin 前缀规则覆盖(保持 JWT 拦截)。
|
|
|
+ assertTrue(true, "权限流需要宿主落地后做行为级验证:此处仅断言门禁载体存在。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 边界流:配置健壮性 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("边界流-1:正在使用的星级档位必须恰好 6 档,档位异常时不得静默放行")
|
|
|
+ void boundary_starTiers_strictlySix() {
|
|
|
+ Object host = contractHost(CFG_SERVICE);
|
|
|
+ int[] tiers = callIntArray(host, "getStarTiers");
|
|
|
+ if (tiers != null && tiers.length != 6) {
|
|
|
+ fail("[team.admin.manage] 生效星级档位数=" + tiers.length
|
|
|
+ + " ≠ 6:§11-#12 星级语义为 1-6 星六档,配置异常不得静默放行。");
|
|
|
+ }
|
|
|
+ // 档位递增不越序(多劳多得语义:门槛单调递增)
|
|
|
+ assertNotNull(tiers, "[team.admin.manage] getStarTiers() 不可返回 null(缺省须回退 #12 默认值)。");
|
|
|
+ for (int i = 1; i < tiers.length; i++) {
|
|
|
+ assertTrue(tiers[i] > tiers[i - 1],
|
|
|
+ "[team.admin.manage] 星级档位门槛应单调递增(第" + (i + 1) + "档 ≤ 第" + i + "档)。");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("边界流-2:未知配置组读取应返回空,不得抛出使管理端崩溃")
|
|
|
+ void boundary_unknownGroup_returnsEmpty() {
|
|
|
+ // 契约:readConfig(unknownGroup) 对未配置组返回空集合(配置缺省=平台默认,不进分润)
|
|
|
+ if (loadClass(CFG_SERVICE) == null) {
|
|
|
+ fail("[team.admin.manage] TeamProfitConfigService 未实现:未知配置组/缺省回退行为(§7.8 缺省)无法验证。");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 反射辅助(锚定契约宿主,避免编译期依赖未落地类) ====================
|
|
|
+
|
|
|
+ private Class<?> loadClass(String name) {
|
|
|
+ try {
|
|
|
+ return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
|
|
|
+ } catch (ClassNotFoundException e) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean hasField(Class<?> clazz, String field) {
|
|
|
+ for (java.lang.reflect.Field f : clazz.getDeclaredFields()) {
|
|
|
+ if (f.getName().equals(field)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Method findMethod(Class<?> clazz, String name, int paramCount) {
|
|
|
+ for (Method m : clazz.getDeclaredMethods()) {
|
|
|
+ if (m.getName().equals(name) && m.getParameterCount() == paramCount) {
|
|
|
+ return m;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 实例化契约宿主;宿主缺失即断言失败(红)。 */
|
|
|
+ private Object contractHost(String fqcn) {
|
|
|
+ try {
|
|
|
+ Class<?> clazz = Class.forName(fqcn);
|
|
|
+ return clazz.getDeclaredConstructor().newInstance();
|
|
|
+ } catch (ClassNotFoundException e) {
|
|
|
+ fail("RED[team.admin.manage] 契约宿主 " + simpleName(fqcn) + " 未实现(实现未落地):"
|
|
|
+ + e.getMessage());
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ } catch (Exception e) {
|
|
|
+ fail("契约宿主 " + fqcn + " 实例化失败:" + e.getMessage());
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 契约方法存在性断言:签名(方法名 + 参数类型)逐项精确匹配;宿主缺失/方法缺失即失败。 */
|
|
|
+ private void expectContractMethodExists(Object host, String method, Class<?>... paramTypes) {
|
|
|
+ String name = simpleName(host.getClass().getName()) + "#" + method;
|
|
|
+ for (Method m : host.getClass().getMethods()) {
|
|
|
+ if (m.getName().equals(method) && Arrays.equals(m.getParameterTypes(), paramTypes)) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ fail("RED[team.admin.manage] 契约方法 " + name + " 未实现:GREEN 阶段按 javadoc 签名落地。");
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 按方法名 + 参数个数调用(宽松适配基本/包装类型);宿主或方法缺失即断言失败。 */
|
|
|
+ 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("契约方法 " + simpleName(host.getClass().getName()) + "#" + method
|
|
|
+ + " 调用失败:" + e.getMessage());
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ fail("RED[team.admin.manage] 契约方法 " + simpleName(host.getClass().getName()) + "#" + 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 String callString(Object host, String method, Object... args) {
|
|
|
+ Object r = call(host, method, args);
|
|
|
+ if (r instanceof String) {
|
|
|
+ return (String) r;
|
|
|
+ }
|
|
|
+ fail("契约方法 " + method + " 应返回 String,实际 " + r);
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ }
|
|
|
+
|
|
|
+ private int[] callIntArray(Object host, String method, Object... args) {
|
|
|
+ Object r = call(host, method, args);
|
|
|
+ if (r instanceof int[]) {
|
|
|
+ return (int[]) r;
|
|
|
+ }
|
|
|
+ fail("契约方法 " + method + " 应返回 int[],实际 " + r);
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ }
|
|
|
+
|
|
|
+ private String simpleName(String fqcn) {
|
|
|
+ int idx = fqcn.lastIndexOf('.');
|
|
|
+ return idx >= 0 ? fqcn.substring(idx + 1) : fqcn;
|
|
|
+ }
|
|
|
+}
|