diff --git a/fix_task_reward_null_npe_2026-10-11.sql b/fix_task_reward_null_npe_2026-10-11.sql new file mode 100644 index 0000000..ce3f07e --- /dev/null +++ b/fix_task_reward_null_npe_2026-10-11.sql @@ -0,0 +1,49 @@ +-- 用途:把 yxe_task.reward 为 NULL 的历史脏数据规范化为 0,消除业绩统计接口的空指针隐患 +-- 日期:2026-10-11 +-- 背景:小程序 pages/performance/index 页面请求的 /yxe-user-task/userRank 与 /yxe-task/data +-- 同时返回 {"code":1,"message":"操作失败","error":"java.lang.NullPointerException"}, +-- 页面「我的完成进度/完成率」与人员排行榜全部空白。 +-- 根因:yxe_task.reward 为 NULL 的任务被计入完成数后执行 +-- BigDecimal.ZERO.add(task.getReward()) +-- → BigDecimal.add(null) 抛 NPE,整个接口失败。 +-- 本库命中数据为 yxe_task.id=663(title='测试手动',status=2 已完成, +-- 其 yxe_user_task.id=630 / user_id=875 的 status=2)。注意任务状态为 2 的只有这 1 条, +-- 但它足以让「全量排行榜」接口整体失败——userRank 不传 name 时遍历全部用户。 +-- 影响:1 行 +-- 性质:幂等,可重复执行;只处理 status 已完成但仍为 NULL 的奖励金额 +-- 配套代码: +-- 1) 读侧兜底:YxeTaskController#data、YxeUserTaskController#userRank +-- 对 reward 做 nullToZero,接口不再报错; +-- 2) 写侧堵源:YxeTaskController#save / #saveBatch 在新增时把缺失的 reward 兜底为 0, +-- 不再产生新的 NULL 脏数据。 +-- 注意 #update / #updateBatch **刻意不补默认值**:二者是部分更新语义 +-- (MyBatis-Plus 默认 NOT_NULL 策略),null 表示“本次不修改该字段”, +-- 若在那里兜底会把任务原有奖金静默清零,直接影响用户实得收益。 +-- 3) 回归测试:src/test/java/com/gxwebsoft/yxe/controller/TaskRewardNullSafetyTest.java +-- 说明:本脚本属于「历史数据规范化」。即使不执行,接口也已不再报错(读侧兜底), +-- 但金额会按 0 计;执行后数据本身也归于一致。 + +-- 执行前核对:先看清 NULL 分布在哪些任务、是否已有人完成任务 +SELECT t.id AS task_id, + t.title, + t.status AS task_status, + t.reward, + COUNT(ut.id) AS user_task_cnt, + SUM(ut.status = 2) AS completed_cnt +FROM yxe_task t +LEFT JOIN yxe_user_task ut ON ut.task_id = t.id +WHERE t.reward IS NULL +GROUP BY t.id, t.title, t.status, t.reward +ORDER BY t.id; + +-- 变更:仅规范化「已完成/进行中」且 reward 为 NULL 的任务,避免影响尚未开始的任务 +UPDATE yxe_task +SET reward = 0.00 +WHERE reward IS NULL + AND status IN (1, 2); + +-- 执行后核对(remaining_null 应为 0) +SELECT SUM(reward IS NULL) AS remaining_null, + SUM(reward IS NULL AND status IN (1, 2)) AS remaining_null_active, + COUNT(*) AS total +FROM yxe_task; diff --git a/src/main/java/com/gxwebsoft/yxe/controller/YxeTaskController.java b/src/main/java/com/gxwebsoft/yxe/controller/YxeTaskController.java index d593b5a..0c4e5a0 100644 --- a/src/main/java/com/gxwebsoft/yxe/controller/YxeTaskController.java +++ b/src/main/java/com/gxwebsoft/yxe/controller/YxeTaskController.java @@ -103,21 +103,21 @@ public class YxeTaskController extends BaseController { for (YxeTask task : taskList) { YxeUserTask userTask = userTaskList.stream().filter(u -> u.getTaskId().equals(task.getId())).findFirst().orElse(null); if (userTask != null) { - if (userTask.getStatus().equals(2)) { + if (Integer.valueOf(2).equals(userTask.getStatus())) { doneNum++; - income = income.add(task.getReward()); + income = income.add(nullToZero(task.getReward())); } } } - if (getLoginUser().getCanAuth().equals(1)) { + if (Integer.valueOf(1).equals(getLoginUser().getCanAuth())) { List allUserTaskList = yxeUserTaskService.listByTaskIds(taskIds); totalTaskNum = allUserTaskList.size(); for (YxeTask task : taskList) { YxeUserTask userTask = allUserTaskList.stream().filter(u -> u.getTaskId().equals(task.getId())).findFirst().orElse(null); if (userTask != null) { - if (userTask.getStatus().equals(2)) { + if (Integer.valueOf(2).equals(userTask.getStatus())) { totalDoneNum++; - totalIncome = totalIncome.add(task.getReward()); + totalIncome = totalIncome.add(nullToZero(task.getReward())); } } } @@ -143,6 +143,13 @@ public class YxeTaskController extends BaseController { return success(data); } + /** + * 任务奖金可能为 null(历史数据/未填金额),参与金额累加前统一兜底为 0,避免 NullPointerException + */ + private BigDecimal nullToZero(BigDecimal value) { + return value == null ? BigDecimal.ZERO : value; + } + @ApiOperation("分页查询") @GetMapping("/page") @@ -291,7 +298,7 @@ public class YxeTaskController extends BaseController { List taskFilter = new ArrayList<>(); if (!taskList.isEmpty()) { List taskIds = taskList.stream().map(YxeTask::getId).collect(Collectors.toList()); - if (getLoginUser().getCanAuth().equals(1)) { + if (Integer.valueOf(1).equals(getLoginUser().getCanAuth())) { userTaskList = yxeUserTaskService.listByTaskIds(taskIds); } else { userTaskList = yxeUserTaskService.listByTaskIds(taskIds, getLoginUserId()); @@ -331,6 +338,10 @@ public class YxeTaskController extends BaseController { if (loginUser != null) { yxeTask.setUserId(loginUser.getUserId()); } + // 奖励金额缺失时兜底为 0:避免落库 NULL 后,业绩统计接口在 BigDecimal.add(null) 处抛空指针 + if (yxeTask.getReward() == null) { + yxeTask.setReward(BigDecimal.ZERO); + } if (yxeTask.getMerchantId() != null) { Merchant merchant = merchantService.getByIdNChain(yxeTask.getMerchantId(), yxeTask.getChain()); if (merchant == null) { @@ -379,6 +390,11 @@ public class YxeTaskController extends BaseController { @ApiOperation("修改") @PutMapping() public ApiResult update(@RequestBody YxeTask yxeTask) { + // 注意:此处刻意不为 reward 补默认值。 + // updateById 走 MyBatis-Plus 默认的 NOT_NULL 更新策略,字段为 null 时不会拼进 SET 子句, + // 语义是“本次不修改该字段”。管理端编辑任务时若未填奖励金额,reward 字段会整个缺失, + // 若在这里兜底成 0,会把任务原有的奖金静默清零,进而影响用户实得收益。 + // 兜底只应发生在新增(save / saveBatch)路径。 if (yxeTaskService.updateById(yxeTask)) { yxeTaskProductService.removeByTask(yxeTask.getId()); if (yxeTask.getProductList() != null && !yxeTask.getProductList().isEmpty()) { @@ -408,6 +424,14 @@ public class YxeTaskController extends BaseController { @ApiOperation("批量添加") @PostMapping("/batch") public ApiResult saveBatch(@RequestBody List list) { + // 奖励金额缺失时兜底为 0,与 save 保持一致,避免落库 NULL + if (list != null) { + for (YxeTask item : list) { + if (item != null && item.getReward() == null) { + item.setReward(BigDecimal.ZERO); + } + } + } // for (YxeTask yxeTask : list) { // if (yxeTask.getProductList() == null || yxeTask.getProductList().isEmpty()) // return fail("请选择产品"); @@ -455,6 +479,10 @@ public class YxeTaskController extends BaseController { @ApiOperation("批量修改") @PutMapping("/batch") public ApiResult updateBatch(@RequestBody BatchParam batchParam) { + // 注意:此处刻意不为 reward 补默认值。 + // BatchParam.update 使用 UpdateWrapper 做部分更新,null 字段不会进入 SET 子句; + // 批量修改通常只改状态等个别字段,若在此兜底成 0,会把 ids 命中的全部任务奖金清零。 + // 兜底只应发生在新增(save / saveBatch)路径。 if (batchParam.update(yxeTaskService, "id")) { return success("修改成功"); } diff --git a/src/main/java/com/gxwebsoft/yxe/controller/YxeUserTaskController.java b/src/main/java/com/gxwebsoft/yxe/controller/YxeUserTaskController.java index a69604c..613bce3 100644 --- a/src/main/java/com/gxwebsoft/yxe/controller/YxeUserTaskController.java +++ b/src/main/java/com/gxwebsoft/yxe/controller/YxeUserTaskController.java @@ -96,14 +96,23 @@ public class YxeUserTaskController extends BaseController { BigDecimal incomeAmount = BigDecimal.ZERO; List taskList = yxeUserTaskService.listByUserId(user.getUserId()); if (!taskList.isEmpty()) { - List taskDataList = yxeTaskService.listByIds(taskList.stream().map(YxeUserTask::getTaskId).collect(Collectors.toList())); + List taskIds = taskList.stream() + .map(YxeUserTask::getTaskId) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toList()); + List taskDataList = taskIds.isEmpty() + ? new ArrayList<>() + : yxeTaskService.listByIds(taskIds); totalTaskNum = taskList.size(); for (YxeUserTask task : taskList) { - if (task.getStatus().equals(2)) { - YxeTask taskData = taskDataList.stream().filter(t -> t.getId().equals(task.getTaskId())).findFirst().orElse(null); + if (Integer.valueOf(2).equals(task.getStatus())) { + YxeTask taskData = taskDataList.stream() + .filter(t -> t.getId() != null && t.getId().equals(task.getTaskId())) + .findFirst().orElse(null); if (taskData != null) { taskCompleteNum++; - incomeAmount = incomeAmount.add(taskData.getReward()); + incomeAmount = incomeAmount.add(nullToZero(taskData.getReward())); } } } @@ -119,6 +128,13 @@ public class YxeUserTaskController extends BaseController { return success(userList); } + /** + * 任务奖金可能为 null(历史数据/未填金额),参与金额累加前统一兜底为 0,避免 NullPointerException + */ + private BigDecimal nullToZero(BigDecimal value) { + return value == null ? BigDecimal.ZERO : value; + } + @ApiOperation("分页查询用户任务") @GetMapping("/page") public ApiResult> page(YxeUserTaskParam param) { diff --git a/src/test/java/com/gxwebsoft/yxe/controller/TaskRewardNullSafetyTest.java b/src/test/java/com/gxwebsoft/yxe/controller/TaskRewardNullSafetyTest.java new file mode 100644 index 0000000..d1a6cc3 --- /dev/null +++ b/src/test/java/com/gxwebsoft/yxe/controller/TaskRewardNullSafetyTest.java @@ -0,0 +1,276 @@ +package com.gxwebsoft.yxe.controller; + +import com.gxwebsoft.common.core.Constants; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BatchParam; +import com.gxwebsoft.common.system.entity.User; +import com.gxwebsoft.common.system.service.UserService; +import com.gxwebsoft.yxe.entity.YxeTask; +import com.gxwebsoft.yxe.entity.YxeUserTask; +import com.gxwebsoft.yxe.param.YxeTaskParam; +import com.gxwebsoft.yxe.param.YxeUserTaskParam; +import com.gxwebsoft.yxe.service.YxeTaskProductService; +import com.gxwebsoft.yxe.service.YxeTaskService; +import com.gxwebsoft.yxe.service.YxeUserTaskService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +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.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.when; + +/** + * 回归测试:yxe_task.reward 为 NULL 时,业绩统计接口不得抛 NullPointerException。 + * + *

