[开发日记]Spring Boot + MyBatis-Plus 抽奖系统开发复盘:从奖品创建、活动校验到前端圈选人员失效的一次完整排障

1. 背景介绍

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

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

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


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

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

json 复制代码
{
  "code": 999,
  "data": null,
  "errorMsg": "奖品金额不能为空"
}

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

json 复制代码
{
  "prizeName": "牛奶",
  "description": "伊利纯牛奶",
  "prize": "50"
}

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

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

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

text 复制代码
获取路径: /pic/bg.png
获取token: null
token解析失败

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

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

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

json 复制代码
{
  "code": 302,
  "data": null,
  "errorMsg": "活动关联的奖品异常"
}

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

json 复制代码
{
  "code": 500,
  "data": null,
  "errorMsg": "系统异常!"
}

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

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


3. 排查与分析

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

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

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

后端 CreatePrizeParam 定义的是:

java 复制代码
private BigDecimal price;

但前端传的是:

json 复制代码
"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 泛型丢失 问题。

方法签名原本写成了:

java 复制代码
private FindPrizeListResult converToFindPrizeListResult(PrizeListDTO prizeListDTO)

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

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

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

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

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

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

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

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

java 复制代码
activityUserDO.setUserName(user.getUserName());

这意味着:

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

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

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


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

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

页面里写的是:

javascript 复制代码
url: '/base-user/find-list'

但后端真正暴露的是:

java 复制代码
@RequestMapping("/user/base-user/find-list")

也就是:

javascript 复制代码
url: '/user/base-user/find-list'

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

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


4. 终极解决方案

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

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

修改前:

java 复制代码
@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);
}

修改后:

java 复制代码
@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);
}

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

java 复制代码
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

修改前:

java 复制代码
List<PrizeDO> findPrizeList(Integer currentPage, Integer pageSize);

修改后:

java 复制代码
@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);

同时,分页调用也从:

java 复制代码
prizeMapper.findPrizeList(pageParam.getCurrentPage(), pageParam.getPageSize());

修正为:

java 复制代码
prizeMapper.findPrizeList(pageParam.offest(), pageParam.getPageSize());

3. 泛型与 DTO 类型统一

修改前:

java 复制代码
private FindPrizeListResult converToFindPrizeListResult(PrizeListDTO prizeListDTO)

修改后:

java 复制代码
private FindPrizeListResult converToFindPrizeListResult(PageListDTO<PrizeDTO> pageListDTO)

并把:

java 复制代码
private List<PrizeDTO> records;

调整为:

java 复制代码
private List<FindPrizeListResult.PrizeInfo> records;

同时补上:

java 复制代码
).toList());

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


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

修改前:

java 复制代码
checkActivityParam(param);
// ...
activityUserDO.setUserName(user.getUserName());
return null;

修改后:

java 复制代码
@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. 创建活动页面前端联动修复

修改前:

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

修改后:

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

同时,我还补了:

javascript 复制代码
var userToken = normalizeToken(localStorage.getItem("user_token") || localStorage.getItem("token"));

以及:

javascript 复制代码
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、前后端字段契约、数据库一致性 等多个层面的协作。真正把它跑顺,不是修一个点,而是把整条链路都校准。

相关推荐
老毛肚1 小时前
jeecgboot vue API 拆分02
前端·javascript·vue.js
赵谨言1 小时前
基于C#的在线编码与自动化测试全栈Web平台的设计与实现
开发语言·前端·c#
砍材农夫1 小时前
物联网实战:Spring Boot MQTT | 客户端框架比对
spring boot·后端·物联网
Raink老师1 小时前
【AI面试临阵磨枪-98】前端如何展示多模态流式输出:文字打字机 + 图片渐进 + 音频播放?
前端·人工智能·面试
AI_零食1 小时前
奶茶大数据运维表 - 鸿蒙PC Electron框架技术实现详解
运维·前端·华为·electron·开源·harmonyos·鸿蒙
小雨下雨的雨1 小时前
鸿蒙PC Electron框架实现流体气泡模拟器
前端·人工智能·算法·华为·electron·鸿蒙
ZC跨境爬虫1 小时前
跟着 MDN 学JavaScript day_4:如何存储你需要的信息——变量
开发语言·前端·javascript·ui·ecmascript
星栈独行1 小时前
10 分钟跑起第一个 Makepad 应用:先把窗口开起来
前端·程序人生·ui·rust·开源·github
独隅1 小时前
Chrome插件开发实战详细指南
前端·chrome