Sfoglia il codice sorgente

merge(tdd): S-cfc-16 reward.cfEstimate.expectedCf 契约单测(38 例全绿,SC-X-22 CF 预估)

Sisyphus 1 settimana fa
parent
commit
f9a6d7011e

+ 321 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/reward/RewardEstimateControllerTest.java

@@ -0,0 +1,321 @@
+package com.etotem.cfc.controller.reward;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.RewardDisplayVO;
+import com.etotem.cfc.dto.RewardEstimateRequest;
+import com.etotem.cfc.dto.RewardEstimateVO;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.Reward;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.RewardMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.service.RewardRuleService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * SC-X-22 · {@code reward.cfEstimate.expectedCf} 控制器契约测试({@link RewardEstimateController},
+ * 直接调用级,统一 Result<T> 形状断言)。
+ *
+ * <p>对应契约 docs/superpowers/specs/2026-11-05-sc-x22-cf-estimate-contract.md:
+ * <ul>
+ *   <li>§4 端点 POST /api/reward/estimate:请求体校验(缺字段 400);</li>
+ *   <li>§7 权限:未登录 401、孩子调家长侧 403、跨家庭资源 403(任务/心愿);</li>
+ *   <li>§2.2 防伪造:请求 DTO 不含 expectedCf 字段,客户端传入被忽略;</li>
+ *   <li>§4.1 统一 @PostMapping;memberId 由服务端从 JWT 解析(userId/currentMemberId)。</li>
+ * </ul>
+ * 纯 Mockito 单测:不依赖 DB / 真实微信 / Spring 容器;不改 src/main。
+ * 溯源:TC-X-22-P(tests/test-cases/cross-role-e2e.md)。
+ */
+class RewardEstimateControllerTest {
+
+    private static final long USER_ID = 100L;
+    private static final long MEMBER_ID = 7L;
+    private static final long FAMILY_ID = 1L;
+
+    @Mock
+    private RewardRuleService rewardRuleService;
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+    @Mock
+    private TaskMapper taskMapper;
+    @Mock
+    private RewardMapper rewardMapper;
+
+    private RewardEstimateController controller;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        controller = new RewardEstimateController();
+        ReflectionTestUtils.setField(controller, "rewardRuleService", rewardRuleService);
+        ReflectionTestUtils.setField(controller, "familyMemberMapper", familyMemberMapper);
+        ReflectionTestUtils.setField(controller, "taskMapper", taskMapper);
+        ReflectionTestUtils.setField(controller, "rewardMapper", rewardMapper);
+
+        when(familyMemberMapper.selectById(MEMBER_ID)).thenReturn(member(MEMBER_ID, FAMILY_ID));
+        when(rewardRuleService.estimate(anyLong(), anyString(), anyString(), anyString(), anyString()))
+                .thenReturn(vo(20));
+    }
+
+    // ==================== 一、正常路径(TC-X-22 第 5 步:expectedCf 与页面一致) ====================
+
+    @Test
+    void estimate_正常请求_200并透传服务端预估且回显请求字段() {
+        RewardEstimateRequest req = request("TASK_COMPLETE", "TASK", "101", "key-1");
+        when(taskMapper.selectById(101L)).thenReturn(task(101L, FAMILY_ID, 20));
+
+        Result<RewardEstimateVO> result = controller.estimate(USER_ID, MEMBER_ID, "parent", req);
+
+        assertEquals(200, result.getCode());
+        assertNotNull(result.getData());
+        assertEquals(20, result.getData().getExpectedCf());
+        assertEquals("本操作预计获得 20 CF", result.getData().getDisplay().getTemplate());
+        verify(rewardRuleService).estimate(eq(MEMBER_ID), eq("TASK_COMPLETE"), eq("TASK"), eq("101"), eq("key-1"));
+    }
+
+    @Test
+    void estimate_无currentMemberId_由userId反查成员再预估() {
+        RewardEstimateRequest req = request("TASK_COMPLETE", "TASK", "101", null);
+        when(familyMemberMapper.selectOne(any())).thenReturn(member(MEMBER_ID, FAMILY_ID));
+        when(taskMapper.selectById(101L)).thenReturn(task(101L, FAMILY_ID, 20));
+
+        Result<RewardEstimateVO> result = controller.estimate(USER_ID, null, "parent", req);
+
+        assertEquals(200, result.getCode());
+        verify(rewardRuleService).estimate(eq(MEMBER_ID), eq("TASK_COMPLETE"), eq("TASK"), eq("101"), eq(null));
+    }
+
+    // ==================== 二、参数校验(契约 §4.2) ====================
+
+    @Test
+    void estimate_请求体为空_400() {
+        Result<RewardEstimateVO> result = controller.estimate(USER_ID, MEMBER_ID, "parent", null);
+
+        assertEquals(400, result.getCode());
+        assertTrue(result.getMessage().contains("请求体不能为空"));
+        verify(rewardRuleService, never()).estimate(anyLong(), anyString(), anyString(), anyString(), anyString());
+    }
+
+    @Test
+    void estimate_缺必填字段_400() {
+        RewardEstimateRequest req = request("", "TASK", "101", null);
+
+        Result<RewardEstimateVO> result = controller.estimate(USER_ID, MEMBER_ID, "parent", req);
+
+        assertEquals(400, result.getCode());
+        assertTrue(result.getMessage().contains("均不能为空"));
+        verify(rewardRuleService, never()).estimate(anyLong(), anyString(), anyString(), anyString(), anyString());
+    }
+
+    // ==================== 三、未登录 / 成员归属(契约 §7 第 1 行) ====================
+
+    @Test
+    void estimate_未登录且无currentMemberId_401() {
+        RewardEstimateRequest req = request("PURCHASE", "PRODUCT", "77", null);
+
+        Result<RewardEstimateVO> result = controller.estimate(null, null, null, req);
+
+        assertEquals(401, result.getCode());
+        assertTrue(result.getMessage().contains("未登录"));
+        verify(rewardRuleService, never()).estimate(anyLong(), anyString(), anyString(), anyString(), anyString());
+    }
+
+    @Test
+    void estimate_成员不存在_401() {
+        RewardEstimateRequest req = request("PURCHASE", "PRODUCT", "77", null);
+        when(familyMemberMapper.selectById(MEMBER_ID)).thenReturn(null);
+
+        Result<RewardEstimateVO> result = controller.estimate(USER_ID, MEMBER_ID, "parent", req);
+
+        assertEquals(401, result.getCode());
+        assertTrue(result.getMessage().contains("家庭成员"));
+    }
+
+    // ==================== 四、角色校验(契约 §7 第 2 行,孩子 vs 家长侧) ====================
+
+    @Test
+    void estimate_孩子调家长侧PURCHASE_403不返回预估() {
+        RewardEstimateRequest req = request("PURCHASE", "PRODUCT", "77", null);
+
+        Result<RewardEstimateVO> result = controller.estimate(USER_ID, MEMBER_ID, "child", req);
+
+        assertEquals(403, result.getCode());
+        assertTrue(result.getMessage().contains("无权预估家长侧产出点"));
+        assertNull(result.getData());
+        verify(rewardRuleService, never()).estimate(anyLong(), anyString(), anyString(), anyString(), anyString());
+    }
+
+    @Test
+    void estimate_孩子调任务完成类_不拦截() {
+        RewardEstimateRequest req = request("TASK_COMPLETE", "TASK", "101", null);
+        when(taskMapper.selectById(101L)).thenReturn(task(101L, FAMILY_ID, 20));
+
+        Result<RewardEstimateVO> result = controller.estimate(USER_ID, MEMBER_ID, "child", req);
+
+        assertEquals(200, result.getCode());
+    }
+
+    @Test
+    void estimate_教师角色调PURCHASE_放行() {
+        RewardEstimateRequest req = request("PURCHASE", "PRODUCT", "77", null);
+
+        Result<RewardEstimateVO> result = controller.estimate(USER_ID, MEMBER_ID, "teacher", req);
+
+        assertEquals(200, result.getCode());
+        verify(rewardRuleService).estimate(eq(MEMBER_ID), eq("PURCHASE"), eq("PRODUCT"), eq("77"), eq(null));
+    }
+
+    // ==================== 五、资源归属 / 防跨家庭泄露(契约 §7 第 3 行) ====================
+
+    @Test
+    void estimate_跨家庭任务_403不返回他人expectedCf() {
+        RewardEstimateRequest req = request("TASK_COMPLETE", "TASK", "101", null);
+        when(taskMapper.selectById(101L)).thenReturn(task(101L, 9L, 20));
+
+        Result<RewardEstimateVO> result = controller.estimate(USER_ID, MEMBER_ID, "parent", req);
+
+        assertEquals(403, result.getCode());
+        assertTrue(result.getMessage().contains("其他家庭的任务"));
+        verify(rewardRuleService, never()).estimate(anyLong(), anyString(), anyString(), anyString(), anyString());
+    }
+
+    @Test
+    void estimate_任务不存在_403() {
+        RewardEstimateRequest req = request("TASK_COMPLETE", "TASK", "404", null);
+        when(taskMapper.selectById(404L)).thenReturn(null);
+
+        Result<RewardEstimateVO> result = controller.estimate(USER_ID, MEMBER_ID, "parent", req);
+
+        assertEquals(403, result.getCode());
+        assertTrue(result.getMessage().contains("任务不存在"));
+    }
+
+    @Test
+    void estimate_任务资源ID非数字_403参数无效() {
+        RewardEstimateRequest req = request("TASK_COMPLETE", "TASK", "not-a-number", null);
+
+        Result<RewardEstimateVO> result = controller.estimate(USER_ID, MEMBER_ID, "parent", req);
+
+        assertEquals(403, result.getCode());
+        assertTrue(result.getMessage().contains("资源ID无效"));
+    }
+
+    @Test
+    void estimate_跨家庭心愿_403() {
+        RewardEstimateRequest req = request("WISH", "REWARD", "9", null);
+        when(rewardMapper.selectById(9L)).thenReturn(reward(9L, 9L, 50));
+
+        Result<RewardEstimateVO> result = controller.estimate(USER_ID, MEMBER_ID, "parent", req);
+
+        assertEquals(403, result.getCode());
+        assertTrue(result.getMessage().contains("其他家庭的心愿"));
+        verify(rewardRuleService, never()).estimate(anyLong(), anyString(), anyString(), anyString(), anyString());
+    }
+
+    @Test
+    void estimate_心愿正常_200() {
+        RewardEstimateRequest req = request("WISH", "REWARD", "9", null);
+        when(rewardMapper.selectById(9L)).thenReturn(reward(9L, FAMILY_ID, 50));
+
+        Result<RewardEstimateVO> result = controller.estimate(USER_ID, MEMBER_ID, "parent", req);
+
+        assertEquals(200, result.getCode());
+        verify(rewardRuleService).estimate(eq(MEMBER_ID), eq("WISH"), eq("REWARD"), eq("9"), eq(null));
+    }
+
+    @Test
+    void estimate_商品购买_公开资源不校验归属_200() {
+        RewardEstimateRequest req = request("PURCHASE", "PRODUCT", "sku-abc", null);
+
+        Result<RewardEstimateVO> result = controller.estimate(USER_ID, MEMBER_ID, "parent", req);
+
+        assertEquals(200, result.getCode());
+        verify(rewardRuleService).estimate(eq(MEMBER_ID), eq("PURCHASE"), eq("PRODUCT"), eq("sku-abc"), eq(null));
+    }
+
+    @Test
+    void estimate_资源校验异常_降级403资源校验失败不越权() {
+        RewardEstimateRequest req = request("TASK_COMPLETE", "TASK", "101", null);
+        when(taskMapper.selectById(101L)).thenThrow(new RuntimeException("db down"));
+
+        Result<RewardEstimateVO> result = controller.estimate(USER_ID, MEMBER_ID, "parent", req);
+
+        assertEquals(403, result.getCode());
+        assertTrue(result.getMessage().contains("资源校验失败"));
+        verify(rewardRuleService, never()).estimate(anyLong(), anyString(), anyString(), anyString(), anyString());
+    }
+
+    // ==================== 六、防伪造(契约 §2.2 / TC-X-22-P 第 5 步) ====================
+
+    @Test
+    void 请求DTO不包含expectedCf字段_客户端无法伪造预估() {
+        Exception e = assertThrows(NoSuchFieldException.class,
+                () -> RewardEstimateRequest.class.getDeclaredField("expectedCf"));
+        assertTrue(e.getMessage().contains("expectedCf"), "DTO 不应声明 expectedCf(服务端计算,客户端传了也忽略)");
+    }
+
+    // ==================== 构造辅助 ====================
+
+    private RewardEstimateRequest request(String operationType, String resourceType, String resourceId, String key) {
+        RewardEstimateRequest req = new RewardEstimateRequest();
+        req.setOperationType(operationType);
+        req.setResourceType(resourceType);
+        req.setResourceId(resourceId);
+        req.setIdempotencyKey(key);
+        return req;
+    }
+
+    private RewardEstimateVO vo(int expectedCf) {
+        RewardEstimateVO vo = new RewardEstimateVO();
+        vo.setExpectedCf(expectedCf);
+        vo.setCurrency("CF");
+        RewardDisplayVO display = new RewardDisplayVO();
+        display.setType(expectedCf > 0 ? "EARN" : "ZERO");
+        display.setTemplate(expectedCf > 0 ? "本操作预计获得 " + expectedCf + " CF" : null);
+        display.setCfValue(expectedCf);
+        vo.setDisplay(display);
+        return vo;
+    }
+
+    private FamilyMember member(long id, long familyId) {
+        FamilyMember m = new FamilyMember();
+        m.setId(id);
+        m.setFamilyId(familyId);
+        m.setUserId(USER_ID);
+        return m;
+    }
+
+    private Task task(long id, long familyId, int points) {
+        Task t = new Task();
+        t.setId(id);
+        t.setFamilyId(familyId);
+        t.setPoints(points);
+        return t;
+    }
+
+    private Reward reward(long id, long familyId, int pointsRequired) {
+        Reward r = new Reward();
+        r.setId(id);
+        r.setFamilyId(familyId);
+        r.setPointsRequired(pointsRequired);
+        return r;
+    }
+}