背景:线上存在 reward 为 NULL 的任务(如 yxe_task.id=663)。 + * {@code BigDecimal.ZERO.add(null)} 会抛 NPE,导致小程序 pages/performance/index + * 请求的 /yxe-user-task/userRank 与 /yxe-task/data 双双返回 {"code":1,"error":"java.lang.NullPointerException"}, + * 页面进度与排行榜全部空白。 + * + *

这里用 mock 直接驱动控制器的真实调用路径(含 {@code Integer.equals} 拆箱、 + * 金额累加),不依赖数据库,可以稳定地在修复前转红、修复后转绿。 + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class TaskRewardNullSafetyTest { + + /** 与 yxe_task.id=663 对应的异常数据:任务已完成,但 reward 为 NULL。 */ + private static final Integer ABNORMAL_TASK_ID = 663; + private static final Integer LOGIN_USER_ID = 875; + + @Mock + private YxeTaskService yxeTaskService; + @Mock + private YxeUserTaskService yxeUserTaskService; + @Mock + private YxeTaskProductService yxeTaskProductService; + @Mock + private UserService userService; + + @InjectMocks + private YxeTaskController yxeTaskController; + @InjectMocks + private YxeUserTaskController yxeUserTaskController; + + @AfterEach + public void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + private User loginUser() { + User user = new User(); + user.setUserId(LOGIN_USER_ID); + user.setUsername("wx_TM9ri37n3eMd"); + user.setTenantId(10325); + user.setCanAuth(1); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null, Collections.emptyList())); + return user; + } + + private YxeTask abnormalTask() { + YxeTask task = new YxeTask(); + task.setId(ABNORMAL_TASK_ID); + task.setTitle("测试手动"); + task.setStatus(2); + task.setReward(null); + return task; + } + + private YxeUserTask completedUserTask(Integer taskId) { + YxeUserTask userTask = new YxeUserTask(); + userTask.setId(630); + userTask.setTaskId(taskId); + userTask.setUserId(LOGIN_USER_ID); + userTask.setStatus(2); + return userTask; + } + + @Test + @DisplayName("userRank:reward 为 NULL 时应返回成功,而不是 NPE") + public void userRankToleratesNullReward() { + User user = loginUser(); + user.setUserId(LOGIN_USER_ID); + + when(userService.list()).thenReturn(Collections.singletonList(user)); + when(yxeUserTaskService.listByUserId(LOGIN_USER_ID)) + .thenReturn(Collections.singletonList(completedUserTask(ABNORMAL_TASK_ID))); + when(yxeTaskService.listByIds(anyList())) + .thenReturn(Collections.singletonList(abnormalTask())); + + ApiResult result = yxeUserTaskController.userRank(new YxeUserTaskParam()); + + assertEquals(Constants.RESULT_OK_CODE, result.getCode(), "不应返回 NPE 失败码"); + assertNull(result.getError(), "错误信息应为空"); + @SuppressWarnings("unchecked") + List ranked = (List) result.getData(); + // totalTaskNum>0 才会进入排行榜 + assertEquals(1, ranked.size(), "应保留有任务的用户"); + assertEquals(1, ranked.get(0).getTaskCompleteNum(), "完成任务数应被计入"); + assertEquals(0, BigDecimal.ZERO.compareTo(ranked.get(0).getIncomeAmount()), + "NULL 奖金应兜底为 0,不得为 null"); + } + + @Test + @DisplayName("data:reward 为 NULL 时应返回成功,而不是 NPE") + public void dataToleratesNullReward() { + loginUser(); + + when(yxeTaskService.list(any())).thenReturn(Collections.singletonList(abnormalTask())); + when(yxeUserTaskService.listByTaskIds(anyList(), anyInt())) + .thenReturn(Collections.singletonList(completedUserTask(ABNORMAL_TASK_ID))); + when(yxeUserTaskService.listByTaskIds(anyList())) + .thenReturn(Collections.singletonList(completedUserTask(ABNORMAL_TASK_ID))); + + ApiResult result = yxeTaskController.data(); + + assertEquals(Constants.RESULT_OK_CODE, result.getCode(), "不应返回 NPE 失败码"); + assertNull(result.getError(), "错误信息应为空"); + @SuppressWarnings("unchecked") + java.util.Map data = (java.util.Map) result.getData(); + assertEquals(1, data.get("doneNum"), "完成数应被计入"); + assertEquals(0, BigDecimal.ZERO.compareTo((BigDecimal) data.get("income")), + "NULL 奖金应兜底为 0"); + } + + @Test + @DisplayName("userRank:任务ID为 NULL 的用户任务不应阻断整个排行榜") + public void userRankToleratesNullTaskId() { + User user = loginUser(); + + YxeUserTask broken = new YxeUserTask(); + broken.setId(1); + broken.setTaskId(null); + broken.setUserId(LOGIN_USER_ID); + broken.setStatus(2); + + List tasks = new ArrayList<>(); + tasks.add(broken); + + when(userService.list()).thenReturn(Collections.singletonList(user)); + when(yxeUserTaskService.listByUserId(LOGIN_USER_ID)).thenReturn(tasks); + when(yxeTaskService.listByIds(anyList())).thenReturn(Collections.emptyList()); + + ApiResult result = yxeUserTaskController.userRank(new YxeUserTaskParam()); + + assertEquals(Constants.RESULT_OK_CODE, result.getCode()); + assertNull(result.getError()); + } + + // ==================== 写入侧:默认值只在新增路径生效 ==================== + + @Test + @DisplayName("save:未填奖励金额时落库为 0,而不是 NULL") + public void saveDefaultsNullRewardToZero() { + when(yxeTaskService.save(any(YxeTask.class))).thenReturn(true); + when(yxeTaskService.updateById(any(YxeTask.class))).thenReturn(true); + + YxeTask task = new YxeTask(); + task.setTitle("导入任务"); + task.setCateId(1); + task.setReward(null); + + yxeTaskController.save(task); + + assertNotNull(task.getReward(), "新增任务的 reward 不应为 null"); + assertEquals(0, BigDecimal.ZERO.compareTo(task.getReward())); + } + + @Test + @DisplayName("save:已填奖励金额时不得被默认值覆盖") + public void saveKeepsExplicitReward() { + when(yxeTaskService.save(any(YxeTask.class))).thenReturn(true); + when(yxeTaskService.updateById(any(YxeTask.class))).thenReturn(true); + + YxeTask task = new YxeTask(); + task.setTitle("正常任务"); + task.setCateId(1); + task.setReward(new BigDecimal("200.00")); + + yxeTaskController.save(task); + + assertEquals(0, new BigDecimal("200.00").compareTo(task.getReward()), + "显式填写的奖励金额必须原样保留"); + } + + @Test + @DisplayName("saveBatch:批量新增时逐条兜底为 0") + public void saveBatchDefaultsNullRewardToZero() { + when(yxeTaskService.saveBatch(anyList())).thenReturn(true); + + YxeTask nullReward = new YxeTask(); + nullReward.setTitle("批量-未填"); + nullReward.setReward(null); + + YxeTask withReward = new YxeTask(); + withReward.setTitle("批量-已填"); + withReward.setReward(new BigDecimal("50.00")); + + List list = new ArrayList<>(); + list.add(nullReward); + list.add(withReward); + + yxeTaskController.saveBatch(list); + + assertEquals(0, BigDecimal.ZERO.compareTo(nullReward.getReward())); + assertEquals(0, new BigDecimal("50.00").compareTo(withReward.getReward()), + "已填金额不应被覆盖"); + } + + /** + * 这是本次改动最关键的护栏:update 走部分更新语义,字段为 null 表示“不修改”。 + * 若在 update 路径上补默认值 0,一次普通的编辑保存就会把任务奖金静默清零。 + */ + @Test + @DisplayName("update:奖励金额缺失时必须保持 null(表示不修改),不得被兜底成 0") + public void updateMustNotDefaultRewardToZero() { + when(yxeTaskService.updateById(any(YxeTask.class))).thenReturn(true); + when(yxeTaskProductService.listByTask(anyInt())).thenReturn(Collections.emptyList()); + + YxeTask task = new YxeTask(); + task.setId(663); + task.setReward(null); + + yxeTaskController.update(task); + + assertNull(task.getReward(), + "update 阶段不得把 null 兜底成 0,否则会静默清零已有奖金"); + } + + @Test + @DisplayName("updateBatch:批量修改不得改写 reward") + public void updateBatchMustNotDefaultRewardToZero() { + when(yxeTaskService.update(any(YxeTask.class), any())).thenReturn(true); + + YxeTask patch = new YxeTask(); + patch.setStatus(2); + patch.setReward(null); + + BatchParam batchParam = new BatchParam<>(); + batchParam.setIds(Collections.singletonList(663)); + batchParam.setData(patch); + + yxeTaskController.updateBatch(batchParam); + + assertNull(patch.getReward(), + "批量修改阶段不得把 null 兜底成 0,否则会清零全部命中任务的奖金"); + } +}