fix: 修复任务奖金为 NULL 导致业绩统计接口空指针

小程序 pages/performance/index 请求的 /yxe-user-task/userRank 与
/yxe-task/data 同时返回 {"code":1,"error":"java.lang.NullPointerException"},
页面完成进度与人员排行榜全部空白。

根因:yxe_task.reward 允许为 NULL,历史数据中存在 NULL 记录(本机库
id=663)。两个接口在累加奖金时写作 BigDecimal.ZERO.add(task.getReward()),
BigDecimal.add(null) 直接抛 NPE。userRank 不传 name 时会遍历全部用户,
任何一人名下有该脏任务,整个排行榜即整体失败。

改动分读、写两侧:

读侧(不再因脏数据报错):
- YxeTaskController#data、#userRank 对 reward 做 null 兜底(nullToZero)
- 顺带把 userTask.getStatus().equals(2) / getLoginUser().getCanAuth().equals(1)
  改为 Integer.valueOf(x).equals(...),避免字段为 NULL 时拆箱再抛 NPE
  (sys_user.can_auth 列本身可空,属同类隐患)
- userRank 过滤 task_id 为 NULL 的用户任务,并去重后批量查任务

写侧(不再产生新的 NULL):
- save / saveBatch 在新增时把缺失的 reward 兜底为 0
- update / updateBatch **刻意不补**,并在代码注释中写明原因:
  二者走 MyBatis-Plus 默认 NOT_NULL 部分更新语义,字段为 null 表示
  “本次不修改该字段”。管理端普通任务的 reward 由产品奖励自动汇总,
  产品行可能为空导致该字段整个缺失;若在此兜底为 0,一次普通编辑
  就会把任务原有奖金静默清零,直接影响用户实得收益。
  该风险已有专门护栏测试 updateMustNotDefaultRewardToZero 覆盖
  (注入错误修法后该测试转红:expected:<null> but was:<0>)。

回归测试 TaskRewardNullSafetyTest 共 8 例,覆盖读侧两接口的 NULL 容忍、
task_id 为 NULL 不阻断排行榜、以及新增侧补默认值、更新侧必须保持 null。
已验证:回退主修后 3 例转红(save/saveBatch/data),update 护栏保持绿色。

业务影响评估(补 0 安全):
- 库中已有 67 条 reward=0 的任务,其中 6 条已完整走通接单→提交→审核→
  income 落库 0.00,说明“奖金为 0”本就是系统接受的正常语义
- 审核结算处 income = reward == null ? ZERO : reward,服务端本就把 NULL 当 0,
  补 0 不改变任何用户实得金额
- 小程序端 12 个源文件逐一排查,无任何依赖 reward 为 null 的分支判断

SQL: fix_task_reward_null_npe_2026-10-11.sql(幂等,本次未执行)
This commit is contained in:
2026-10-11 20:20:03 +08:00
parent 9ff52cfe02
commit 2ae3710a82
4 changed files with 379 additions and 10 deletions
+49
View File
@@ -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;
@@ -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<YxeUserTask> 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<YxeTask> taskFilter = new ArrayList<>();
if (!taskList.isEmpty()) {
List<Integer> 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<YxeTask> 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<YxeTask> batchParam) {
// 注意:此处刻意不为 reward 补默认值。
// BatchParam.update 使用 UpdateWrapper 做部分更新,null 字段不会进入 SET 子句;
// 批量修改通常只改状态等个别字段,若在此兜底成 0,会把 ids 命中的全部任务奖金清零。
// 兜底只应发生在新增(save / saveBatch)路径。
if (batchParam.update(yxeTaskService, "id")) {
return success("修改成功");
}
@@ -96,14 +96,23 @@ public class YxeUserTaskController extends BaseController {
BigDecimal incomeAmount = BigDecimal.ZERO;
List<YxeUserTask> taskList = yxeUserTaskService.listByUserId(user.getUserId());
if (!taskList.isEmpty()) {
List<YxeTask> taskDataList = yxeTaskService.listByIds(taskList.stream().map(YxeUserTask::getTaskId).collect(Collectors.toList()));
List<Integer> taskIds = taskList.stream()
.map(YxeUserTask::getTaskId)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
List<YxeTask> 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<PageResult<YxeUserTask>> page(YxeUserTaskParam param) {
@@ -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。
*
* <p>背景:线上存在 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"},
* 页面进度与排行榜全部空白。
*
* <p>这里用 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<User> ranked = (List<User>) 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<String, Object> data = (java.util.Map<String, Object>) 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<YxeUserTask> 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<YxeTask> 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<YxeTask> batchParam = new BatchParam<>();
batchParam.setIds(Collections.singletonList(663));
batchParam.setData(patch);
yxeTaskController.updateBatch(batchParam);
assertNull(patch.getReward(),
"批量修改阶段不得把 null 兜底成 0,否则会清零全部命中任务的奖金");
}
}