|
|
@@ -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);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|