+ 463 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/RewardRuleServiceEstimateTest.java

@@ -0,0 +1,463 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.config.ConfigService;
+import com.etotem.cfc.dto.RewardEstimateVO;
+import com.etotem.cfc.entity.CfBalanceLog;
+import com.etotem.cfc.entity.CfEarnDailyLimit;
+import com.etotem.cfc.entity.CfEarnRule;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.GrowthTask;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.Reward;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.mapper.CfBalanceLogMapper;
+import com.etotem.cfc.mapper.CfEarnDailyLimitMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.GrowthTaskMapper;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.RewardMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.sql.Date;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+
+/**
+ * SC-X-22 · {@code reward.cfEstimate.expectedCf} 契约级单测({@link RewardRuleService})。
+ *
+ * <p>对应契约 docs/superpowers/specs/2026-11-05-sc-x22-cf-estimate-contract.md:
+ * <ul>
+ *   <li>§2.1 服务端唯一计算方:按当日有效规则算 expectedCf(正=获得、负=消耗、0=无奖励);</li>
+ *   <li>§3 产出点三类文案(获得型 / 消耗型 / 零值禁「+0 CF」);</li>
+ *   <li>§5.2 日限触顶按剩余额度、不超上限;</li>
+ *   <li>§8 规则未配置 / 未生效 / 内部异常 → 降级「CF 奖励以实际为准」,不抛 500。</li>
+ * </ul>
+ * 纯 Mockito 单测:不依赖 DB / 真实微信 / Spring 容器;不改 src/main。
+ * 溯源:TC-X-22 / TC-X-22-E / TC-X-22-E2(tests/test-cases/cross-role-e2e.md)。
+ */
+class RewardRuleServiceEstimateTest {
+
+    private static final long MEMBER_ID = 7L;
+    private static final long FAMILY_ID = 1L;
+    private static final long USER_ID = 100L;
+
+    @Mock
+    private CfEarnRuleService cfEarnRuleService;
+    @Mock
+    private CfBalanceLogMapper cfBalanceLogMapper;
+    @Mock
+    private CfEarnDailyLimitMapper cfEarnDailyLimitMapper;
+    @Mock
+    private ConfigService configService;
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+    @Mock
+    private TaskMapper taskMapper;
+    @Mock
+    private GrowthTaskMapper growthTaskMapper;
+    @Mock
+    private ProductMapper productMapper;
+    @Mock
+    private RewardMapper rewardMapper;
+    @Mock
+    private MembershipService membershipService;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+    private final RewardRuleService rewardRuleService = new RewardRuleService();
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        // MyBatis-Plus LambdaQueryWrapper 需要实体元数据(参照 CfEarnDispatchServiceTest)
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), CfBalanceLog.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), CfEarnDailyLimit.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), FamilyMember.class);
+
+        ReflectionTestUtils.setField(rewardRuleService, "cfEarnRuleService", cfEarnRuleService);
+        ReflectionTestUtils.setField(rewardRuleService, "cfBalanceLogMapper", cfBalanceLogMapper);
+        ReflectionTestUtils.setField(rewardRuleService, "cfEarnDailyLimitMapper", cfEarnDailyLimitMapper);
+        ReflectionTestUtils.setField(rewardRuleService, "configService", configService);
+        ReflectionTestUtils.setField(rewardRuleService, "familyMemberMapper", familyMemberMapper);
+        ReflectionTestUtils.setField(rewardRuleService, "taskMapper", taskMapper);
+        ReflectionTestUtils.setField(rewardRuleService, "growthTaskMapper", growthTaskMapper);
+        ReflectionTestUtils.setField(rewardRuleService, "productMapper", productMapper);
+        ReflectionTestUtils.setField(rewardRuleService, "rewardMapper", rewardMapper);
+        ReflectionTestUtils.setField(rewardRuleService, "objectMapper", objectMapper);
+        ReflectionTestUtils.setField(rewardRuleService, "membershipService", membershipService);
+
+        // 默认:无日限、无会员等级、无今日已发放;1 元 = 100 CF
+        when(cfBalanceLogMapper.selectCount(any())).thenReturn(0L);
+        when(cfBalanceLogMapper.selectObjs(any())).thenReturn(new ArrayList<>());
+        when(cfEarnDailyLimitMapper.selectOne(any())).thenReturn(null);
+        when(membershipService.getCurrentLevel(any())).thenReturn(null);
+        when(configService.getConfig(anyString())).thenReturn(null);
+        when(configService.getConfig("cf.rate.cf_per_yuan")).thenReturn("100");
+        when(familyMemberMapper.selectById(MEMBER_ID)).thenReturn(member(MEMBER_ID, FAMILY_ID));
+    }
+
+    // ==================== 一、获得型正常路径(TC-X-22) ====================
+
+    @Test
+    void taskComplete_固定规则20_返回获得型预估与统一文案() {
+        when(cfEarnRuleService.getByCode("F22")).thenReturn(rule("F22", "fixed", null, null, null, 20));
+        when(taskMapper.selectById(101L)).thenReturn(task(101L, FAMILY_ID, 20));
+
+        RewardEstimateVO vo = rewardRuleService.estimate(MEMBER_ID, "TASK_COMPLETE", "TASK", "101", "key-1");
+
+        assertEquals(20, vo.getExpectedCf());
+        assertNull(vo.getActualCf());
+        assertNull(vo.getReason());
+        assertEquals("CF", vo.getCurrency());
+        assertEquals("TASK_COMPLETE", vo.getOperationType());
+        assertEquals("TASK", vo.getResourceType());
+        assertEquals("101", vo.getResourceId());
+        assertEquals("key-1", vo.getIdempotencyKey());
+        assertEquals("EARN", vo.getDisplay().getType());
+        assertEquals("本操作预计获得 20 CF", vo.getDisplay().getTemplate());
+        assertEquals(20, vo.getDisplay().getCfValue());
+    }
+
+    @Test
+    void audit_与任务完成共享F22规则_审核发分预估() {
+        when(cfEarnRuleService.getByCode("F22")).thenReturn(rule("F22", "fixed", null, null, null, 20));
+        when(taskMapper.selectById(102L)).thenReturn(task(102L, FAMILY_ID, 20));
+
+        RewardEstimateVO vo = rewardRuleService.estimate(MEMBER_ID, "AUDIT", "TASK", "102", null);
+
+        assertEquals(20, vo.getExpectedCf());
+        assertEquals("EARN", vo.getDisplay().getType());
+        assertEquals("本操作预计获得 20 CF", vo.getDisplay().getTemplate());
+    }
+
+    @Test
+    void purchase_公式F02_99元商品按1元100CF估9900() {
+        when(cfEarnRuleService.getByCode("F02")).thenReturn(rule("F02", "formula", "floor(amt)",
+                "{\"cf_per_yuan\":100,\"amt_unit\":\"cent\"}", null, null));
+        // price 单位为分:9900 分 = 99 元 → 9900 CF
+        when(productMapper.selectById(77L)).thenReturn(product(77L, 9900));
+
+        RewardEstimateVO vo = rewardRuleService.estimate(MEMBER_ID, "PURCHASE", "PRODUCT", "77", null);
+
+        assertEquals(9900, vo.getExpectedCf());
+        assertEquals("EARN", vo.getDisplay().getType());
+        assertEquals("本操作预计获得 9900 CF", vo.getDisplay().getTemplate());
+    }
+
+    @Test
+    void growthTask_按任务奖励积分perItem预估() {
+        when(cfEarnRuleService.getByCode("F12")).thenReturn(rule("F12", "per_item", null, null, null, null));
+        when(growthTaskMapper.selectById(55L)).thenReturn(growthTask(55L, 10));
+
+        RewardEstimateVO vo = rewardRuleService.estimate(MEMBER_ID, "GROWTH_TASK", "GROWTH_TASK", "55", null);
+
+        assertEquals(10, vo.getExpectedCf());
+        assertEquals("EARN", vo.getDisplay().getType());
+        assertEquals("本操作预计获得 10 CF", vo.getDisplay().getTemplate());
+    }
+
+    // ==================== 二、消耗型(TC-X-22-E2 第 2 步) ====================
+
+    @Test
+    void wish_心愿兑换_负向消耗文案不误用获得型() {
+        when(rewardMapper.selectById(9L)).thenReturn(reward(9L, FAMILY_ID, 50));
+
+        RewardEstimateVO vo = rewardRuleService.estimate(MEMBER_ID, "WISH", "REWARD", "9", null);
+
+        assertEquals(-50, vo.getExpectedCf());
+        assertEquals("SPEND", vo.getDisplay().getType());
+        assertEquals("本操作将消耗 50 CF", vo.getDisplay().getTemplate());
+        assertEquals(-50, vo.getDisplay().getCfValue());
+        assertFalse(vo.getDisplay().getTemplate().contains("获得"));
+    }
+
+    @Test
+    void wish_心愿无消耗值_零值省略文案不展示加0CF() {
+        when(rewardMapper.selectById(10L)).thenReturn(reward(10L, FAMILY_ID, null));
+
+        RewardEstimateVO vo = rewardRuleService.estimate(MEMBER_ID, "WISH", "REWARD", "10", null);
+
+        assertEquals(0, vo.getExpectedCf());
+        assertEquals("ZERO", vo.getDisplay().getType());
+        assertNull(vo.getDisplay().getTemplate());
+    }
+
+    // ==================== 三、零值(TC-X-22-E2 第 1 步,禁止「+0 CF」) ====================
+
+    @Test
+    void 规则值为0_返回零值展示且不出现加0CF文案() {
+        when(cfEarnRuleService.getByCode("F22")).thenReturn(rule("F22", "fixed", null, null, null, 0));
+        when(taskMapper.selectById(103L)).thenReturn(task(103L, FAMILY_ID, 20));
+
+        RewardEstimateVO vo = rewardRuleService.estimate(MEMBER_ID, "TASK_COMPLETE", "TASK", "103", null);
+
+        assertEquals(0, vo.getExpectedCf());
+        assertEquals("ZERO", vo.getDisplay().getType());
+        assertNull(vo.getDisplay().getTemplate(), "零值必须省略预估文案,禁止展示「+0 CF」");
+        assertEquals(0, vo.getDisplay().getCfValue());
+    }
+
+    // ==================== 四、降级(TC-X-22-E 第 4 步 / 契约 §8) ====================
+
+    @Test
+    void 规则未配置_降级为以实际为准且不抛异常() {
+        when(cfEarnRuleService.getByCode("F22")).thenReturn(null);
+
+        RewardEstimateVO vo = assertDoesNotThrow(
+                () -> rewardRuleService.estimate(MEMBER_ID, "TASK_COMPLETE", "TASK", "101", null));
+
+        assertNull(vo.getExpectedCf());
+        assertEquals("DEGRADED", vo.getDisplay().getType());
+        assertEquals("CF 奖励以实际为准", vo.getDisplay().getTemplate());
+        assertEquals("CF", vo.getCurrency());
+    }
+
+    @Test
+    void 规则未启用_降级为以实际为准() {
+        CfEarnRule disabled = rule("F22", "fixed", null, null, null, 20);
+        disabled.setEnabled(0);
+        when(cfEarnRuleService.getByCode("F22")).thenReturn(disabled);
+
+        RewardEstimateVO vo = rewardRuleService.estimate(MEMBER_ID, "TASK_COMPLETE", "TASK", "101", null);
+
+        assertNull(vo.getExpectedCf());
+        assertEquals("DEGRADED", vo.getDisplay().getType());
+    }
+
+    @Test
+    void 规则不在生效期_降级为以实际为准() {
+        CfEarnRule future = rule("F22", "fixed", null, null, null, 20);
+        future.setEffectiveFrom(Date.valueOf(LocalDate.now().plusDays(1)));
+        when(cfEarnRuleService.getByCode("F22")).thenReturn(future);
+
+        RewardEstimateVO vo = rewardRuleService.estimate(MEMBER_ID, "TASK_COMPLETE", "TASK", "101", null);
+
+        assertNull(vo.getExpectedCf());
+        assertEquals("DEGRADED", vo.getDisplay().getType());
+    }
+
+    @Test
+    void 任务不存在_降级不泄露规则数值() {
+        when(cfEarnRuleService.getByCode("F22")).thenReturn(rule("F22", "fixed", null, null, null, 20));
+        when(taskMapper.selectById(404L)).thenReturn(null);
+
+        RewardEstimateVO vo = rewardRuleService.estimate(MEMBER_ID, "TASK_COMPLETE", "TASK", "404", null);
+
+        assertNull(vo.getExpectedCf());
+        assertEquals("DEGRADED", vo.getDisplay().getType());
+    }
+
+    @Test
+    void 任务积分为0_降级不返回虚假预估() {
+        when(cfEarnRuleService.getByCode("F22")).thenReturn(rule("F22", "fixed", null, null, null, 20));
+        when(taskMapper.selectById(105L)).thenReturn(task(105L, FAMILY_ID, 0));
+
+        RewardEstimateVO vo = rewardRuleService.estimate(MEMBER_ID, "TASK_COMPLETE", "TASK", "105", null);
+
+        assertNull(vo.getExpectedCf());
+        assertEquals("DEGRADED", vo.getDisplay().getType());
+    }
+
+    @Test
+    void 规则读取内部异常_降级不向调用方抛500() {
+        when(cfEarnRuleService.getByCode("F22")).thenThrow(new RuntimeException("db down"));
+
+        RewardEstimateVO vo = assertDoesNotThrow(
+                () -> rewardRuleService.estimate(MEMBER_ID, "TASK_COMPLETE", "TASK", "101", null));
+
+        assertNull(vo.getExpectedCf());
+        assertEquals("DEGRADED", vo.getDisplay().getType());
+        assertEquals("CF 奖励以实际为准", vo.getDisplay().getTemplate());
+    }
+
+    // ==================== 五、日上限(TC-X-22-E 第 2 步 / 契约 §5.2) ====================
+
+    @Test
+    void 次数日限已触顶_预估按剩余额度0展示为无奖励() {
+        CfEarnRule limited = rule("F22", "fixed", null, null, 1, 20);
+        when(cfEarnRuleService.getByCode("F22")).thenReturn(limited);
+        when(taskMapper.selectById(106L)).thenReturn(task(106L, FAMILY_ID, 20));
+        when(cfBalanceLogMapper.selectCount(any())).thenReturn(1L); // 今日已发放 1 次 = 上限
+
+        RewardEstimateVO vo = rewardRuleService.estimate(MEMBER_ID, "TASK_COMPLETE", "TASK", "106", null);
+
+        assertEquals(0, vo.getExpectedCf());
+        assertEquals("ZERO", vo.getDisplay().getType());
+        assertNull(vo.getDisplay().getTemplate());
+    }
+
+    @Test
+    void 金额日限未触顶_按剩余额度缩小预估不超上限() {
+        CfEarnRule limited = rule("F22", "fixed", null, "{\"daily_limit_amount\":100}", null, 20);
+        when(cfEarnRuleService.getByCode("F22")).thenReturn(limited);
+        when(taskMapper.selectById(107L)).thenReturn(task(107L, FAMILY_ID, 20));
+        when(cfBalanceLogMapper.selectObjs(any())).thenReturn(Arrays.asList(90L)); // 今日已得 90
+
+        RewardEstimateVO vo = rewardRuleService.estimate(MEMBER_ID, "TASK_COMPLETE", "TASK", "107", null);
+
+        assertEquals(10, vo.getExpectedCf());
+        assertEquals("EARN", vo.getDisplay().getType());
+        assertEquals("本操作预计获得 10 CF", vo.getDisplay().getTemplate());
+    }
+
+    // ==================== 六、幂等(契约 §5.1) ====================
+
+    @Test
+    void 同一幂等键重放_预估值稳定且回显幂等键() {
+        when(cfEarnRuleService.getByCode("F22")).thenReturn(rule("F22", "fixed", null, null, null, 20));
+        when(taskMapper.selectById(108L)).thenReturn(task(108L, FAMILY_ID, 20));
+
+        RewardEstimateVO first = rewardRuleService.estimate(MEMBER_ID, "TASK_COMPLETE", "TASK", "108", "same-key");
+        RewardEstimateVO second = rewardRuleService.estimate(MEMBER_ID, "TASK_COMPLETE", "TASK", "108", "same-key");
+
+        assertEquals(first.getExpectedCf(), second.getExpectedCf());
+        assertEquals(20, second.getExpectedCf());
+        assertEquals("same-key", second.getIdempotencyKey());
+    }
+
+    // ==================== 七、纯数值取数入口 estimateCf ====================
+
+    @Test
+    void estimateCf_正常返回整数_降级返回0() {
+        when(cfEarnRuleService.getByCode("F22")).thenReturn(rule("F22", "fixed", null, null, null, 20));
+        when(taskMapper.selectById(109L)).thenReturn(task(109L, FAMILY_ID, 20));
+
+        assertEquals(20, rewardRuleService.estimateCf(MEMBER_ID, "TASK_COMPLETE", "TASK", "109"));
+
+        when(cfEarnRuleService.getByCode("F22")).thenReturn(null);
+        assertEquals(0, rewardRuleService.estimateCf(MEMBER_ID, "TASK_COMPLETE", "TASK", "109"));
+    }
+
+    // ==================== 八、产出点内嵌挂载(契约 §4.1 / §4.3) ====================
+
+    @Test
+    void attachTaskEstimate_为任务挂载expectedCf与display() {
+        when(cfEarnRuleService.getByCode("F22")).thenReturn(rule("F22", "fixed", null, null, null, 20));
+        when(taskMapper.selectById(110L)).thenReturn(task(110L, FAMILY_ID, 20));
+        Task task = task(110L, FAMILY_ID, 20);
+
+        rewardRuleService.attachTaskEstimate(task, MEMBER_ID);
+
+        assertEquals(20, task.getExpectedCf());
+        assertNotNull(task.getDisplay());
+        assertEquals("EARN", task.getDisplay().getType());
+    }
+
+    @Test
+    void attachTaskListEstimate_空值安全不抛异常() {
+        assertDoesNotThrow(() -> rewardRuleService.attachTaskListEstimate(null, MEMBER_ID));
+        assertDoesNotThrow(() -> rewardRuleService.attachTaskListEstimate(new ArrayList<>(), null));
+
+        Task task = task(111L, FAMILY_ID, 20);
+        assertDoesNotThrow(() -> rewardRuleService.attachTaskListEstimate(Arrays.asList(task), null));
+        assertNull(task.getExpectedCf(), "memberId 为空不挂载预估");
+    }
+
+    @Test
+    void attachGrowthTaskMapEstimate_写回map的expectedCf与display() {
+        when(cfEarnRuleService.getByCode("F12")).thenReturn(rule("F12", "per_item", null, null, null, null));
+        when(growthTaskMapper.selectById(55L)).thenReturn(growthTask(55L, 10));
+        Map<String, Object> item = new HashMap<>();
+        item.put("id", 55L);
+        List<Map<String, Object>> items = new ArrayList<>();
+        items.add(item);
+
+        rewardRuleService.attachGrowthTaskMapEstimate(items, MEMBER_ID);
+
+        assertEquals(10, item.get("expectedCf"));
+        assertNotNull(item.get("display"));
+        assertEquals("EARN", ((com.etotem.cfc.dto.RewardDisplayVO) item.get("display")).getType());
+    }
+
+    // ==================== 九、成员解析 ====================
+
+    @Test
+    void resolveMemberIdByUser_按userId反查家庭成员_无记录返回null() {
+        when(familyMemberMapper.selectOne(any())).thenReturn(member(MEMBER_ID, FAMILY_ID));
+        assertEquals(Long.valueOf(MEMBER_ID), rewardRuleService.resolveMemberIdByUser(USER_ID));
+
+        when(familyMemberMapper.selectOne(any())).thenReturn(null);
+        assertNull(rewardRuleService.resolveMemberIdByUser(USER_ID));
+        assertNull(rewardRuleService.resolveMemberIdByUser(null));
+        assertNull(rewardRuleService.resolveMemberIdByUser(0L));
+    }
+
+    // ==================== 构造辅助 ====================
+
+    private FamilyMember member(long id, long familyId) {
+        FamilyMember m = new FamilyMember();
+        m.setId(id);
+        m.setFamilyId(familyId);
+        m.setUserId(USER_ID);
+        return m;
+    }
+
+    private Task task(long id, long familyId, int points) {
+        Task t = new Task();
+        t.setId(id);
+        t.setFamilyId(familyId);
+        t.setPoints(points);
+        return t;
+    }
+
+    private GrowthTask growthTask(long id, int rewardPoints) {
+        GrowthTask gt = new GrowthTask();
+        gt.setId(id);
+        gt.setRewardPoints(rewardPoints);
+        gt.setTaskKey("key-" + id);
+        return gt;
+    }
+
+    private Product product(long id, int price) {
+        Product p = new Product();
+        p.setId(id);
+        p.setPrice(price);
+        return p;
+    }
+
+    private Reward reward(long id, long familyId, Integer pointsRequired) {
+        Reward r = new Reward();
+        r.setId(id);
+        r.setFamilyId(familyId);
+        r.setPointsRequired(pointsRequired);
+        return r;
+    }
+
+    private CfEarnRule rule(String code, String ruleType, String formula, String paramsJson,
+                            Integer dailyLimitPerMember, Integer amount) {
+        CfEarnRule r = new CfEarnRule();
+        r.setCode(code);
+        r.setName(code + "-规则");
+        r.setCategory("earn");
+        r.setEnabled(1);
+        r.setRuleType(ruleType);
+        r.setFormula(formula);
+        r.setParamsJson(paramsJson);
+        r.setDailyLimitPerMember(dailyLimitPerMember);
+        r.setAmount(amount);
+        r.setDedupRefType(null);
+        return r;
+    }
+}