Răsfoiți Sursa

fix(B-22): CF 预估挂载 N+1 批量优化 + 鉴权清单参数化契约

- 优化 RewardRuleService.attachTaskListEstimate:一次性 collect taskId → selectBatchIds 单次查询 +
  规则/日限上下文解析一次,内存回填 expectedCf/display(N+1 → 常数查询,不随 N 线性增长)
- 降级不阻塞:批量查询异常 log.warn 跳过整批,不抛 500;单条异常独立降级不影响其余
- 保持对外语义一致:批量结果与逐条 estimate() 完全一致(含语义对齐 S-cfc-16 契约单测)
- 新增 JwtInterceptorLoginRequiredPathTest:LOGIN_REQUIRED_PATHS 全条目参数化「无 token → 401」
  契约用例(走真实拦截器),修复鉴权清单零断言问题
- 新增 RewardRuleServiceBatchEstimateTest:批量查询次数断言 + 降级断言 + 批量==逐条语义一致断言
Sisyphus 1 săptămână în urmă
părinte
comite
a6ee2a7cb7

+ 146 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/RewardRuleService.java

@@ -26,6 +26,7 @@ import org.springframework.context.annotation.Lazy;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
+import java.util.ArrayList;
 import java.util.Calendar;
 import java.util.Date;
 import java.util.HashMap;
@@ -189,14 +190,157 @@ public class RewardRuleService {
         }
     }
 
