1. 背景介绍

这轮开发里,我在完善一个基于 Spring Boot 3 + MyBatis-Plus + MySQL + Redis + 原生 HTML/jQuery 的抽奖系统,核心功能包括:

  • 奖品创建与分页查询
  • 活动创建
  • 活动关联奖品与参与人员
  • 前端页面圈选奖品 / 圈选参与人员
  • 活动详情缓存到 Redis

问题最早出现在我调试 /prize/create/activity/create 两个接口时,随后逐步扩散到 参数校验、Mapper 绑定、静态资源拦截、前端字段名不一致 等一串典型的全栈联动问题。


2. 遭遇的困境(Bug/报错)

最开始,创建奖品时接口返回:

{
  "code": 999,
  "data": null,
  "errorMsg": "奖品金额不能为空"
}

但我明明传了金额,实际请求却是:

{
  "prizeName": "牛奶",
  "description": "伊利纯牛奶",
  "prize": "50"
}

接着又遇到了分页查询奖品时报错:

org.apache.ibatis.binding.BindingException: Invalid bound statement (not found): com.amadeus.lotterysystem.dao.mapper.PrizeMapper.findPrizeList

前端进入创建奖品页面时,还出现了拦截器误报:

获取路径: /pic/bg.png
获取token: null
token解析失败

说明不是业务接口 token 失效,而是静态资源被拦截了

在活动创建阶段,/activity/create 的异常也不少。

当我用无效奖品 ID 测试时,接口返回:

{
  "code": 302,
  "data": null,
  "errorMsg": "活动关联的奖品异常"
}

当我传非法奖品等级时,一开始竟然直接变成了系统异常:

{
  "code": 500,
  "data": null,
  "errorMsg": "系统异常!"
}

前端“圈选参与人员”按钮点击后没有任何人员列表展示,根因后来查到是请求地址写错了。

另外,我还发现一个更隐蔽的 bug:创建活动时,即使前端传入的参与人员信息和 user 表里的真实用户不一致,也可能被成功写入 activity_user 表。


3. 排查与分析

这次排障里,我把问题拆成了几个层次来看。

1. 奖品创建接口为什么提示“金额不能为空”

根因不是后端没收到请求,而是前端字段名写错了

后端 CreatePrizeParam 定义的是:

private BigDecimal price;

但前端传的是:

"prize": "50"

也就是说,Jackson 根本没把金额绑定到 price 字段上,于是 @NotNull 校验触发,报出“奖品金额不能为空”。

更进一步,我发现校验异常统一被 GlobalExceptionHandler 处理成了 全局错误码 999,没有映射到奖品模块自己的错误码,这也是设计上的问题。


2. PrizeMapper.findPrizeList 为什么报 Invalid bound statement

这个异常本质上是在说:

Java Mapper 方法存在,但 MyBatis 没找到对应 SQL。

我检查后发现:

  • PrizeMapper 接口里声明了 findPrizeList
  • 但没有 @Select
  • 对应 XML 的 namespace 却是旧的 generator.mapper.PrizeMapper
  • 当前实际扫描的是 com.amadeus.lotterysystem.dao.mapper.PrizeMapper

也就是说,接口和 XML 根本没绑上。


3. 为什么 map(prizeDTO -> ...) 里的 getPrizeId() 识别不到

这里是一个很典型的 Java 泛型丢失 问题。

方法签名原本写成了:

private FindPrizeListResult converToFindPrizeListResult(PrizeListDTO prizeListDTO)

由于用了原始类型 PrizeListDTO,导致流里的 prizeDTO 被推断成 Object,自然就没有 getPrizeId()getName() 这些方法。

同时,这段代码还有两个伴生问题:

  • setRecords(...) 传的是 Stream,却没 .toList()
  • FindPrizeListResult.records 定义成了 List<PrizeDTO>,但实际 map 出来的是 PrizeInfo

4. 活动创建为什么存在“假人员也能写入”的风险

活动创建时,后端原本只做了这类校验:

List<Long> activityUserIds = param.getActivityUserList()
    .stream()
    .map(CreateUserByActivityParam::getUsreId)
    .distinct()
    .collect(Collectors.toList());

然后只判断这些 usreId 是否存在于 user 表。

但真正落库时却直接用了前端传来的名字:

activityUserDO.setUserName(user.getUserName());

这意味着:

  • 只要 usreId 是真的
  • 前端就可以随便伪造 userName
  • 最终 activity_user 表里会写入一个和 user 表不一致的用户名

这不是“用户不存在也能创建”,而是更糟糕的情况:

用户 ID 存在,但用户名是伪造的,数据不一致却照样成功。


5. 前端“圈选参与人员”为什么没有列表展示

这个问题最后定位得很明确:前端接口路径写错了

页面里写的是:

url: '/base-user/find-list'

但后端真正暴露的是:

@RequestMapping("/user/base-user/find-list")

也就是:

url: '/user/base-user/find-list'

因为页面只对 401 做了处理,没处理 404 或业务失败,所以表现出来就是:

点击按钮后什么都没显示,看起来像“没反应”。


4. 终极解决方案

这轮我最终做的修改,核心分成几组。

1. 奖品参数校验:统一接入模块错误码

修改前:

@ExceptionHandler(MethodArgumentNotValidException.class)
public CommonResult<?> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
    FieldError fieldError = ex.getBindingResult().getFieldError();
    String errorMsg = fieldError == null ? GlobalErrorCodeConstants.UNKNOWN.getMsg() : fieldError.getDefaultMessage();
    return CommonResult.error(GlobalErrorCodeConstants.UNKNOWN.getCode(), errorMsg);
}

修改后:

