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

merge(task/S-cfc-41): RED 契约级单测并入主链

Agnes Agent 5 дней назад
Родитель
Сommit
372f5a35bf

+ 183 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/ContractReflect.java

@@ -0,0 +1,183 @@
+package com.etotem.cfc.service;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * RED 契约测试反射工具(仅测试代码,S-cfc-41 / team.profit.settle)。
+ *
+ * <p>能力键 {@code team.profit.settle}(单元 team)对应 2026-11-07 契约新增能力点,实现尚未落地
+ * (cfclub 基线无 ProfitPoolService / TeamProfitService / StarScoreCalculator 及四张新表实体)。
+ * 契约级单测以「字符串定位契约类/方法 + 反射调用」方式编写:类或方法缺失时以<b>断言失败(RED)</b>
+ * 暴露「实现未落地」,而非编译失败;GREEN 实现落地后同一批测试自动转为行为校验。
+ *
+ * <p>规格锚点:docs/superpowers/specs/2026-11-07-referral-team-profit-sharing.md v0.7
+ * §12-T2(建表 + ProfitPoolService + TeamProfitService)+ §2.1 分配公式 + §4.2 8 份分配(#5 拍板)
+ * + §4.3 星级 + §4.4 团队奖(#3 拍板 platform_rule)+ §7.1/7.3/7.4/7.5 建表。
+ */
+final class ContractReflect {
+
+    private ContractReflect() {
+    }
+
+    /**
+     * 定位契约类;不存在则抛断言失败(RED)。
+     *
+     * @param fqcn    契约类全限定名
+     * @param specRef 规格锚点(如 §12-T2 / §7.1)
+     */
+    static Class<?> requireClass(String fqcn, String specRef) {
+        try {
+            return Class.forName(fqcn);
+        } catch (ClassNotFoundException e) {
+            fail("RED: 契约类未实现 [" + specRef + "] " + fqcn + " —— " + e.getMessage());
+            throw new IllegalStateException("unreachable: fail() must throw");
+        }
+    }
+
+    /**
+     * 无参构造实例化契约类(cfc-backend 注入约定为 @Resource 字段注入,服务类均有无参构造)。
+     */
+    static Object newInstance(Class<?> clazz) {
+        try {
+            return clazz.getDeclaredConstructor().newInstance();
+        } catch (ReflectiveOperationException e) {
+            fail("RED: 契约类不可无参实例化 " + clazz.getName() + " —— " + e);
+            throw new IllegalStateException("unreachable: fail() must throw");
+        }
+    }
+
+    /**
+     * 按字段名向实例注入值(含父类字段)。契约要求字段名与类型默认 Bean Name 一致(项目 @Resource 约定),
+     * 例如 {@code profitPoolOrderMapper}、{@code configService}。
+     */
+    static void setField(Object target, String fieldName, Object value) {
+        Field f = findField(target.getClass(), fieldName);
+        if (f == null) {
+            fail("RED: 契约字段未定义 " + target.getClass().getSimpleName() + "." + fieldName
+                    + " —— 契约要求以该字段名注入(@Resource 约定)");
+            return;
+        }
+        try {
+            f.setAccessible(true);
+            f.set(target, value);
+        } catch (IllegalAccessException e) {
+            fail("RED: 契约字段不可写 " + target.getClass().getSimpleName() + "." + fieldName + " —— " + e);
+        }
+    }
+
+    /**
+     * 读取契约对象上的字段值(含父类字段)。
+     */
+    @SuppressWarnings("unchecked")
+    static <T> T getField(Object target, String fieldName) {
+        Field f = findField(target.getClass(), fieldName);
+        if (f == null) {
+            fail("RED: 契约字段未定义 " + target.getClass().getSimpleName() + "." + fieldName);
+            return null;
+        }
+        try {
+            f.setAccessible(true);
+            return (T) f.get(target);
+        } catch (IllegalAccessException e) {
+            fail("RED: 契约字段不可读 " + target.getClass().getSimpleName() + "." + fieldName + " —— " + e);
+            return null;
+        }
+    }
+
+    /** 读取 long 型契约字段(Integer/Long 均可,自动解包)。 */
+    static long getLong(Object target, String fieldName) {
+        Object v = getField(target, fieldName);
+        if (v == null) {
+            return 0L;
+        }
+        if (!(v instanceof Number)) {
+            fail("RED: 契约字段非数值 " + target.getClass().getSimpleName() + "." + fieldName
+                    + ",实际 " + v.getClass().getSimpleName());
+            return 0L;
+        }
+        return ((Number) v).longValue();
+    }
+
+    /** 读取 int 型契约字段(Integer/Long 均可,自动解包)。 */
+    static int getInt(Object target, String fieldName) {
+        Object v = getField(target, fieldName);
+        if (v == null) {
+            return 0;
+        }
+        if (!(v instanceof Number)) {
+            fail("RED: 契约字段非数值 " + target.getClass().getSimpleName() + "." + fieldName
+                    + ",实际 " + v.getClass().getSimpleName());
+            return 0;
+        }
+        return ((Number) v).intValue();
+    }
+
+    /** 读取 String 型契约字段。 */
+    static String getString(Object target, String fieldName) {
+        Object v = getField(target, fieldName);
+        if (v == null) {
+            return null;
+        }
+        if (!(v instanceof String)) {
+            fail("RED: 契约字段非字符串 " + target.getClass().getSimpleName() + "." + fieldName
+                    + ",实际 " + v.getClass().getSimpleName());
+            return null;
+        }
+        return (String) v;
+    }
+
+    /**
+     * 确认契约类声明了指定字段(仅字段存在性检查)。
+     */
+    static void requireField(Class<?> clazz, String fieldName, String specRef) {
+        if (findField(clazz, fieldName) == null) {
+            fail("RED: 契约字段缺失 [" + specRef + "] " + clazz.getSimpleName() + "." + fieldName);
+        }
+    }
+
+    /**
+     * 调用契约方法;方法缺失或执行异常均以断言失败暴露(RED),并给出契约签名以便 GREEN 对齐。
+     */
+    static Object invoke(Object target, String methodName, Class<?>[] paramTypes, Object[] args) {
+        Class<?> c = target.getClass();
+        try {
+            Method m = c.getMethod(methodName, paramTypes);
+            return m.invoke(target, args);
+        } catch (NoSuchMethodException e) {
+            fail("RED: 契约方法未实现 " + c.getSimpleName() + "." + methodName + "(" + signature(paramTypes) + ")"
+                    + " —— 契约要求该方法(GREEN 实现须与本测试签名一致)");
+        } catch (InvocationTargetException e) {
+            Throwable cause = e.getCause() == null ? e : e.getCause();
+            fail("契约方法执行异常 " + c.getSimpleName() + "." + methodName + " —— " + cause);
+        } catch (IllegalAccessException | IllegalArgumentException e) {
+            fail("契约方法不可调用 " + c.getSimpleName() + "." + methodName + " —— " + e);
+        }
+        throw new IllegalStateException("unreachable: fail() must throw");
+    }
+
+    private static Field findField(Class<?> c, String name) {
+        while (c != null) {
+            try {
+                return c.getDeclaredField(name);
+            } catch (NoSuchFieldException e) {
+                c = c.getSuperclass();
+            }
+        }
+        return null;
+    }
+
+    private static String signature(Class<?>[] types) {
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < types.length; i++) {
+            if (i > 0) {
+                sb.append(", ");
+            }
+            sb.append(types[i].getSimpleName());
+        }
+        return sb.toString();
+    }
+}

+ 169 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/ProfitPoolOrderContractRedTest.java

@@ -0,0 +1,169 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.config.ConfigService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import static com.etotem.cfc.service.ContractReflect.getLong;
+import static com.etotem.cfc.service.ContractReflect.getString;
+import static com.etotem.cfc.service.ContractReflect.invoke;
+import static com.etotem.cfc.service.ContractReflect.newInstance;
+import static com.etotem.cfc.service.ContractReflect.requireClass;
+import static com.etotem.cfc.service.ContractReflect.setField;
+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.mockito.ArgumentMatchers.anyDouble;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测(RED,S-cfc-41 / team.profit.settle · ProfitPoolService §2.1 总账模型)。
+ * 规格:docs/superpowers/specs/2026-11-07-referral-team-profit-sharing.md v0.7 §12-T2 + §2.1 + §7.1 + §9#3
+ *
+ * <p><b>契约(本测试定义,GREEN 实现须满足)</b>:</p>
+ * <pre>
+ *   ProfitPoolService.settle(Long orderId, String orderType, Long productId, long amountCent, long costCent) : ProfitPoolOrder
+ *   字段注入契约(@Resource,字段名=类型默认 Bean Name):
+ *     - configService            (com.etotem.cfc.config.ConfigService)
+ *     - profitPoolOrderMapper    (com.etotem.cfc.mapper.ProfitPoolOrderMapper)
+ * </pre>
+ *
+ * <p><b>数值口径(§2.1,金额单位:分;基准示例 S=1000元 / C=800元)</b>:</p>
+ * <pre>
+ *   P = S - C                                    (平台留存)
+ *   O = round(P × cfg_platform_ops_rate   =10%)  (平台运营)
+ *   F = round(S × cfg_cf_cost_rate        =1%)   (CF 值固定成本,消费额口径)
+ *   D = P - O - F                                (可分配池;D ≤ 0 不触发分润,§2.2/§9#3)
+ *   R = round(D × cfg_referral_share      =70%)  (推荐人体系,CF 值预算参照)
+ *   T = round(D × cfg_team_share          =30%)  (团队体系,货币)
+ *   R + T = D;8份池 = round(T × 80%);团队奖池 = round(T × 20%);8份池 + 团队奖池 = T
+ * </pre>
+ *
+ * <p>基准数值(用户示例,§2):S=100000分 → P=20000、O=2000、F=1000、D=17000、
+ * R=11900、T=5100、8份池=4080、团队奖=1020。</p>
+ *
+ * <p>权限流不适用(T2 为服务层契约,无 HTTP 端点/角色,标注 N/A)。</p>
+ */
+class ProfitPoolOrderContractRedTest {
+
+    private static final String M_SETTLE = "settle";
+    private static final Class<?>[] SETTLE_PARAMS = {Long.class, String.class, Long.class, long.class, long.class};
+
+    private static final long ORDER_ID = 9001L;
+    private static final String ORDER_TYPE = "service";
+    private static final long PRODUCT_ID = 11L;
+
+    private ConfigService configService;
+    private Object profitPoolOrderMapper;
+
+    @BeforeEach
+    void setUp() {
+        configService = Mockito.mock(ConfigService.class);
+        stubRate("referral_profit.ops_rate", 0.10);
+        stubRate("referral_profit.cf_cost_rate", 0.01);
+        stubRate("referral_profit.referral_share", 0.70);
+        stubRate("referral_profit.team_share", 0.30);
+        stubRate("team_profit.eight_share_ratio", 0.80);
+        stubRate("team_profit.team_award_ratio", 0.20);
+        when(configService.getIntConfig(eq("team_profit.share_total"), anyInt())).thenReturn(8);
+    }
+
+    private void stubRate(String key, double value) {
+        when(configService.getDoubleConfig(eq(key), anyDouble())).thenReturn(value);
+    }
+
+    // ==================== 契约实例 ====================
+
+    private Object mapper() {
+        if (profitPoolOrderMapper == null) {
+            profitPoolOrderMapper =
+                    Mockito.mock(requireClass(TeamProfitContract.MAPPER_PROFIT_POOL, "§7.1/§12-T2"));
+        }
+        return profitPoolOrderMapper;
+    }
+
+    private Object service() {
+        Object service = newInstance(requireClass(TeamProfitContract.SERVICE_PROFIT_POOL, "§12-T2"));
+        setField(service, "configService", configService);
+        setField(service, "profitPoolOrderMapper", mapper());
+        return service;
+    }
+
+    private Object settle(long amountCent, long costCent) {
+        Object result = invoke(service(), M_SETTLE, SETTLE_PARAMS,
+                new Object[]{ORDER_ID, ORDER_TYPE, PRODUCT_ID, amountCent, costCent});
+        assertNotNull(result, "契约: settle 应返回 ProfitPoolOrder");
+        return result;
+    }
+
+    // ==================== 正常流(用户示例基准) ====================
+
+    /** AC: §2 用户示例 S=1000元/C=800元 → D=170元(17000分);各分项与 §2.1 公式一致 */
+    @Test
+    void settle_userExample_fullFunnelNumbers() {
+        Object p = settle(100000L, 80000L);
+        assertEquals(ORDER_ID, getLong(p, "orderId"));
+        assertEquals(ORDER_TYPE, getString(p, "orderType"));
+        assertEquals(PRODUCT_ID, getLong(p, "productId"));
+        assertEquals(100000L, getLong(p, "amountCent"));
+        assertEquals(80000L, getLong(p, "costCent"));
+        assertEquals(20000L, getLong(p, "platformRetainCent"), "P = S - C = 20000");
+        assertEquals(2000L, getLong(p, "opsCent"), "O = round(P×10%) = 2000");
+        assertEquals(1000L, getLong(p, "cfCostCent"), "F = round(S×1%) = 1000");
+        assertEquals(17000L, getLong(p, "poolCent"), "D = P - O - F = 17000");
+        assertEquals(11900L, getLong(p, "referralShareCent"), "R = round(D×70%) = 11900");
+        assertEquals(5100L, getLong(p, "teamShareCent"), "T = round(D×30%) = 5100");
+        assertEquals(4080L, getLong(p, "teamEightShareCent"), "8份池 = round(T×80%) = 4080");
+        assertEquals(1020L, getLong(p, "teamAwardCent"), "团队奖池 = round(T×20%) = 1020");
+        assertEquals("pending", getString(p, "status"), "§8.1: 初始状态 pending");
+    }
+
+    /** AC: 预算守恒 R+T=D、8份+团队奖=T(§2.1,恒不超 D 预算 §4.7 杠杆2) */
+    @Test
+    void settle_userExample_budgetConservation() {
+        Object p = settle(100000L, 80000L);
+        long pool = getLong(p, "poolCent");
+        long referral = getLong(p, "referralShareCent");
+        long team = getLong(p, "teamShareCent");
+        long eight = getLong(p, "teamEightShareCent");
+        long award = getLong(p, "teamAwardCent");
+        assertEquals(pool, referral + team, "R + T = D");
+        assertEquals(team, eight + award, "8份池 + 团队奖池 = T");
+    }
+
+    // ==================== 边界流 ====================
+
+    /** AC: §2.2/§9#3 D ≤ 0(低于保本线)→ 整单不产生推荐人/团队分润,仅落 profit_pool_order 供统计 */
+    @Test
+    void settle_belowBreakeven_noProfitSharing() {
+        // S=800.00, C=800.00 → P=0, O=0, F=800, D=-800 ≤ 0
+        Object p = settle(80000L, 80000L);
+        assertEquals(0L, getLong(p, "platformRetainCent"));
+        assertEquals(0L, getLong(p, "opsCent"));
+        assertEquals(800L, getLong(p, "cfCostCent"));
+        assertTrue(getLong(p, "poolCent") <= 0L, "D ≤ 0 不触发分润");
+        assertEquals(0L, getLong(p, "referralShareCent"), "D ≤ 0 时 R = 0");
+        assertEquals(0L, getLong(p, "teamShareCent"), "D ≤ 0 时 T = 0");
+        assertEquals(0L, getLong(p, "teamEightShareCent"));
+        assertEquals(0L, getLong(p, "teamAwardCent"));
+        assertEquals("pending", getString(p, "status"), "仍落 profit_pool_order 供统计(§9#3)");
+    }
+
+    /** AC: 金额取整口径——逐项 round(Math.round / BigDecimal HALF_UP 等价),R+T=D 不留尾差 */
+    @Test
+    void settle_rounding_standardMoney() {
+        // S=100000, C=87500 → P=12500, O=round(1250)=1250, F=1000, D=10250
+        // R=round(10250×0.70)=7175, T=round(10250×0.30)=3075, R+T=10250=D ✓
+        Object p = settle(100000L, 87500L);
+        long pool = getLong(p, "poolCent");
+        long referral = getLong(p, "referralShareCent");
+        long team = getLong(p, "teamShareCent");
+        assertEquals(10250L, pool);
+        assertEquals(7175L, referral, "R = round(D×70%) = 7175");
+        assertEquals(3075L, team, "T = round(D×30%) = 3075");
+        assertEquals(pool, referral + team, "R + T = D 即使含取整");
+    }
+}

+ 150 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/StarScoreCalculatorContractRedTest.java

@@ -0,0 +1,150 @@
+package com.etotem.cfc.service;
+
+import org.junit.jupiter.api.Test;
+
+import static com.etotem.cfc.service.ContractReflect.invoke;
+import static com.etotem.cfc.service.ContractReflect.newInstance;
+import static com.etotem.cfc.service.ContractReflect.requireClass;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 契约级单测(RED,S-cfc-41 / team.profit.settle · StarScoreCalculator 星级 §4.3)。
+ * 规格:docs/superpowers/specs/2026-11-07-referral-team-profit-sharing.md v0.7 §12-T2 + §4.3 + §4.3.1
+ *
+ * <p><b>契约(本测试定义,GREEN 实现须满足)</b>:</p>
+ * <pre>
+ *   StarScoreCalculator.calculateScore(long directServiceAmount, long teamServiceAmount, long referralConsumeAmount) : long
+ *   StarScoreCalculator.resolveStarLevel(long score) : int                       // 1~6
+ *   StarScoreCalculator.isPromotable(long score, int directValidNewRefs, int targetStar) : boolean
+ * </pre>
+ *
+ * <p><b>业绩分公式(§4.3,金额单位:元口径)</b>:</p>
+ * <pre>
+ *   Score = 直接服务收入 × 50% + 团队服务收入 × 40% + 推荐用户平台消费收入 × 10%
+ * </pre>
+ *
+ * <p><b>星级↔档位映射(§4.3,可配 team_profit.star_tiers,推荐默认值)</b>:</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>升级双门槛(主理人意见⑥ / #15 拍板)</b>:升级条件 = 业绩分 ≥ 目标档门槛 AND 本窗口团队直推 ≥ 1;
+ * 两条件缺一不可(§4.3 判定规则)。一星为入团基线,无直推门槛。</p>
+ *
+ * <p>权限流不适用(服务层契约,N/A)。</p>
+ */
+class StarScoreCalculatorContractRedTest {
+
+    /** 契约方法签名常量 */
+    private static final String M_SCORE = "calculateScore";
+    private static final String M_STAR = "resolveStarLevel";
+    private static final String M_PROMOTABLE = "isPromotable";
+
+    // ==================== 契约实例 ====================
+
+    private Object calculator() {
+        return newInstance(requireClass(TeamProfitContract.SERVICE_STAR, "§12-T2/§4.3"));
+    }
+
+    private long score(long direct, long team, long referral) {
+        Object r = invoke(calculator(), M_SCORE, new Class<?>[]{long.class, long.class, long.class},
+                new Object[]{direct, team, referral});
+        return ((Number) r).longValue();
+    }
+
+    private int star(long scoreValue) {
+        Object r = invoke(calculator(), M_STAR, new Class<?>[]{long.class}, new Object[]{scoreValue});
+        return ((Number) r).intValue();
+    }
+
+    private boolean promotable(long scoreValue, int refs, int targetStar) {
+        Object r = invoke(calculator(), M_PROMOTABLE,
+                new Class<?>[]{long.class, int.class, int.class}, new Object[]{scoreValue, refs, targetStar});
+        return (Boolean) r;
+    }
+
+    // ==================== 业绩分(§4.3 公式) ====================
+
+    /** AC(§4.3): Score = 直接×50% + 团队×40% + 推荐×10% */
+    @Test
+    void calculateScore_weighted_50_40_10() {
+        assertEquals(10000L, score(10000L, 10000L, 10000L), "5000+4000+1000 = 10000");
+    }
+
+    /** AC(§4.3): 三维构成示例(§4.3.1 ①:50,000 / 20,000 / 2,000)加权 */
+    @Test
+    void calculateScore_dashboardExample() {
+        assertEquals(33200L, score(50000L, 20000L, 2000L), "25000+8000+200 = 33200");
+    }
+
+    /** AC(§4.3): 无业绩 → 0 分(一星基线) */
+    @Test
+    void calculateScore_zero_referralOnlyZero() {
+        assertEquals(0L, score(0L, 0L, 0L));
+    }
+
+    // ==================== 星级档位(§4.3) ====================
+
+    /** AC(§4.3 档位表): 门槛 0/8000 → 一星/二星边界 */
+    @Test
+    void resolveStarLevel_boundary_1star_to_2star() {
+        assertEquals(1, star(0L), "一星门槛 ≥0(入团即一星)");
+        assertEquals(1, star(7999L), "7999 < 8000 → 一星");
+        assertEquals(2, star(8000L), "8000 → 二星");
+        assertEquals(2, star(24999L), "24999 < 25000 → 二星");
+    }
+
+    /** AC(§4.3 档位表): 门槛 25000/70000 → 三星/四星边界 */
+    @Test
+    void resolveStarLevel_boundary_3star_to_4star() {
+        assertEquals(3, star(25000L), "25000 → 三星");
+        assertEquals(3, star(69999L), "69999 < 70000 → 三星");
+        assertEquals(4, star(70000L), "70000 → 四星");
+        assertEquals(4, star(139999L), "139999 < 140000 → 四星");
+    }
+
+    /** AC(§4.3 档位表): 门槛 140000/250000 → 五星/六星边界,六星为上限 */
+    @Test
+    void resolveStarLevel_boundary_5star_to_6star() {
+        assertEquals(5, star(140000L), "140000 → 五星");
+        assertEquals(5, star(249999L), "249999 < 250000 → 五星");
+        assertEquals(6, star(250000L), "250000 → 六星(最多)");
+        assertEquals(6, star(500000L), "超六星门槛仍为六星");
+    }
+
+    // ==================== 升级双门槛(§4.3,主观意⑥/#15 拍板) ====================
+
+    /** AC(§4.3 判定规则): 业绩分达标 AND 团队直推 ≥1 才可升级到目标档 */
+    @Test
+    void isPromotable_meetsBothGate_true() {
+        assertTrue(promotable(25000L, 1, 3), "25000分 + 1直推 → 可升三星");
+        assertTrue(promotable(70000L, 2, 4), "70000分 + 2直推 → 可升四星");
+    }
+
+    /** AC(#15 拍板): 业绩分够但团队直推 = 0 → 不可升级(两条件缺一不可) */
+    @Test
+    void isPromotable_noTeamReferral_false() {
+        assertFalse(promotable(25000L, 0, 3), "25000分但 0 直推 → 不可升三星");
+        assertFalse(promotable(250000L, 0, 6), "六星同样要求直推≥1");
+    }
+
+    /** AC(§4.3): 业绩分未达目标档门槛 → 不可升级 */
+    @Test
+    void isPromotable_scoreBelowThreshold_false() {
+        assertFalse(promotable(24999L, 1, 3), "24999 < 25000 不可升三星");
+        assertFalse(promotable(69999L, 1, 4), "69999 < 70000 不可升四星");
+    }
+
+    /** AC(§4.3): 一星为入团基线,星≥1 的基线判定恒可通过 */
+    @Test
+    void isPromotable_star1_baseline_alwaysPass() {
+        assertTrue(promotable(0L, 0, 1), "入团即一星,无需业绩分/直推");
+    }
+}

+ 133 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/TeamAwardRecordContractRedTest.java

@@ -0,0 +1,133 @@
+package com.etotem.cfc.service;
+
+import org.junit.jupiter.api.Test;
+
+import static com.etotem.cfc.service.ContractReflect.getLong;
+import static com.etotem.cfc.service.ContractReflect.getString;
+import static com.etotem.cfc.service.ContractReflect.invoke;
+import static com.etotem.cfc.service.ContractReflect.newInstance;
+import static com.etotem.cfc.service.ContractReflect.requireClass;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * 契约级单测(RED,S-cfc-41 / team.profit.settle · TeamProfitService 团队奖 §4.4)。
+ * 规格:docs/superpowers/specs/2026-11-07-referral-team-profit-sharing.md v0.7 §12-T2 + §4.4(#3 拍板)+ §7.4 + §9#5
+ *
+ * <p><b>契约(本测试定义,GREEN 实现须满足)</b>:</p>
+ * <pre>
+ *   TeamProfitService.buildTeamAward(Long poolId, Long orderId, String orderType, Long teamId, Long ownerUserId,
+ *                                   long teamAwardPoolCent, long teamThresholdCent, long teamRollingRevenueCent)
+ *       : TeamAwardRecord
+ * </pre>
+ *
+ * <p><b>分档规则(§4.4)</b>(thr = teamThresholdCent 团队年度滚动业绩门槛):</p>
+ * <pre>
+ *   rev &lt;  thr          → TA0  0%   (不进入分红,回流平台)
+ *   thr ≤ rev &lt;  2×thr   → TA1  50%
+ *   2×thr ≤ rev &lt; 4×thr  → TA2  80%
+ *   rev ≥  4×thr         → TA3  100%
+ *   awardCent = round(团队奖池 × 比例)
+ * </pre>
+ *
+ * <p><b>归属(#3 已拍板 v0.7)</b>:团队奖归属<b>平台自有团队</b>(规划师/测评师等平台服务人员),
+ * 分配规则由平台统一制定(§7.8 team_profit.award_distribute 默认 platform_rule),不交给外部引荐链负责人自由统筹;
+ * 发放对象 = 服务订单所属平台服务团队(owner_user_id = 该团队负责人/根,§4.4/§4.5)。</p>
+ *
+ * <p>权限流不适用(服务层契约,N/A)。</p>
+ */
+class TeamAwardRecordContractRedTest {
+
+    private static final String M_BUILD_AWARD = "buildTeamAward";
+    private static final Class<?>[] BUILD_AWARD_PARAMS =
+            {Long.class, Long.class, String.class, Long.class, Long.class, long.class, long.class, long.class};
+
+    private static final long POOL_ID = 1001L;
+    private static final long ORDER_ID = 9001L;
+    private static final String ORDER_TYPE = "service";
+    private static final long TEAM_ID = 55L;      // distribution_systems.id(§4.5 平台服务团队)
+    private static final long OWNER = 901L;       // 平台自有团队负责人(#3 拍板,非引荐链负责人)
+    private static final long AWARD_POOL = 1020L; // §2 示例 T×20% = 51×20% = 10.2元 = 1020分
+    private static final long THRESHOLD = 100000L; // 团队年度滚动业绩门槛(分口径,取整便于断言)
+
+    // ==================== 契约实例 ====================
+
+    private Object service() {
+        return newInstance(requireClass(TeamProfitContract.SERVICE_TEAM, "§12-T2/§4.4"));
+    }
+
+    private Object buildAward(long revenueCent) {
+        Object result = invoke(service(), M_BUILD_AWARD, BUILD_AWARD_PARAMS,
+                new Object[]{POOL_ID, ORDER_ID, ORDER_TYPE, TEAM_ID, OWNER, AWARD_POOL, THRESHOLD, revenueCent});
+        assertNotNull(result, "契约: buildTeamAward 应返回 TeamAwardRecord");
+        if (!TeamProfitContract.ENTITY_TEAM_AWARD.equals(result.getClass().getName())) {
+            fail("RED: buildTeamAward 返回类型应为 TeamAwardRecord,实际 " + result.getClass().getName());
+        }
+        return result;
+    }
+
+    // ==================== 正常流(分档) ====================
+
+    /** AC(§4.4): 团队年度滚动业绩 ≥ 门槛×4 → TA3 档 100%,负责人得 10.2 元(1020 分) */
+    @Test
+    void revenueAt4xThreshold_TA3_100Percent() {
+        Object r = buildAward(4 * THRESHOLD);
+        assertEquals("TA3", getString(r, "tier"));
+        assertEquals(1020L, getLong(r, "awardCent"), "TA3 100% × 1020 = 1020");
+        assertOwnerFields(r);
+    }
+
+    /** AC(§4.4): 门槛 ~ 门槛×2 → TA1 档 50% */
+    @Test
+    void revenueAtThreshold_TA1_50Percent() {
+        Object r = buildAward(THRESHOLD);
+        assertEquals("TA1", getString(r, "tier"));
+        assertEquals(510L, getLong(r, "awardCent"), "TA1 50% × 1020 = 510");
+    }
+
+    /** AC(§4.4): TA1 区间内(门槛 < rev < 2×门槛) */
+    @Test
+    void revenueMidTA1_stillTA1() {
+        Object r = buildAward(THRESHOLD + THRESHOLD / 2); // 1.5×门槛
+        assertEquals("TA1", getString(r, "tier"));
+        assertEquals(510L, getLong(r, "awardCent"));
+    }
+
+    /** AC(§4.4): 2×门槛 ≤ rev < 4×门槛 → TA2 档 80% */
+    @Test
+    void revenueAt2xThreshold_TA2_80Percent() {
+        Object r = buildAward(2 * THRESHOLD);
+        assertEquals("TA2", getString(r, "tier"));
+        assertEquals(816L, getLong(r, "awardCent"), "TA2 80% × 1020 = 816");
+    }
+
+    // ==================== 边界流 ====================
+
+    /** AC(§4.4/§9#5): 未达团队年度业绩门槛 → TA0 档 0%,回流平台(awardCent = 0) */
+    @Test
+    void revenueBelowThreshold_TA0_refluxToPlatform() {
+        Object r = buildAward(THRESHOLD - 1);
+        assertEquals("TA0", getString(r, "tier"));
+        assertEquals(0L, getLong(r, "awardCent"), "TA0 0%,不进入分红,回流平台");
+    }
+
+    /** AC(#3 拍板): 记录携带归属字段——团队奖归属平台自有团队(§4.4/§4.5),owner 为平台团队负责人 */
+    @Test
+    void ownerFields_platformOwnedTeam() {
+        Object r = buildAward(4 * THRESHOLD);
+        assertEquals(POOL_ID, getLong(r, "poolId"));
+        assertEquals(ORDER_ID, getLong(r, "orderId"));
+        assertEquals(ORDER_TYPE, getString(r, "orderType"));
+        assertEquals(TEAM_ID, getLong(r, "teamId"));
+        assertEquals(OWNER, getLong(r, "ownerUserId"), "#3 拍板: 归属平台自有团队负责人");
+        assertEquals("pending", getString(r, "status"), "§8.1/§7.4 初始状态 pending");
+    }
+
+    private static void assertOwnerFields(Object r) {
+        assertEquals(POOL_ID, getLong(r, "poolId"));
+        assertEquals(ORDER_ID, getLong(r, "orderId"));
+        assertEquals(TEAM_ID, getLong(r, "teamId"));
+        assertEquals(OWNER, getLong(r, "ownerUserId"));
+    }
+}

+ 21 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/TeamProfitContract.java

@@ -0,0 +1,21 @@
+package com.etotem.cfc.service;
+
+/**
+ * 团队分润契约锚点(S-cfc-41 / team.profit.settle)。
+ * <p>规格锚点:docs/superpowers/specs/2026-11-07-referral-team-profit-sharing.md v0.7 §12-T2 + §2.1 + §4.2 + §4.3 + §4.4 + §7.1/7.3/7.4/7.5</p>
+ */
+class TeamProfitContract {
+    static final String ENTITY_PROFIT_POOL = "com.etotem.cfc.entity.ProfitPoolOrder";
+    static final String ENTITY_TEAM_SHARE = "com.etotem.cfc.entity.TeamShareRecord";
+    static final String ENTITY_TEAM_AWARD = "com.etotem.cfc.entity.TeamAwardRecord";
+    static final String ENTITY_SERVICE_COST = "com.etotem.cfc.entity.ServiceCostConfig";
+
+    static final String MAPPER_PROFIT_POOL = "com.etotem.cfc.mapper.ProfitPoolOrderMapper";
+    static final String MAPPER_TEAM_SHARE = "com.etotem.cfc.mapper.TeamShareRecordMapper";
+    static final String MAPPER_TEAM_AWARD = "com.etotem.cfc.mapper.TeamAwardRecordMapper";
+    static final String MAPPER_SERVICE_COST = "com.etotem.cfc.mapper.ServiceCostConfigMapper";
+
+    static final String SERVICE_PROFIT_POOL = "com.etotem.cfc.service.ProfitPoolService";
+    static final String SERVICE_TEAM = "com.etotem.cfc.service.TeamProfitService";
+    static final String SERVICE_STAR = "com.etotem.cfc.service.StarScoreCalculator";
+}

+ 125 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/TeamProfitSchemaContractRedTest.java

@@ -0,0 +1,125 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+
+import static com.etotem.cfc.service.ContractReflect.requireClass;
+import static com.etotem.cfc.service.ContractReflect.requireField;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * 契约级单测(RED,S-cfc-41 / team.profit.settle · 建表契约 §7.1/7.3/7.4/7.5)。
+ * 规格:docs/superpowers/specs/2026-11-07-referral-team-profit-sharing.md v0.7 §12-T2 + §7.1/7.3/7.4/7.5
+ *
+ * <p>断言四张新表的<b>实体</b>({@code @TableName} 与表名一致)与 <b>Mapper</b>
+ * (MyBatis-Plus {@code BaseMapper<T>})存在,且实体字段覆盖 §7 各表业务列(蛇形列名 → 驼峰字段)。
+ * 幂等唯一键(§8.3 uk_order / uk_order_rec / uk_order_team)为建表 DDL 唯一键约束,落库行为由 GREEN 对账保证。
+ *
+ * <p>权限流不适用(建表契约,N/A)。</p>
+ */
+class TeamProfitSchemaContractRedTest {
+
+    // ==================== 建表存在性(§7.1/7.3/7.4/7.5) ====================
+
+    /** AC(§7.1/7.3/7.4/7.5): 四张新表实体存在且 @TableName 与规格表名一致 */
+    @Test
+    void entities_exist_withSpecTableNames() {
+        assertTableName(TeamProfitContract.ENTITY_PROFIT_POOL, "profit_pool_order");
+        assertTableName(TeamProfitContract.ENTITY_TEAM_SHARE, "team_share_record");
+        assertTableName(TeamProfitContract.ENTITY_TEAM_AWARD, "team_award_record");
+        assertTableName(TeamProfitContract.ENTITY_SERVICE_COST, "service_cost_config");
+    }
+
+    /** AC(§12-T2): 四张新表 Mapper 存在,接口继承 BaseMapper<对应实体> */
+    @Test
+    void mappers_extend_baseMapper_withMatchingEntity() {
+        assertBaseMapper(TeamProfitContract.MAPPER_PROFIT_POOL, TeamProfitContract.ENTITY_PROFIT_POOL);
+        assertBaseMapper(TeamProfitContract.MAPPER_TEAM_SHARE, TeamProfitContract.ENTITY_TEAM_SHARE);
+        assertBaseMapper(TeamProfitContract.MAPPER_TEAM_AWARD, TeamProfitContract.ENTITY_TEAM_AWARD);
+        assertBaseMapper(TeamProfitContract.MAPPER_SERVICE_COST, TeamProfitContract.ENTITY_SERVICE_COST);
+    }
+
+    // ==================== 字段覆盖(§7.x 业务列) ====================
+
+    /** AC(§7.1): profit_pool_order 业务列齐全(id/order_type/product_id/amount/cost/P/O/F/D/R/T/8份/奖/status) */
+    @Test
+    void profitPoolOrder_fields_coverSection71() {
+        Class<?> e = requireClass(TeamProfitContract.ENTITY_PROFIT_POOL, "§7.1");
+        for (String f : new String[]{
+                "orderId", "orderType", "productId",
+                "amountCent", "costCent", "platformRetainCent",
+                "opsCent", "cfCostCent", "poolCent",
+                "referralShareCent", "teamShareCent",
+                "teamEightShareCent", "teamAwardCent", "status"}) {
+            requireField(e, f, "§7.1");
+        }
+    }
+
+    /** AC(§7.3): team_share_record 业务列齐全(layer/star 快照/shares/shareCent/status/幂等依据) */
+    @Test
+    void teamShareRecord_fields_coverSection73() {
+        Class<?> e = requireClass(TeamProfitContract.ENTITY_TEAM_SHARE, "§7.3");
+        for (String f : new String[]{
+                "orderId", "orderType", "poolId", "providerUserId", "recipientUserId",
+                "layer", "starLevel", "shares", "shareCent", "status"}) {
+            requireField(e, f, "§7.3");
+        }
+    }
+
+    /** AC(§7.4): team_award_record 业务列齐全(tier/awardCent/归属字段) */
+    @Test
+    void teamAwardRecord_fields_coverSection74() {
+        Class<?> e = requireClass(TeamProfitContract.ENTITY_TEAM_AWARD, "§7.4");
+        for (String f : new String[]{
+                "orderId", "orderType", "poolId", "teamId", "ownerUserId",
+                "tier", "awardCent", "status"}) {
+            requireField(e, f, "§7.4");
+        }
+    }
+
+    /** AC(§7.5): service_cost_config 业务列齐全(服务成本价载体) */
+    @Test
+    void serviceCostConfig_fields_coverSection75() {
+        Class<?> e = requireClass(TeamProfitContract.ENTITY_SERVICE_COST, "§7.5");
+        for (String f : new String[]{"serviceType", "serviceName", "costCent", "enabled"}) {
+            requireField(e, f, "§7.5");
+        }
+    }
+
+    // ==================== 断言工具 ====================
+
+    private static void assertTableName(String fqcn, String table) {
+        Class<?> c = requireClass(fqcn, "§7 建表");
+        TableName tn = c.getAnnotation(TableName.class);
+        if (tn == null) {
+            fail("RED: 实体缺少 @TableName 注解 " + fqcn);
+            return;
+        }
+        assertEquals(table, tn.value(), "实体 @TableName 应与 §7 建表名一致");
+    }
+
+    private static void assertBaseMapper(String mapperFqcn, String entityFqcn) {
+        Class<?> m = requireClass(mapperFqcn, "§12-T2");
+        if (!m.isInterface()) {
+            fail("RED: 契约 Mapper 应为接口 " + mapperFqcn);
+            return;
+        }
+        Class<?> entity = requireClass(entityFqcn, "§7 建表");
+        for (Type itf : m.getGenericInterfaces()) {
+            if (itf instanceof ParameterizedType
+                    && ((ParameterizedType) itf).getRawType() == BaseMapper.class) {
+                Type arg = ((ParameterizedType) itf).getActualTypeArguments()[0];
+                assertTrue(arg == entity,
+                        "RED: " + m.getSimpleName() + " 应为 BaseMapper<" + entity.getSimpleName() + ">,实际 " + arg.getTypeName());
+                return;
+            }
+        }
+        fail("RED: 契约 Mapper 未继承 BaseMapper<实体> " + mapperFqcn);
+    }
+}

+ 163 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/TeamShareRecordEightShareContractRedTest.java

@@ -0,0 +1,163 @@
+package com.etotem.cfc.service;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static com.etotem.cfc.service.ContractReflect.getInt;
+import static com.etotem.cfc.service.ContractReflect.getLong;
+import static com.etotem.cfc.service.ContractReflect.getString;
+import static com.etotem.cfc.service.ContractReflect.invoke;
+import static com.etotem.cfc.service.ContractReflect.newInstance;
+import static com.etotem.cfc.service.ContractReflect.requireClass;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * 契约级单测(RED,S-cfc-41 / team.profit.settle · TeamProfitService 8 份分佣 §4.2)。
+ * 规格:docs/superpowers/specs/2026-11-07-referral-team-profit-sharing.md v0.7 §12-T2 + §4.2(#5 拍板)+ §7.3 + §9#4
+ *
+ * <p><b>契约(本测试定义,GREEN 实现须满足)</b>:</p>
+ * <pre>
+ *   TeamProfitService.allocateEightShares(Long poolId, Long orderId, String orderType, Long providerUserId,
+ *                                        long eightSharePoolCent, long[] recipientUserIds, int[] layerStarLevels)
+ *       : List&lt;TeamShareRecord&gt;
+ *   layerStarLevels[i] = 第 i+1 层成员当期星级快照(§4.3,1~6);0 = 该层无人(§9#4 → 回流,不产出记录)
+ *   recipientUserIds[i] = 第 i+1 层成员 userId(与 star 对齐;无人层位忽略)
+ * </pre>
+ *
+ * <p><b>分配规则(§4.2 + #5 已拍板 v0.7)</b>:</p>
+ * <pre>
+ *   剩余份数 = shareTotal(默认 8,§7.8 team_profit.share_total)
+ *   L1(服务者本人)     分得 min(L1星级, 剩余);剩余 -= 已分
+ *   L2(引荐人)         分得 min(L2星级, 剩余);剩余 -= 已分
+ *   L3(再上级)         分得 min(L3星级, 剩余);剩余 -= 已分
+ *   每层最多 6 份(六星上限,#5 拍板);层级数动态浮动:最少 2 层、最多 3 层
+ *   金额 shareCent = round(8份池 × 份数 / 8)
+ *   剩余 > 0 且层级已尽 → 回流团队奖池/平台(§4.2/§4.5),不产出记录
+ *   唯一键幂等:team_share_record.uk_order_rec (order_id, recipient_user_id, layer)(§8.3,建表约束)
+ * </pre>
+ *
+ * <p>权限流不适用(服务层契约,N/A)。</p>
+ */
+class TeamShareRecordEightShareContractRedTest {
+
+    /** 契约方法签名常量 */
+    private static final String M_ALLOCATE = "allocateEightShares";
+    private static final Class<?>[] ALLOCATE_PARAMS =
+            {Long.class, Long.class, String.class, Long.class, long.class, long[].class, int[].class};
+
+    private static final long POOL_ID = 1001L;
+    private static final long ORDER_ID = 9001L;
+    private static final String ORDER_TYPE = "service";
+    private static final long PROVIDER = 101L;
+    private static final long EIGHT_POOL = 4080L; // §2 示例 T×80% = 51×80% = 40.8元 = 4080分
+
+    // ==================== 契约实例 ====================
+
+    private Object service() {
+        return newInstance(requireClass(TeamProfitContract.SERVICE_TEAM, "§12-T2/§4.2"));
+    }
+
+    /** 调用契约方法;返回 List<TeamShareRecord>(按 layer 升序) */
+    private List<Object> allocate(long[] recipients, int[] stars) {
+        Object result = invoke(service(), M_ALLOCATE, ALLOCATE_PARAMS,
+                new Object[]{POOL_ID, ORDER_ID, ORDER_TYPE, PROVIDER, EIGHT_POOL, recipients, stars});
+        assertNotNull(result, "契约: allocateEightShares 应返回 List<TeamShareRecord>");
+        if (!(result instanceof List)) {
+            fail("RED: allocateEightShares 返回类型应为 List<TeamShareRecord>,实际 " + result.getClass());
+        }
+        return (List<Object>) result;
+    }
+
+    // ==================== 正常流 ====================
+
+    /** AC(§4.2 用户示例): 6★自服务(6份) + 1★引荐人(1份) + 1★再上级(1份) → 30.6元/5.1元/5.1元 */
+    @Test
+    void userExample_6star_1star_1star_threeLayers() {
+        List<Object> records = allocate(new long[]{101L, 102L, 103L}, new int[]{6, 1, 1});
+        assertEquals(3, records.size(), "应产出 3 条记录(L1/L2/L3)");
+        assertLayer(records.get(0), 1, PROVIDER, 101L, 6, 6, 3060L);
+        assertLayer(records.get(1), 2, PROVIDER, 102L, 1, 1, 510L);
+        assertLayer(records.get(2), 3, PROVIDER, 103L, 1, 1, 510L);
+        assertEquals(EIGHT_POOL,
+                getLong(records.get(0), "shareCent") + getLong(records.get(1), "shareCent")
+                        + getLong(records.get(2), "shareCent"),
+                "8 份分完,金额合计 = 8份池 4080");
+    }
+
+    /** AC(#5 拍板): 每层按星级拿份数;6★+2★ 恰好 8 份 → 最少 2 层即分完,第 3 层无记录 */
+    @Test
+    void minTwoLayers_6star_2star_exhaustAllEight() {
+        List<Object> records = allocate(new long[]{101L, 102L, 0L}, new int[]{6, 2, 0});
+        assertEquals(2, records.size(), "6+2=8 分完,无需第 3 层(最少 2 层,#5 拍板)");
+        assertLayer(records.get(0), 1, PROVIDER, 101L, 6, 6, 3060L);
+        assertLayer(records.get(1), 2, PROVIDER, 102L, 2, 2, 1020L);
+        assertEquals(EIGHT_POOL,
+                getLong(records.get(0), "shareCent") + getLong(records.get(1), "shareCent"));
+    }
+
+    /** AC(#5 拍板): 每层最多 6 份;6★+6★ 时第 2 层取 min(6, 剩余2)=2 份,3 层不再产出 */
+    @Test
+    void capByRemaining_6star_6star_thirdLayerExhausted() {
+        List<Object> records = allocate(new long[]{101L, 102L, 103L}, new int[]{6, 6, 1});
+        assertEquals(2, records.size(), "L1 6份 + L2 min(6,剩余2)=2份 → 8 份分完,L3 无记录");
+        assertLayer(records.get(0), 1, PROVIDER, 101L, 6, 6, 3060L);
+        assertLayer(records.get(1), 2, PROVIDER, 102L, 6, 2, 1020L);
+    }
+
+    // ==================== 边界流 ====================
+
+    /** AC: 每层 min(星级, 剩余);三星全 1★ → 只用 3 份,剩余 5 份回流(不产出记录,金额合计 < 池) */
+    @Test
+    void allOneStar_remainingRefluxToFund() {
+        List<Object> records = allocate(new long[]{101L, 102L, 103L}, new int[]{1, 1, 1});
+        assertEquals(3, records.size());
+        assertLayer(records.get(0), 1, PROVIDER, 101L, 1, 1, 510L);
+        assertLayer(records.get(1), 2, PROVIDER, 102L, 1, 1, 510L);
+        assertLayer(records.get(2), 3, PROVIDER, 103L, 1, 1, 510L);
+        assertEquals(1530L,
+                getLong(records.get(0), "shareCent") + getLong(records.get(1), "shareCent")
+                        + getLong(records.get(2), "shareCent"),
+                "3 份发放 1530 分;剩余 5 份回流团队奖池/平台(§4.2/§4.5)");
+    }
+
+    /** AC(§9#4): 服务提供者无引荐人/无团队 → 该层无人,份额回流;仅服务者本人记录 */
+    @Test
+    void loneProvider_3star_remainingReflux() {
+        List<Object> records = allocate(new long[]{101L, 0L, 0L}, new int[]{3, 0, 0});
+        assertEquals(1, records.size(), "仅 L1 有记录,L2/L3 无人回流");
+        assertLayer(records.get(0), 1, PROVIDER, 101L, 3, 3, 1530L);
+    }
+
+    /** AC(§7.3): 记录携带契约列(poolId/orderId/orderType/providerUserId/recipient/layer/starLevel/shares/shareCent/status) */
+    @Test
+    void recordFields_carryContractColumns() {
+        List<Object> records = allocate(new long[]{101L, 102L, 103L}, new int[]{4, 2, 1});
+        assertEquals(3, records.size());
+        Object r = records.get(0);
+        assertEquals(POOL_ID, getLong(r, "poolId"));
+        assertEquals(ORDER_ID, getLong(r, "orderId"));
+        assertEquals(ORDER_TYPE, getString(r, "orderType"));
+        assertEquals(PROVIDER, getLong(r, "providerUserId"));
+        assertEquals(101L, getLong(r, "recipientUserId"));
+        assertEquals(1, getInt(r, "layer"));
+        assertEquals(4, getInt(r, "starLevel"), "当期星级快照(§4.3/§9#8)");
+        assertEquals(4, getInt(r, "shares"));
+        assertEquals(2040L, getLong(r, "shareCent"), "4080×4/8 = 2040");
+        assertEquals("pending", getString(r, "status"), "§8.1/§7.3 初始状态 pending");
+    }
+
+    // ==================== 断言工具 ====================
+
+    private static void assertLayer(Object r, int layer, long provider, long recipient,
+                                    int star, int shares, long cent) {
+        assertEquals(layer, getInt(r, "layer"), "layer");
+        assertEquals(provider, getLong(r, "providerUserId"), "providerUserId");
+        assertEquals(recipient, getLong(r, "recipientUserId"), "recipientUserId");
+        assertEquals(star, getInt(r, "starLevel"), "starLevel(当期星级快照)");
+        assertEquals(shares, getInt(r, "shares"), "shares");
+        assertEquals(cent, getLong(r, "shareCent"), "shareCent(8份池×份数/8)");
+    }
+}