-    /** 批量为任务列表挂载预估(逐条旁路读,单条异常不影响其余) */
+    /** 批量为任务列表挂载预估(B-22:N+1 → 批量查询 + 内存回填,不逐条 estimate())。 */
     public void attachTaskListEstimate(List<Task> tasks, Long memberId) {
         if (tasks == null || tasks.isEmpty() || memberId == null) {
             return;
         }
+        List<Long> taskIds = new ArrayList<>(tasks.size());
         for (Task task : tasks) {
-            attachTaskEstimate(task, memberId);
+            if (task != null && task.getId() != null) {
+                taskIds.add(task.getId());
+            }
+        }
+        if (taskIds.isEmpty()) {
+            return;
+        }
+        try {
+            // 1) 一次性收集 taskId → 批量加载任务上下文(单次查询,替代逐条 selectById)
+            Map<Long, Task> taskById = loadTasksByIds(taskIds);
+            // 2) 共享规则 + 日限上下文只解析一次(同一 memberId + TASK_COMPLETE→F22,全批一致)
+            TaskEstimateBatchCtx batchCtx = resolveTaskBatchCtx(memberId);
+            // 3) 内存回填:逐条独立降级,单条失败不影响其余;整批查询失败则跳过该批
+            for (Task task : tasks) {
+                if (task == null || task.getId() == null) {
+                    continue;
+                }
+                fillTaskEstimateFromBatch(task, taskById, batchCtx);
+            }
+        } catch (Exception e) {
+            // 批量查询异常:静默降级但可观测,跳过整个批次,不抛 500(契约 §8)
+            log.warn("批量挂载任务预估失败,跳过该批次 taskCount={} memberId={}:{}",
+                    tasks.size(), memberId, e.getMessage());
+        }
+    }
+
+    /** 批量加载任务(空闲 taskId 忽略),返回 id → Task 映射 */
+    private Map<Long, Task> loadTasksByIds(List<Long> taskIds) {
+        Map<Long, Task> taskById = new HashMap<>();
+        List<Task> loaded = taskMapper.selectBatchIds(taskIds);
+        if (loaded != null) {
+            for (Task t : loaded) {
+                if (t != null && t.getId() != null) {
+                    taskById.put(t.getId(), t);
+                }
+            }
         }
+        return taskById;
+    }
+
+    /** 批量共享上下文(全批 TASK_COMPLETE 共用同一规则与日限状态) */
+    private static class TaskEstimateBatchCtx {
+        boolean ctxReady = false;      // false=规则缺失/停用/未生效 → 全批降级
+        CfEarnRule rule;
+        Map<String, Object> params;
+        String refType;
+        Integer dailyCountLimit;       // 日限次数(null=未配置)
+        Long todayCount;               // 今日已发次数(需次数闸门时解析)
+        double amountLimit;            // 金额闸门阈值(0=未启用)
+        Long todayEarned;              // 今日已获金额(需金额闸门时解析)
+    }
+
+    /** 解析批量共享上下文:规则加载一次 + 日限状态一次(替代逐条 estimate 内的重复查询) */
+    private TaskEstimateBatchCtx resolveTaskBatchCtx(Long memberId) {
+        TaskEstimateBatchCtx ctx = new TaskEstimateBatchCtx();
+        String ruleCode = OPERATION_TO_RULE.get(normalizeOpType(OP_TASK_COMPLETE));
+        CfEarnRule rule = cfEarnRuleService.getByCode(ruleCode);
+        if (rule == null || rule.getEnabled() == null || rule.getEnabled() != 1) {
+            return ctx;
+        }
+        Date now = new Date();
+        if (rule.getEffectiveFrom() != null && now.before(rule.getEffectiveFrom())) {
+            return ctx;
+        }
+        if (rule.getEffectiveTo() != null && now.after(rule.getEffectiveTo())) {
+            return ctx;
+        }
+        ctx.rule = rule;
+        ctx.params = parseParams(rule.getParamsJson());
+        ctx.refType = rule.getDedupRefType() != null && !rule.getDedupRefType().trim().isEmpty()
+                ? rule.getDedupRefType().trim() : ruleCode;
+        try {
+            // 日限上下文解析失败仅关闭闸门(与原 clampByDailyLimits 内部 catch → 返回原始值一致),不阻断批次
+            Integer dailyCountLimit = resolveDailyLimitCount(memberId, rule);
+            ctx.dailyCountLimit = dailyCountLimit;
+            if (dailyCountLimit != null && dailyCountLimit > 0) {
+                ctx.todayCount = countTodayEarn(memberId, ctx.refType);
+            }
+            double amountLimit = resolveAmountDailyLimit(rule);
+            ctx.amountLimit = amountLimit;
+            if (amountLimit > 0) {
+                ctx.todayEarned = sumTodayEarned(memberId, ctx.refType);
+            }
+        } catch (Exception e) {
+            log.warn("批量日限上下文解析失败,关闭日限闸门 memberId={} rule={}:{}",
+                    memberId, rule.getCode(), e.getMessage());
+        }
+        ctx.ctxReady = true;
+        return ctx;
+    }
+
+    /** 按批量共享上下文内存回填单条任务(不触发任何 DB 查询;单条异常降级,不影响其余) */
+    private void fillTaskEstimateFromBatch(Task task, Map<Long, Task> taskById, TaskEstimateBatchCtx ctx) {
+        try {
+            if (!ctx.ctxReady) {
+                // 规则缺失/停用/未生效 → 降级(与原 resolveRuleAndCtx.degraded 一致)
+                task.setExpectedCf(null);
+                task.setDisplay(degradedDisplay());
+                return;
+            }
+            Task loaded = taskById.get(task.getId());
+            if (loaded == null || loaded.getPoints() == null || loaded.getPoints() <= 0) {
+                // 任务不存在或无奖励积分 → 降级(与原 resolveCtx 返回 null 一致)
+                task.setExpectedCf(null);
+                task.setDisplay(degradedDisplay());
+                return;
+            }
+            Map<String, Object> perTaskCtx = new HashMap<>();
+            perTaskCtx.put("amount", loaded.getPoints());
+            perTaskCtx.put("task_id", task.getId());
+            int rawAmount = computeAmount(ctx.rule, ctx.params, perTaskCtx);
+            int clamped = clampBatchByDailyLimits(ctx, rawAmount);
+            task.setExpectedCf(clamped);
+            task.setDisplay(buildDisplay(clamped));
+        } catch (Exception e) {
+            log.warn("批量挂载单条任务预估失败 taskId={}:{}", task.getId(), e.getMessage());
+            task.setExpectedCf(null);
+            task.setDisplay(degradedDisplay());
+        }
+    }
+
+    /** 批量日限钳制:与 clampByDailyLimits 语义一致,仅使用预解析的共享上下文(内存计算) */
+    private int clampBatchByDailyLimits(TaskEstimateBatchCtx ctx, int rawAmount) {
+        if (ctx.dailyCountLimit != null && ctx.dailyCountLimit > 0 && ctx.todayCount != null) {
+            if (ctx.todayCount >= ctx.dailyCountLimit) {
+                return 0;
+            }
+        }
+        if (ctx.amountLimit > 0 && ctx.todayEarned != null) {
+            int remaining = (int) Math.max(0, Math.floor(ctx.amountLimit - ctx.todayEarned + 1e-6));
+            if (rawAmount > remaining) {
+                return remaining;
+            }
+        }
+        return rawAmount;
+    }
+
+    /** 降级展示(与 degradedVO 的 display 一致,供批量回填复用) */
+    private RewardDisplayVO degradedDisplay() {
+        RewardDisplayVO display = new RewardDisplayVO();
+        display.setType(DISPLAY_DEGRADED);
+        display.setTemplate("CF 奖励以实际为准");
+        display.setCfValue(0);
+        return display;
     }
 
     /** 为分页对象挂载预估(MyBatis-Plus IPage) */