@ExceptionHandler(MethodArgumentNotValidException.class)
public CommonResult<?> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
    FieldError fieldError = ex.getBindingResult().getFieldError();
    String errorMsg = fieldError == null ? GlobalErrorCodeConstants.UNKNOWN.getMsg() : fieldError.getDefaultMessage();
    ErrorCode serviceErrorCode = getServiceValidationErrorCode(ex.getParameter().getParameterType(), fieldError);
    if (serviceErrorCode != null) {
        return CommonResult.error(serviceErrorCode.getCode(), errorMsg);
    }
    return CommonResult.error(GlobalErrorCodeConstants.UNKNOWN.getCode(), errorMsg);
}

同时我补充了奖品模块错误码:

ErrorCode CREATE_PRIZE_NAME_IS_EMPTY = new ErrorCode(201, "奖品名称不能为空");
ErrorCode CREATE_PRIZE_DESCRIPTION_IS_EMPTY = new ErrorCode(202, "奖品描述不能为空");
ErrorCode CREATE_PRIZE_PRICE_IS_EMPTY = new ErrorCode(203, "奖品金额不能为空");
ErrorCode CREATE_PRIZE_PARAM_ERROR = new ErrorCode(204, "创建奖品参数错误");

2. PrizeMapper.findPrizeList 直接绑定 SQL

修改前:

List<PrizeDO> findPrizeList(Integer currentPage, Integer pageSize);

修改后:

@Select("select id, gmt_create, gmt_modified, name, description, price, image_url from prize limit #{offset}, #{pageSize}")
List<PrizeDO> findPrizeList(@Param("offset") Integer offset, @Param("pageSize") Integer pageSize);

同时,分页调用也从:

prizeMapper.findPrizeList(pageParam.getCurrentPage(), pageParam.getPageSize());

修正为:

prizeMapper.findPrizeList(pageParam.offest(), pageParam.getPageSize());

3. 泛型与 DTO 类型统一

修改前:

private FindPrizeListResult converToFindPrizeListResult(PrizeListDTO prizeListDTO)

修改后:

private FindPrizeListResult converToFindPrizeListResult(PageListDTO<PrizeDTO> pageListDTO)

并把:

private List<PrizeDTO> records;

调整为:

private List<FindPrizeListResult.PrizeInfo> records;

同时补上:

).toList());

这样 map(prizeDTO -> ...) 里的 getPrizeId()getName() 就都能正确识别。


4. 活动创建:补齐事务、落库、缓存和用户真实性校验

修改前:

checkActivityParam(param);
// ...
activityUserDO.setUserName(user.getUserName());
return null;

修改后:

@Transactional(rollbackFor = Exception.class)
public CreateActivityDTO createActivity(CreateActivityParam param) {
    Map<Long, UserDO> userDOMap = checkActivityParam(param);

    ActivityDO activityDO = new ActivityDO();
    activityDO.setActivityName(param.getActivityName());
    activityDO.setDescription(param.getDescription());
    activityDO.setStatus(ActivityStatusEnum.RUNNING.name());
    activityMapper.insert(activityDO);

    List<ActivityUserDO> activityUserDOList = param.getActivityUserList()
        .stream()
        .map(user -> {
            UserDO userDO = userDOMap.get(user.getUsreId());
            ActivityUserDO activityUserDO = new ActivityUserDO();
            activityUserDO.setActivityId(activityDO.getId());
            activityUserDO.setUserId(user.getUsreId());
            activityUserDO.setUserName(userDO.getUserName());
            activityUserDO.setStatus(ActivityUserStatusEnum.INIT.name());
            return activityUserDO;
        }).collect(Collectors.toList());

    activityUserDOList.forEach(activityUserMapper::insert);

    CreateActivityDTO createActivityDTO = new CreateActivityDTO();
    createActivityDTO.setActivityId(activityDO.getId());
    return createActivityDTO;
}

这里的关键变化有两个:

  • 不再信任前端传来的 userName
  • 落库时统一使用数据库里的真实用户名

5. 创建活动页面前端联动修复

修改前:

url: '/base-user/find-list'
selectedUsers.push({
    userId: userId,
    userName: userName
});
data.activityPrizeList = selectedPrizes

修改后:

url: '/user/base-user/find-list'
selectedUsers.push({
    usreId: userId,
    userName: userName
});
data.acticityPrizeList = selectedPrizes

同时,我还补了:

var userToken = normalizeToken(localStorage.getItem("user_token") || localStorage.getItem("token"));

以及:

function showUsersModal() {
    $('#usersModal').css('display', 'block');
    fetchUsers();
}

这样可以保证:

  • token 正常传递
  • 每次打开弹窗都重新拉最新人员列表
  • 提交字段名和后端当前参数定义一致

5. 踩坑心得

1. 前后端字段名不一致,是最隐蔽也最常见的 bug 之一

这次我连续踩了几次:

  • prize vs price
  • activityPrizeList vs acticityPrizeList
  • userId vs usreId

接口能通、不代表数据一定绑定成功。以后我会优先做两件事:

  • 对照 DTO 字段名检查请求体
  • 打印 Controller 入参日志,而不是只看前端提交内容

2. 不要相信前端传来的冗余业务字段

activity_useruserName 最终应该来源于 user 表,而不是来源于前端勾选后拼出来的 JSON。

最佳实践是:

  • 前端只传 主键 id
  • 后端查数据库拿 真实业务数据
  • 最终落库只信任后端查询结果

这样才能保证数据一致性,也能防止“伪造展示名”这类脏数据进入系统。


这次排障让我很明显地感受到,一个看起来简单的“创建活动”功能,背后其实牵扯了参数绑定、异常映射、Mapper 绑定、Redis 缓存、JWT、前后端字段契约、数据库一致性 等多个层面的协作。真正把它跑顺,不是修一个点,而是把整条链路都校准。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