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

Merge branch 'task/S-cfc-304' (S-cfc-304 energy.ruleEngine.apply RED 契约单测)

zxyj-langgraph 22 часов назад
Родитель
Сommit
029dd0d590

+ 394 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/EnergyRuleEngineApplyContractTest.java

@@ -0,0 +1,394 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.entity.EnergyBalance;
+import com.etotem.cfc.entity.EnergyDimension;
+import com.etotem.cfc.entity.EnergyLog;
+import com.etotem.cfc.entity.EnergyRule;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.mapper.EnergyBalanceMapper;
+import com.etotem.cfc.mapper.EnergyBehaviorConfigMapper;
+import com.etotem.cfc.mapper.EnergyDimensionMapper;
+import com.etotem.cfc.mapper.EnergyLogMapper;
+import com.etotem.cfc.mapper.EnergyRuleMapper;
+import com.etotem.cfc.mapper.EnergySourceConfigMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.WishMapper;
+import com.etotem.cfc.service.EnergyRuleEngine;
+import com.etotem.cfc.service.EnergyService;
+import com.etotem.cfc.service.NotificationDispatchService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:五维能量规则引擎实时触发写入(能力键 {@code energy.ruleEngine.apply},任务 S-cfc-304,TDD-RED 阶段)。
+ *
+ * <p><b>需求/设计溯源</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} 业务域 C-2——
+ *       SC-P-87(任务完成触发五维能量按规则归因增长)、SC-P-88(心愿兑换扣减五维能量余额不为负)、
+ *       SC-P-90(五维每日获取上限 DailyLimit)、SC-P-93(文章互动与活动参与触发五维能量);
+ *       叙事 {@code tests/user-scenarios/07-five-dimension.md} 场景 ED-1(任务归因)/ED-2(心愿扣减)/
+ *       ED-4(日上限)/ED-7(文章·活动);
+ *       用例 {@code tests/test-cases/parent.md} TC-P-87 / TC-P-88 / TC-P-90 / TC-P-93(含 -E 异常流 / -P 权限流)。</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.5(/api/energy/logs 流水写盘依据——
+ *       {@code sourceType}/{@code sourceId}/{@code dimensionId}/{@code amount}/{@code balanceAfter});
+ *       模块 {@link EnergyRuleEngine}(事件→规则匹配→归因维度+数值)/ {@link EnergyBalance}(余额账本)/
+ *       {@code DimensionDailyLimit}(日上限,S-cfc-306 同链)。</li>
+ *   <li><b>职责边界</b>:本键 = <b>实时触发写入(事前/事中)</b>——事件触发时规则引擎把事件映射为
+ *       归因维度+数值并写 {@code EnergyLog}/{@code EnergyBalance}(SC-P-87/88/90/93 的写入路径);
+ *       <b>事后校验</b>(流水对账/唯一键/归属守卫)由 {@code energy.ledger.guard}(S-cfc-225,
+ *       EnergyLedgerGuardContractTest)覆盖;<b>服务侧日上限守卫入口</b>(public guard + 配置驱动)由
+ *       {@code energy.dailyLimit.guard}(S-cfc-306,EnergyDailyLimitGuardContractTest)覆盖。
+ *       本键只锚 apply 写路径自身:入口/可观测结果/归因生效/幂等/异常告警传播/扣减拒绝,与二者互补不重复。</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约行为(TDD-RED 锚点,当前实现未落地 → 红)</b></p>
+ * <ul>
+ *   <li><b>apply 入口(能力键名)</b>:{@link EnergyRuleEngine} 应暴露 public {@code apply(...)} 实时触发入口
+ *       (事件→规则匹配→归因维度+数值→写 EnergyLog/EnergyBalance)——当前仅有 {@code evaluate(...)}(反射结构探测);</li>
+ *   <li><b>返回可观测结果(SC-P-87/90,事前·事中可感知)</b>:{@code apply} 应返回非 void 结果
+ *       (归因维度→amount、拒绝原因、日上限触顶标记「已达今日上限」),调用方(TaskController/ArticleService/
+ *       ActivityService)可感知写入结果——当前 {@code evaluate} 返回 void,日上限触顶(ED-4)、拒绝均不可观测;</li>
+ *   <li><b>规则归因生效(SC-P-87 主流程)</b>:规则声明 {@code dimensionCode=body} 时,apply 应把能量写入 body
+ *       维度({@code EnergyLog.dimensionId=body})——当前 {@code evaluate} increase 分支把规则解析出的 dimId
+ *       丢弃、直接 {@code awardEnergy},无 behavior/source 配置时回落默认 action(ED-1「系统按 EnergyRuleEngine
+ *       将任务类型映射到的维度增加 amount」不成立);</li>
+ *   <li><b>文章互动归因(SC-P-93/ED-7)</b>:{@code eventType=article} 规则声明 {@code dimensionCode=mind} 时
+ *       写 mind 维度——当前同样回落 action(行为断言);</li>
+ *   <li><b>同源重复触发幂等(SC-P-87 边界/ED-1)</b>:同一 {@code (memberId, sourceType=task, sourceId)}
+ *       重复触发(重复审批/网络重试)只写一次 {@code EnergyLog}——当前 {@code evaluate} 每次直接 award,
+ *       无写路径幂等(行为断言;schema 侧幂等唯一键由 S-cfc-225 覆盖);</li>
+ *   <li><b>规则计算异常告警传播(SC-P-87-E-4/ED-1)</b>:写入层/规则计算异常应传播给调用方(可告警、可回滚),
+ *       不得静默吞掉——当前 {@code evaluate} 内部 {@code catch (Exception e) { log.error(...) }} 吞掉继续;</li>
+ *   <li><b>amount null/负拒绝(SC-P-87-E-4)</b>:规则 {@code amount} 缺失/负属配置异常,应拒绝并告警——
+ *       当前 {@code calculateAmount} 对 null 返回 0 静默 {@code continue};</li>
+ *   <li><b>余额不足可感知拒绝(SC-P-88-E-1/ED-2)</b>:decrease 规则在余额不足时应向调用方暴露拒绝
+ *       (异常/结果标记「能量不足」),不得静默忽略 {@code deductEnergy} 的 -1——当前 {@code evaluate}
+ *       忽略返回且不抛错(余额不为负本身由 {@code EnergyService.deductEnergy} 保证,本键锚调用方可感知性)。</li>
+ * </ul>
+ *
+ * <p><b>正常流锚点(已实现 → 绿,如实记录非 RED)</b></p>
+ * <ul>
+ *   <li>无匹配规则的事件:{@code evaluate} 直接返回、不写任何流水;</li>
+ *   <li>归因写入的流水承载 {@code sourceType=task}/{@code sourceId=任务实例ID}(/api/energy/logs 写盘依据)。</li>
+ * </ul>
+ *
+ * <p><b>权限流(TC-P-87-P/88-P/90-P/93-P)</b>:跨角色/跨家庭能量访问 403 由
+ * {@link EnergyIsolationContractTest}(数据隔离)覆盖、日上限配置越权由
+ * {@link EnergyConfigAdminContractTest}(admin 守卫)覆盖,本键为内部触发写入路径,权限流不适用(说明性)。</p>
+ *
+ * <p><b>RED 预期</b>:8 条契约缺口断言失败(红,失败原文含 {@code RED[energy.ruleEngine.apply] …})、
+ * 2 条正常流锚点通过(绿)、1 条权限流标注(绿,说明性)。GREEN 阶段按 SC-P-87/88/90/93 落地
+ * {@code apply}(public 入口 + 可观测结果 + 规则归因生效 + 写路径幂等 + 异常告警传播 + 扣减拒绝感知)后自动转绿。</p>
+ *
+ * <p><b>测试范围</b>:纯 Mockito / 无 Spring 上下文 / 无 DB / 无外部服务;直接实例化
+ * {@link EnergyRuleEngine} 注入 mock {@link EnergyRuleMapper}/{@link EnergyDimensionMapper},并把
+ * mock 依赖注入的 {@link EnergyService} 实例作为 {@code energyService} 字段注入引擎
+ * (复用 {@link EnergyContractTestBase#inject}/{@link EnergyContractTestBase#fiveDims}),
+ * 反射结构探测 + 行为断言(写入捕获/异常传播/幂等计数)。</p>
+ *
+ * <p><b>本类与同族能量测试互补清单</b>:
+ * {@code energy.ledger.guard}(S-cfc-225)= 事后对账/唯一键/归属守卫;
+ * {@code energy.dailyLimit.guard}(S-cfc-306)= 服务侧 public 守卫入口 + 配置驱动;
+ * {@code energy.fiveDim.view}/view 查看口径由 EnergyFiveDimViewContractTest 覆盖;
+ * 本键 {@code energy.ruleEngine.apply}(S-cfc-304)= 实时触发写入(事前/事中)。</p>
+ */
+class EnergyRuleEngineApplyContractTest extends EnergyContractTestBase {
+
+    private static final Long MEMBER_ID = 50L;
+    private static final Long TASK_ID = 100L;
+
+    private EnergyRuleEngine engine;
+    private EnergyService service;
+
+    private EnergyRuleMapper ruleMapper;
+    private EnergyDimensionMapper dimensionMapper;
+    private EnergyBehaviorConfigMapper behaviorConfigMapper;
+    private EnergyLogMapper logMapper;
+    private EnergyBalanceMapper balanceMapper;
+    private FamilyMemberMapper familyMemberMapper;
+    private EnergySourceConfigMapper sourceConfigMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        engine = new EnergyRuleEngine();
+        service = new EnergyService();
+
+        ruleMapper = mock(EnergyRuleMapper.class);
+        dimensionMapper = mock(EnergyDimensionMapper.class);
+        behaviorConfigMapper = mock(EnergyBehaviorConfigMapper.class);
+        logMapper = mock(EnergyLogMapper.class);
+        balanceMapper = mock(EnergyBalanceMapper.class);
+        familyMemberMapper = mock(FamilyMemberMapper.class);
+        sourceConfigMapper = mock(EnergySourceConfigMapper.class);
+
+        inject(engine, "energyRuleMapper", ruleMapper);
+        inject(engine, "energyDimensionMapper", dimensionMapper);
+        inject(engine, "energyService", service);
+
+        inject(service, "energyDimensionMapper", dimensionMapper);
+        inject(service, "energyBehaviorConfigMapper", behaviorConfigMapper);
+        inject(service, "energyLogMapper", logMapper);
+        inject(service, "energyBalanceMapper", balanceMapper);
+        inject(service, "familyMemberMapper", familyMemberMapper);
+        inject(service, "energySourceConfigMapper", sourceConfigMapper);
+        inject(service, "wishMapper", mock(WishMapper.class));
+        inject(service, "notificationDispatchService", mock(NotificationDispatchService.class));
+
+        when(dimensionMapper.selectList(any())).thenReturn(Arrays.asList(fiveDims()));
+        when(dimensionMapper.selectOne(any())).thenReturn(null);
+        when(behaviorConfigMapper.selectOne(any())).thenReturn(null);
+        when(logMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(balanceMapper.selectOne(any())).thenReturn(null);
+
+        FamilyMember member = new FamilyMember();
+        member.setId(MEMBER_ID);
+        member.setUserId(12L);
+        when(familyMemberMapper.selectById(any())).thenReturn(member);
+
+        initTable(EnergyDimension.class);
+        initTable(EnergyLog.class);
+        initTable(EnergyBalance.class);
+        initTable(EnergyRule.class);
+    }
+
+    // ==================== RED-1/2:apply 入口 + 可观测结果(SC-P-87/90,事前·事中可感知) ====================
+
+    @Test
+    @DisplayName("RED:EnergyRuleEngine 应暴露 public apply 实时触发入口(能力键名)")
+    void RED_apply入口_应存在public方法() {
+        assertTrue(findPublicApply() != null,
+                "RED[energy.ruleEngine.apply]:EnergyRuleEngine 应暴露 public apply(...) 实时触发入口"
+                        + "(事件→规则匹配→归因维度+数值→写 EnergyLog/EnergyBalance,SC-P-87 主流程);"
+                        + "当前仅有 evaluate(...),能力键 apply 未落地,调用方只能依赖无返回、吞异常的 evaluate。");
+    }
+
+    @Test
+    @DisplayName("RED:apply 应返回可观测结果(归因/拒绝/日上限触顶可感知,SC-P-87/90)")
+    void RED_apply结果_非void可观测() {
+        Method apply = findPublicApply();
+        if (apply == null) {
+            fail("RED[energy.ruleEngine.apply]:apply 入口缺失 → 调用方必然无法拿到归因/拒绝/触顶结果"
+                    + "(SC-P-87/ED-1 归因可核对、SC-P-90/ED-4『已达今日上限』触顶展示、SC-P-88『能量不足』拒绝文案),"
+                    + "当前 evaluate 返回 void 且吞异常,触发侧(TaskController/ArticleService/ActivityService)"
+                    + "无法感知写入结果。");
+        }
+        assertTrue(apply.getReturnType() != void.class && apply.getReturnType() != Void.class,
+                "RED[energy.ruleEngine.apply]:apply 应返回非 void 结果(Map<dimensionCode,amount> 或带"
+                        + "capped/rejected 标记的结果对象),使日上限触顶(ED-4)、余额不足拒绝(ED-2)可被调用方感知;"
+                        + "当前 evaluate 返回 void,触顶/拒绝均不可观测。");
+    }
+
+    // ==================== RED-3/4:规则归因生效(SC-P-87/93,ED-1/ED-7 主流程) ====================
+
+    @Test
+    @DisplayName("RED:任务事件规则声明归因维度 body → EnergyLog 应写 body 维度(SC-P-87)")
+    void RED_归因_任务规则写body维度() {
+        EnergyRule rule = rule(1L, "task", "increase", "body", 10, 1);
+        when(ruleMapper.selectList(any())).thenReturn(Collections.singletonList(rule));
+        when(dimensionMapper.selectOne(any())).thenReturn(dim(5L, "body", "身体", "土"));
+
+        engine.evaluate("task", TASK_ID, MEMBER_ID, new HashMap<>());
+
+        List<EnergyLog> written = captureInsertedLogs();
+        boolean wroteBody = written.stream().anyMatch(l ->
+                l.getDimensionId() != null && l.getDimensionId().longValue() == 5L);
+        assertTrue(wroteBody,
+                "RED[energy.ruleEngine.apply]:任务事件规则声明归因维度 body(id=5),按规则应把能量写入 body 维度"
+                        + "(ED-1 主流程 2『系统按 EnergyRuleEngine 将任务类型映射到的维度增加 amount』+ 主流程 3 写"
+                        + " EnergyLog.dimensionId)。当前 evaluate 的 increase 分支把规则解析出的 dimId 丢弃、直接"
+                        + " awardEnergy → 无 behavior/source 配置时回落默认 action(id=3)维度,实际写入 dimensionId="
+                        + written.get(0).getDimensionId() + ",规则归因维度未生效(SC-P-87 缺口)。");
+    }
+
+    @Test
+    @DisplayName("RED:文章互动事件规则声明归因维度 mind → EnergyLog 应写 mind 维度(SC-P-93/ED-7)")
+    void RED_归因_文章互动规则写mind维度() {
+        EnergyRule rule = rule(2L, "article", "increase", "mind", 5, 1);
+        when(ruleMapper.selectList(any())).thenReturn(Collections.singletonList(rule));
+        when(dimensionMapper.selectOne(any())).thenReturn(dim(4L, "mind", "心智", "火"));
+
+        engine.evaluate("article", 200L, MEMBER_ID, new HashMap<>());
+
+        List<EnergyLog> written = captureInsertedLogs();
+        boolean wroteMind = written.stream().anyMatch(l ->
+                l.getDimensionId() != null && l.getDimensionId().longValue() == 4L);
+        assertTrue(wroteMind,
+                "RED[energy.ruleEngine.apply]:文章互动事件(SC-P-93/ED-7 主流程 1『发布/评论文章…触发 EnergyRuleEngine"
+                        + " 对应维度 +amount』)规则声明归因维度 mind(id=4),应写入 mind 维度;当前回落默认 action"
+                        + "(id=3)维度,实际写入 dimensionId=" + written.get(0).getDimensionId()
+                        + ",文章互动归因维度未生效(SC-P-93 缺口)。");
+    }
+
+    // ==================== RED-5:同源重复触发幂等(SC-P-87 边界/ED-1) ====================
+
+    @Test
+    @DisplayName("RED:同 member+task+sourceId 重复触发只写一次 EnergyLog(重复审批幂等)")
+    void RED_幂等_同源重复触发只写一次() {
+        EnergyRule rule = rule(3L, "task", "increase", "body", 1, 1);
+        when(ruleMapper.selectList(any())).thenReturn(Collections.singletonList(rule));
+        when(dimensionMapper.selectOne(any())).thenReturn(dim(5L, "body", "身体", "土"));
+
+        engine.evaluate("task", TASK_ID, MEMBER_ID, new HashMap<>());
+        engine.evaluate("task", TASK_ID, MEMBER_ID, new HashMap<>());
+
+        verify(logMapper, times(1)).insert(any(EnergyLog.class));
+    }
+
+    // ==================== RED-6/7:规则计算异常告警(SC-P-87-E-4/ED-1 边界) ====================
+
+    @Test
+    @DisplayName("RED:写入层异常应传播给调用方(可告警),不得静默吞掉")
+    void RED_告警_写入异常应传播() throws Exception {
+        // 注入一个写入即抛的 mock EnergyService:契约要求 apply 把异常传播给调用方(TaskController 可告警/回滚)
+        EnergyService throwing = mock(EnergyService.class);
+        when(throwing.awardEnergy(anyLong(), any(), any(), anyInt(), any(), any()))
+                .thenThrow(new RuntimeException("写入层故障: energy_balance update failed"));
+
+        EnergyRuleEngine engineWithThrowing = new EnergyRuleEngine();
+        inject(engineWithThrowing, "energyRuleMapper", ruleMapper);
+        inject(engineWithThrowing, "energyDimensionMapper", dimensionMapper);
+        inject(engineWithThrowing, "energyService", throwing);
+
+        EnergyRule rule = rule(4L, "task", "increase", "body", 10, 1);
+        when(ruleMapper.selectList(any())).thenReturn(Collections.singletonList(rule));
+        when(dimensionMapper.selectOne(any())).thenReturn(dim(5L, "body", "身体", "土"));
+
+        assertThrows(RuntimeException.class,
+                () -> engineWithThrowing.evaluate("task", TASK_ID, MEMBER_ID, new HashMap<>()),
+                "RED[energy.ruleEngine.apply]:写入层异常应传播给调用方(SC-P-87-E-4『规则计算异常告警、不写脏流水』"
+                        + "——调用方可感知失败并告警/回滚)。当前 evaluate 内部 catch(Exception) 仅 log.error 吞掉异常,"
+                        + "调用方(TaskController)无法得知写入失败,告警链路断点。");
+    }
+
+    @Test
+    @DisplayName("RED:规则 amount 缺失/负值应拒绝并告警,不得静默跳过")
+    void RED_告警_amount缺失负值应拒绝() {
+        // 规则 amount 缺失(配置异常):合约 = 拒绝并告警;当前 calculateAmount 对 null 返回 0 → 静默 continue
+        EnergyRule rule = rule(5L, "task", "increase", "body", null, 1);
+        when(ruleMapper.selectList(any())).thenReturn(Collections.singletonList(rule));
+
+        assertThrows(IllegalArgumentException.class,
+                () -> engine.evaluate("task", TASK_ID, MEMBER_ID, new HashMap<>()),
+                "RED[energy.ruleEngine.apply]:规则 amount 缺失/负值属配置异常,契约要求拒绝并告警"
+                        + "(SC-P-87-E-4:规则计算异常→拒绝执行并告警,不写脏流水);当前 calculateAmount 对 null"
+                        + " 返回 0 后 evaluate 静默 continue,无任何拒绝/告警信号。");
+        verify(logMapper, never()).insert(any(EnergyLog.class));
+    }
+
+    // ==================== RED-8:余额不足可感知拒绝(SC-P-88-E-1/ED-2) ====================
+
+    @Test
+    @DisplayName("RED:decrease 规则余额不足应向调用方暴露拒绝(不得静默忽略 deductEnergy 的 -1)")
+    void RED_扣减_余额不足应可感知() {
+        // 心愿兑换扣减场景(ED-2/SC-P-88):B 该维度 balance=0,decrease 规则 amount=10 → 余额不足
+        EnergyRule rule = rule(6L, "wish", "decrease", "body", 10, 1);
+        when(ruleMapper.selectList(any())).thenReturn(Collections.singletonList(rule));
+        when(dimensionMapper.selectOne(any())).thenReturn(dim(5L, "body", "身体", "土"));
+
+        assertThrows(IllegalStateException.class,
+                () -> engine.evaluate("wish", 300L, MEMBER_ID, new HashMap<>()),
+                "RED[energy.ruleEngine.apply]:心愿兑换扣减(SC-P-88-E-1『余额不足明确返回能量不足、心愿保持待审批、"
+                        + "不出现负余额』)余额不足时,apply 应向调用方暴露拒绝信号(异常/结果标记),不得静默忽略"
+                        + " deductEnergy 的 -1 返回值;当前 evaluate 忽略扣减返回且不抛错,兑换侧无法感知『能量不足』。");
+    }
+
+    // ==================== 正常流锚点(已实现 → 绿,如实记录非 RED) ====================
+
+    @Test
+    @DisplayName("GREEN anchor:无匹配规则的事件不写任何流水")
+    void GREEN_无规则不写流水() {
+        when(ruleMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        engine.evaluate("task", TASK_ID, MEMBER_ID, new HashMap<>());
+
+        verify(logMapper, never()).insert(any(EnergyLog.class));
+    }
+
+    @Test
+    @DisplayName("GREEN anchor:归因写入的流水承载 sourceType=task/sourceId(/api/energy/logs 写盘依据)")
+    void GREEN_流水承载sourceType与sourceId() {
+        EnergyRule rule = rule(7L, "task", "increase", "body", 1, 1);
+        when(ruleMapper.selectList(any())).thenReturn(Collections.singletonList(rule));
+        when(dimensionMapper.selectOne(any())).thenReturn(dim(5L, "body", "身体", "土"));
+
+        engine.evaluate("task", TASK_ID, MEMBER_ID, new HashMap<>());
+
+        List<EnergyLog> written = captureInsertedLogs();
+        assertTrue(written.stream().anyMatch(l -> "task".equals(l.getSourceType())
+                        && Long.valueOf(TASK_ID).equals(l.getSourceId())),
+                "GREEN anchor:归因写入的 EnergyLog 应承载 sourceType=task、sourceId=任务实例ID"
+                        + "(ED-1 主流程 3 + API_REFERENCE §4.5 /api/energy/logs 写盘依据,当前已生效)。");
+    }
+
+    // ==================== 权限流(TC-P-87-P/88-P/90-P/93-P,说明性) ====================
+
+    @Test
+    @DisplayName("权限流:跨角色/跨家庭能量访问 403 由隔离/守卫测试覆盖,本键不适用")
+    void 权限流_不适用() {
+        // 契约 AC 为「事件→规则→写入」内部触发链路,无用户权限维度。
+        // TC-P-87-P/88-P/93-P(家长 A 查/改 F9 成员能量 403、孩子 B 调 grant/deduct 被拒)
+        // 由 EnergyIsolationContractTest(数据隔离)覆盖;TC-P-90-P(越权改 F9 DailyLimit 403)
+        // 由 EnergyConfigAdminContractTest(admin 守卫)覆盖。
+        assertTrue(true, "权限流不适用:energy.ruleEngine.apply 为内部实时触发写入路径,访问控制在 Controller 层。");
+    }
+
+    // ==================== 辅助 ====================
+
+    /** 反射寻找 public apply(...) 入口(当前不存在 → null) */
+    private static Method findPublicApply() {
+        for (Method m : EnergyRuleEngine.class.getDeclaredMethods()) {
+            if ("apply".equals(m.getName()) && Modifier.isPublic(m.getModifiers())) {
+                return m;
+            }
+        }
+        return null;
+    }
+
+    /** 捕获并返回本次发生的全部 EnergyLog.insert 参数 */
+    private List<EnergyLog> captureInsertedLogs() {
+        ArgumentCaptor<EnergyLog> captor = ArgumentCaptor.forClass(EnergyLog.class);
+        verify(logMapper, atLeastOnce()).insert(captor.capture());
+        return captor.getAllValues();
+    }
+
+    /** 规则构造(status=1 active,conditions 空 → 无条件匹配) */
+    private static EnergyRule rule(Long id, String eventType, String action, String dimCode,
+                                   Integer amount, int priority) {
+        EnergyRule r = new EnergyRule();
+        r.setId(id);
+        r.setRuleCode("R" + id);
+        r.setRuleName("规则" + id);
+        r.setEventType(eventType);
+        r.setAction(action);
+        r.setDimensionCode(dimCode);
+        r.setAmount(amount);
+        r.setPriority(priority);
+        r.setStatus(1);
+        r.setDescription(eventType + "-" + dimCode + "归因");
+        return r;
+    }
+}