+ 129 - 0
cfc-backend/src/test/java/com/etotem/cfc/config/JwtInterceptorLoginRequiredPathTest.java

@@ -0,0 +1,129 @@
+package com.etotem.cfc.config;
+
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+import javax.servlet.http.HttpServletResponse;
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * B-22 鉴权清单参数化契约:遍历 {@link JwtInterceptor#LOGIN_REQUIRED_PATHS} 全部条目,
+ * 无 token 时一律 401(安全拦截),无 token 时不得匿名放行(防业务 500)。
+ *
+ * <p>与 {@link JwtInterceptorLoginRequiredTest} 互补:后者只覆盖历史已修复的个别路径;
+ * 本类对清单做全量参数化覆盖,防止新增路由时鉴权漂移再次漏测(SC-G-06 返工根因)。
+ */
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+class JwtInterceptorLoginRequiredPathTest {
+
+    @Mock
+    private UserMapper userMapper;
+
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    @InjectMocks
+    private JwtInterceptor jwtInterceptor;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    // ====================================================================
+    // 主断言:全部 LOGIN_REQUIRED_PATHS 无 token → 401
+    // ====================================================================
+
+    static Stream<String[]> loginRequiredCases() throws Exception {
+        String[] paths = readLoginRequiredPaths();
+        return Arrays.stream(paths).map(p -> new String[]{p});
+    }
+
+    @ParameterizedTest(name = "[{index}] {0} 无 token → 401")
+    @MethodSource("loginRequiredCases")
+    @Order(1)
+    void 无token_一律返回401(String entry) throws Exception {
+        // 前缀路径(以 / 结尾)取一个子路径作为 uri;精确路径原样测试
+        String uri = entry.endsWith("/") ? entry + "action" : entry;
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI(uri);
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+
+        assertFalse(result, uri + " 无 token 必须被拦(LOGIN_REQUIRED)");
+        assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus(), uri + " 应返回 401");
+        String body = response.getContentAsString();
+        assertTrue(body.contains("请先登录"), uri + " 应返回统一 401 提示: " + body);
+        assertFalse(body.contains("Exception"), uri + " 不应泄露内部异常: " + body);
+    }
+
+    @Test
+    @Order(2)
+    void 清单全量_统计条目数非空() throws Exception {
+        String[] paths = readLoginRequiredPaths();
+        assertFalse(paths.length == 0, "LOGIN_REQUIRED_PATHS 不得为空");
+        // 覆盖已知高危条目:reward/estimate、share/qrcode、health/report 族等
+        List<String> all = Arrays.asList(paths);
+        assertTrue(all.contains("/api/reward/estimate"), "SC-X-22 预估接口必须要求登录");
+        assertTrue(all.contains("/api/share/qrcode"), "海报小程序码必须要求登录(防微信 quota 耗尽)");
+        assertTrue(all.contains("/api/health/report/"), "健康报告族必须要求登录");
+        assertTrue(all.contains("/api/tasks/"), "任务族必须要求登录");
+        assertTrue(all.contains("/api/family/member/"), "家庭成员族必须要求登录");
+    }
+
+    // ====================================================================
+    // 反向验证:公开路径(PUBLIC_PATHS)无 token 仍放行,不被 LOGIN_REQUIRED 误拦
+    // ====================================================================
+
+    @Test
+    void 公开路径_noToken_仍放行() throws Exception {
+        // 取一条已知公开路径,证明 LOGIN_REQUIRED 不影响公开路径
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI("/api/product/list");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+
+        assertTrue(result, "/api/product/list 公开路径无 token 必须放行");
+        assertEquals(HttpServletResponse.SC_OK, response.getStatus());
+    }
+
+    // ====================================================================
+    // 私有辅助
+    // ====================================================================
+
+    private static String[] readLoginRequiredPaths() throws Exception {
+        Field f = JwtInterceptor.class.getDeclaredField("LOGIN_REQUIRED_PATHS");
+        f.setAccessible(true);
+        List<String> raw = Arrays.asList((String[]) f.get(null));
+        // 去除带注释的行(以 // 开头)
+        return raw.stream()
+                .map(s -> s.trim())
+                .filter(s -> !s.isEmpty() && !s.startsWith("//"))
+                .collect(Collectors.toList())
+                .toArray(new String[0]);
+    }
+}

