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 之一
这次我连续踩了几次:
prizevspriceactivityPrizeListvsacticityPrizeListuserIdvsusreId
接口能通、不代表数据一定绑定成功。以后我会优先做两件事:
- 对照 DTO 字段名检查请求体
- 打印
Controller入参日志,而不是只看前端提交内容
2. 不要相信前端传来的冗余业务字段
activity_user 的 userName 最终应该来源于 user 表,而不是来源于前端勾选后拼出来的 JSON。
最佳实践是:
- 前端只传 主键 id
- 后端查数据库拿 真实业务数据
- 最终落库只信任后端查询结果
这样才能保证数据一致性,也能防止"伪造展示名"这类脏数据进入系统。
这次排障让我很明显地感受到,一个看起来简单的"创建活动"功能,背后其实牵扯了参数绑定、异常映射、Mapper 绑定、Redis 缓存、JWT、前后端字段契约、数据库一致性 等多个层面的协作。真正把它跑顺,不是修一个点,而是把整条链路都校准。