+ 348 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/RewardRuleServiceBatchEstimateTest.java

@@ -0,0 +1,348 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.config.ConfigService;
+import com.etotem.cfc.controller.task.TaskController;
+import com.etotem.cfc.dto.RewardDisplayVO;
+import com.etotem.cfc.entity.CfEarnRule;
+import com.etotem.cfc.entity.FamilyMember;
+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.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+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.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * B-22:CF 预估挂载 N+1 优化 —— {@link RewardRuleService#attachTaskListEstimate} 批量化验证。
+ *
+ * <p>与 {@code RewardRuleServiceEstimateTest}(S-cfc-16 契约级单测,验证 estimate() 单条语义)互补:
+ * 本类专测列表挂载的批量化路径,覆盖三类断言:
+ * <ol>
+ *   <li><b>批量查询次数</b>:N 条任务时 estimate 相关查询 ≤ 常数(不随 N 线性增长)——
+ *       N=3 与 N=30 的查询计数相等,且逐条 {@code taskMapper.selectById} 不再发生(单次 selectBatchIds)。</li>
+ *   <li><b>降级不阻塞</b>:批量查询抛异常时方法不抛出(接口仍 200,不 500),该批任务保持未挂载。</li>
+ *   <li><b>语义一致</b>:批量挂载结果与逐条 estimate() 完全一致(expectedCf / display.type),
+ *       memberId==null 与空列表直接返回不触发任何查询。</li>
+ * </ol>
+ * 纯单元测试:Mockito 注入 mapper/service 依赖,不连接数据库。
+ */
+class RewardRuleServiceBatchEstimateTest {
+
+    private static final long MEMBER_ID = 5001L;
+    private static final long FAMILY_ID = 100L;
+
+    // ==================== 批量查询次数(N+1 → 常数) ====================
+
+    @Test
+    void 批量挂载_N条任务_查询次数不随N线性增长() {
+        // N=3 与 N=30 两次独立运行,查询计数必须相等(常数)
+        int count3 = countEstimateQueries(3);
+        int count30 = countEstimateQueries(30);
+        assertEquals(count3, count30,
+                "N 增大查询次数不应线性增长:N=3 计 " + count3 + ",N=30 计 " + count30);
+        assertTrue(count30 <= 8,
+                "estimate 相关查询应 ≤ 常数(实际 N=30 计 " + count30 + ",远小于逐条 N+1)");
+    }
+
+    private int countEstimateQueries(int n) {
+        AtomicInteger counter = new AtomicInteger();
+        Rig rig = newRig();
+        List<Task> tasks = new ArrayList<>();
+        List<Task> dbRows = new ArrayList<>();
+        for (int i = 1; i <= n; i++) {
+            tasks.add(task((long) i, 5 + i));
+            dbRows.add(task((long) i, 5 + i));
+        }
+        // 单次批量查询(替代逐条 selectById)
+        when(rig.taskMapper.selectBatchIds(anyList())).thenAnswer(inv -> {
+            counter.incrementAndGet();
+            return dbRows;
+        });
+        // 规则解析一次(TASK_COMPLETE → F22)
+        when(rig.cfEarnRuleService.getByCode(eq("F22"))).thenAnswer(inv -> {
+            counter.incrementAndGet();
+            return enabledFixedRule(10);
+        });
+        when(rig.familyMemberMapper.selectById(anyLong())).thenAnswer(inv -> {
+            counter.incrementAndGet();
+            return member(FAMILY_ID);
+        });
+        when(rig.membershipService.getCurrentLevel(anyLong())).thenAnswer(inv -> {
+            counter.incrementAndGet();
+            return null; // 无会员等级 → FREE
+        });
+        when(rig.cfEarnDailyLimitMapper.selectOne(any())).thenAnswer(inv -> {
+            counter.incrementAndGet();
+            return null; // 无日限 → 不触发次数/金额闸门查询
+        });
+
+        rig.service.attachTaskListEstimate(tasks, MEMBER_ID);
+
+        // 逐条 selectById 绝不允许再发生(这是 N+1 根因)
+        verify(rig.taskMapper, never()).selectById(anyLong());
+        // 次数/金额闸门在无日限配置下不应触发任何账本查询
+        verify(rig.cfBalanceLogMapper, never()).selectCount(any());
+        verify(rig.cfBalanceLogMapper, never()).selectObjs(any());
+        return counter.get();
+    }
+
+    @Test
+    void 批量挂载_无日限_每条任务正确挂载EARN() {
+        Rig rig = newRig();
+        List<Task> tasks = new ArrayList<>();
+        List<Task> dbRows = new ArrayList<>();
+        for (int i = 1; i <= 3; i++) {
+            tasks.add(task((long) i, 10));
+            dbRows.add(task((long) i, 10));
+        }
+        when(rig.taskMapper.selectBatchIds(anyList())).thenReturn(dbRows);
+        when(rig.cfEarnRuleService.getByCode(eq("F22"))).thenReturn(enabledFixedRule(10));
+        when(rig.familyMemberMapper.selectById(anyLong())).thenReturn(member(FAMILY_ID));
+        when(rig.membershipService.getCurrentLevel(anyLong())).thenReturn(null);
+        when(rig.cfEarnDailyLimitMapper.selectOne(any())).thenReturn(null);
+
+        rig.service.attachTaskListEstimate(tasks, MEMBER_ID);
+
+        for (Task t : tasks) {
+            assertEquals(10, t.getExpectedCf(), "规则 fixed amount=10 → 每条 expectedCf=10");
+            RewardDisplayVO display = t.getDisplay();
+            assertNotNull(display);
+            assertEquals("EARN", display.getType(), "无日限 → display.type=EARN");
+            assertEquals("本操作预计获得 10 CF", display.getTemplate());
+        }
+        verify(rig.taskMapper, never()).selectById(anyLong());
+    }
+
+    // ==================== 降级不阻塞(接口仍 200,不 500) ====================
+
+    @Test
+    void 批量查询异常_不抛出_该批任务保持未挂载() {
+        Rig rig = newRig();
+        List<Task> tasks = Arrays.asList(task(1L, 10), task(2L, 20));
+        when(rig.taskMapper.selectBatchIds(anyList()))
+                .thenThrow(new RuntimeException("db unavailable"));
+
+        assertDoesNotThrow(() -> rig.service.attachTaskListEstimate(tasks, MEMBER_ID),
+                "批量查询异常必须被吞掉,不得向 controller 抛出(否则接口 500)");
+        // 跳过整批:规则/日限上下文不应再解析,任务保持未挂载
+        verify(rig.cfEarnRuleService, never()).getByCode(anyString());
+        for (Task t : tasks) {
+            assertNull(t.getExpectedCf(), "整批跳过 → expectedCf 保持 null(降级展示)");
+            assertNull(t.getDisplay(), "整批跳过 → display 保持 null");
+        }
+    }
+
+    @Test
+    void 规则未配置_批量挂载_逐条降级为DEGRADED() {
+        Rig rig = newRig();
+        List<Task> tasks = Arrays.asList(task(1L, 10), task(2L, 20));
+        when(rig.taskMapper.selectBatchIds(anyList())).thenReturn(tasks);
+        when(rig.cfEarnRuleService.getByCode(eq("F22"))).thenReturn(null); // 规则缺失
+
+        assertDoesNotThrow(() -> rig.service.attachTaskListEstimate(tasks, MEMBER_ID));
+        for (Task t : tasks) {
+            assertNull(t.getExpectedCf(), "规则未配置 → expectedCf=null(降级)");
+            assertEquals("DEGRADED", t.getDisplay().getType(), "规则未配置 → display.type=DEGRADED");
+        }
+    }
+
+    @Test
+    void 单条任务无积分_仅该条降级_其余正常挂载() {
+        Rig rig = newRig();
+        Task t1 = task(1L, 10);
+        Task t2 = task(2L, 0); // 无奖励积分 → 该条降级
+        List<Task> tasks = Arrays.asList(t1, t2);
+        when(rig.taskMapper.selectBatchIds(anyList())).thenReturn(Arrays.asList(task(1L, 10), task(2L, 0)));
+        when(rig.cfEarnRuleService.getByCode(eq("F22"))).thenReturn(enabledFixedRule(10));
+        when(rig.familyMemberMapper.selectById(anyLong())).thenReturn(member(FAMILY_ID));
+        when(rig.membershipService.getCurrentLevel(anyLong())).thenReturn(null);
+        when(rig.cfEarnDailyLimitMapper.selectOne(any())).thenReturn(null);
+
+        rig.service.attachTaskListEstimate(tasks, MEMBER_ID);
+
+        assertEquals(10, t1.getExpectedCf(), "有积分任务正常挂载");
+        assertEquals("EARN", t1.getDisplay().getType());
+        assertNull(t2.getExpectedCf(), "无积分任务降级 → expectedCf=null");
+        assertEquals("DEGRADED", t2.getDisplay().getType(), "无积分任务 → display.type=DEGRADED");
+    }
+
+    // ==================== 语义一致(批量 == 逐条) ====================
+
+    @Test
+    void 批量挂载结果_与逐条estimate_完全一致() {
+        // 批量路径
+        Rig batch = newRig();
+        List<Task> batchTasks = Arrays.asList(task(1L, 5), task(2L, 10), task(3L, 15));
+        List<Task> batchRows = Arrays.asList(task(1L, 5), task(2L, 10), task(3L, 15));
+        when(batch.taskMapper.selectBatchIds(anyList())).thenReturn(batchRows);
+        when(batch.cfEarnRuleService.getByCode(eq("F22"))).thenReturn(enabledFixedRule(10));
+        when(batch.familyMemberMapper.selectById(anyLong())).thenReturn(member(FAMILY_ID));
+        when(batch.membershipService.getCurrentLevel(anyLong())).thenReturn(null);
+        when(batch.cfEarnDailyLimitMapper.selectOne(any())).thenReturn(null);
+        batch.service.attachTaskListEstimate(batchTasks, MEMBER_ID);
+
+        // 逐条路径(旧行为:estimate() 逐条查)
+        Rig perItem = newRig();
+        List<Task> perTasks = Arrays.asList(task(1L, 5), task(2L, 10), task(3L, 15));
+        Map<Long, Task> byId = new HashMap<>();
+        for (Task t : perTasks) {
+            byId.put(t.getId(), task(t.getId(), t.getPoints()));
+        }
+        when(perItem.cfEarnRuleService.getByCode(eq("F22"))).thenReturn(enabledFixedRule(10));
+        when(perItem.taskMapper.selectById(anyLong())).thenAnswer(inv -> byId.get(inv.getArgument(0)));
+        when(perItem.familyMemberMapper.selectById(anyLong())).thenReturn(member(FAMILY_ID));
+        when(perItem.membershipService.getCurrentLevel(anyLong())).thenReturn(null);
+        when(perItem.cfEarnDailyLimitMapper.selectOne(any())).thenReturn(null);
+        for (Task t : perTasks) {
+            perItem.service.attachTaskEstimate(t, MEMBER_ID);
+        }
+
+        for (int i = 0; i < batchTasks.size(); i++) {
+            assertEquals(perTasks.get(i).getExpectedCf(), batchTasks.get(i).getExpectedCf(),
+                    "expectedCf 必须一致(taskId=" + batchTasks.get(i).getId() + ")");
+            assertEquals(perTasks.get(i).getDisplay().getType(), batchTasks.get(i).getDisplay().getType(),
+                    "display.type 必须一致(taskId=" + batchTasks.get(i).getId() + ")");
+        }
+    }
+
+    @Test
+    void memberId为null_直接返回不触发查询() {
+        Rig rig = newRig();
+        List<Task> tasks = Arrays.asList(task(1L, 10));
+        rig.service.attachTaskListEstimate(tasks, null);
+        verifyNoInteractions(rig.taskMapper, rig.cfEarnRuleService, rig.familyMemberMapper);
+    }
+
+    @Test
+    void 空列表_直接返回不触发查询() {
+        Rig rig = newRig();
+        rig.service.attachTaskListEstimate(new ArrayList<>(), MEMBER_ID);
+        verifyNoInteractions(rig.taskMapper, rig.cfEarnRuleService, rig.familyMemberMapper);
+    }
+
+    // ==================== 接口层:批量查询异常 → 200(不 500) ====================
+
+    @Test
+    void 接口_批量查询异常_仍返回200() throws Exception {
+        // 真实 RewardRuleService(mapper 抛异常)+ 真实 TaskController 直调,验证接口不 500
+        Rig rig = newRig();
+        when(rig.taskMapper.selectBatchIds(anyList())).thenThrow(new RuntimeException("db down"));
+
+        TaskController controller = new TaskController();
+        setField(controller, "rewardRuleService", rig.service);
+        TaskService taskService = mock(TaskService.class);
+        List<Task> tasks = Arrays.asList(task(1L, 10), task(2L, 20));
+        when(taskService.getTodayTasks(eq(MEMBER_ID), isNull())).thenReturn(tasks);
+        setField(controller, "taskService", taskService);
+        // 其余 controller 依赖本次路径不触达,仅注入最小集
+        setField(controller, "miniGameService", mock(MiniGameService.class));
+        setField(controller, "taskMapper", mock(TaskMapper.class));
+        setField(controller, "membershipService", mock(MembershipService.class));
+        setField(controller, "problemCompletionService", mock(ProblemCompletionService.class));
+        setField(controller, "profileComputeService", mock(ProfileComputeService.class));
+
+        Result<List<Task>> result = controller.getTodayTasks(new HashMap<>(), MEMBER_ID, FAMILY_ID);
+
+        assertEquals(200, result.getCode(), "批量查询异常时接口必须仍返回 200,不 500");
+        assertEquals(tasks, result.getData(), "任务列表原样返回(预估降级,主流程不阻塞)");
+    }
+
+    // ==================== 工具方法 ====================
+
+    /** 测试夹具:真实 service + 注入的 mock 依赖(暴露 mock 以便 verify / stub) */
+    private static class Rig {
+        RewardRuleService service;
+        CfEarnRuleService cfEarnRuleService;
+        CfBalanceLogMapper cfBalanceLogMapper;
+        CfEarnDailyLimitMapper cfEarnDailyLimitMapper;
+        FamilyMemberMapper familyMemberMapper;
+        TaskMapper taskMapper;
+        MembershipService membershipService;
+    }
+
+    private Rig newRig() {
+        Rig rig = new Rig();
+        rig.service = new RewardRuleService();
+        rig.cfEarnRuleService = mock(CfEarnRuleService.class);
+        rig.cfBalanceLogMapper = mock(CfBalanceLogMapper.class);
+        rig.cfEarnDailyLimitMapper = mock(CfEarnDailyLimitMapper.class);
+        rig.familyMemberMapper = mock(FamilyMemberMapper.class);
+        rig.taskMapper = mock(TaskMapper.class);
+        rig.membershipService = mock(MembershipService.class);
+        setField(rig.service, "cfEarnRuleService", rig.cfEarnRuleService);
+        setField(rig.service, "cfBalanceLogMapper", rig.cfBalanceLogMapper);
+        setField(rig.service, "cfEarnDailyLimitMapper", rig.cfEarnDailyLimitMapper);
+        setField(rig.service, "configService", mock(ConfigService.class));
+        setField(rig.service, "familyMemberMapper", rig.familyMemberMapper);
+        setField(rig.service, "taskMapper", rig.taskMapper);
+        setField(rig.service, "growthTaskMapper", mock(GrowthTaskMapper.class));
+        setField(rig.service, "productMapper", mock(ProductMapper.class));
+        setField(rig.service, "rewardMapper", mock(RewardMapper.class));
+        setField(rig.service, "objectMapper", new ObjectMapper());
+        setField(rig.service, "membershipService", rig.membershipService);
+        return rig;
+    }
+
+    private Task task(long id, int points) {
+        Task t = new Task();
+        t.setId(id);
+        t.setPoints(points);
+        return t;
+    }
+
+    private FamilyMember member(long familyId) {
+        FamilyMember m = new FamilyMember();
+        m.setId(MEMBER_ID);
+        m.setFamilyId(familyId);
+        return m;
+    }
+
+    private CfEarnRule enabledFixedRule(int amount) {
+        CfEarnRule rule = new CfEarnRule();
+        rule.setCode("F22");
+        rule.setEnabled(1);
+        rule.setRuleType("fixed");
+        rule.setAmount(amount);
+        return rule;
+    }
+
+    private void setField(Object target, String name, Object value) {
+        try {
+            Field f = target.getClass().getDeclaredField(name);
+            f.setAccessible(true);
+            f.set(target, value);
+        } catch (Exception e) {
+            throw new RuntimeException("注入字段失败:" + name, e);
+        }
+    }
+}