南沙项目物联网 - 月卡管理模块 Bug 排查记录

南沙项目物联网的月卡管理模块在日常使用中陆续发现了三个 Bug,涵盖前端接口调用错误、后端空指针异常、以及业务逻辑校验缺陷三类典型问题。本文记录这三个 Bug 的完整排查过程、根因分析及修复方案,供后续开发参考。

前言

月卡管理是停车系统的核心功能之一,涉及费用计算、车位绑定、审批流程等多个子模块。本次排查的三个 Bug 分别来自:

序号 问题 类型 影响
1 个人月卡拒绝功能 404 错误 前端接口路径不匹配 拒绝功能完全不可用
2 月卡变更审批拒绝 404 错误 后端空指针异常 拒绝操作触发 404
3 月卡变更车位占用校验失效 业务逻辑错误 空闲车位被误报占用

这三个问题虽然都表现为”404 错误”,但根因完全不同,体现了停车场系统中常见的几类典型缺陷。


问题一:个人月卡拒绝功能 404

问题现象

费用管理模块 → 个人月卡页面,点击”拒绝退款”或”拒绝变更”按钮时,系统报错 Request failed with status code 404

排查过程

Step 1:定位后端接口

通过代码搜索找到后端 Controller:

1
文件: src/main/java/cn/flyrise/pai/nsrx/iva/controller/MonthPersonalController.java

后端实际提供的接口是:

  • POST /v1/monthpersonal/changeRefuse — 拒绝变更(第 296-301 行)
  • POST /v1/monthpersonal/refundRefuse — 拒绝退款(第 307-312 行)

Step 2:查看前端代码

前端 api/iva/monthPersonal.js 中的 API 定义如下:

1
2
3
4
5
6
7
8
// 前端实际调用
export const updateFlow = (row) => {
return request({
url: `${service}/${ver}/${module}/updateFlow`,
method: 'post',
data: row
})
}

Step 3:根因分析

功能 前端调用 后端实际接口 状态
拒绝变更 /updateFlow /changeRefuse ❌ 不匹配
拒绝退款 /updateFlow /refundRefuse ❌ 不匹配

前端调用了 /updateFlow,但后端根本没有这个接口。Spring Boot 在找不到对应路由时返回 404。

前后端接口调用流程
图1:前端调用 /updateFlow 但后端接口不匹配,导致 404 错误

修复方案

Step 1:在 monthPersonal.js 中添加两个新的 API 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 拒绝变更
export const changeRefuse = (row) => {
return request({
url: `${service}/${ver}/${module}/changeRefuse`,
method: 'post',
data: row
})
}

// 拒绝退款
export const refundRefuse = (row) => {
return request({
url: `${service}/${ver}/${module}/refundRefuse`,
method: 'post',
data: row
})
}

Step 2:修改 Vue 页面中的调用方法,将 updateFlow 替换为对应的拒绝接口:

1
2
3
4
5
// 拒绝退款
refundRefuse({ id: row.id, msg: this.msg })

// 拒绝变更
changeRefuse({ id: row.id, msg: this.rejectReason })

经验总结

  • 前后端接口命名需保持一致,建议通过 Swagger 文档同步接口
  • 代码审查时检查接口匹配性
  • 测试覆盖完整的业务流程(通过/拒绝两种路径)

问题二:月卡变更审批拒绝 404

问题现象

费用管理模块 → 月卡变更列表页面 → 点击”审核” → 选择”拒绝”时,系统报错 404。但选择”通过”时正常。

排查过程

Step 1:定位后端接口

Controller 路径为 src/main/java/cn/flyrise/pai/nsrx/iva/controller/MonthPersonListController.java,审核接口为 POST /v1/monthpersonlist/audit

Step 2:分析 audit 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@PostMapping("/audit")
public R audit(@Valid @RequestBody MonthPersonListEntity monthPersonList) {
// 写入日志
if(StringUtil.isNotBlank(monthPersonList.getRefuseReason())){
LogInfoEntity infoEntity = new LogInfoEntity();
infoEntity.setKeyId(monthPersonList.getId()+"");
infoEntity.setRemark(monthPersonList.getRefuseReason());
infoEntity.setOperateAction("拒绝审核");
logInfoService.save(infoEntity);
}
boolean flag = monthPersonListService.updateById(monthPersonList);
if(flag){
if("4".equals(monthPersonList.getState())){ // state=4 表示拒绝
MonthPersonalEntity detail = monthPersonalService.getById(monthPersonList.getMonthlyCardId());
detail.setState("11"); // ← NPE 风险!如果 detail 为 null
monthPersonalService.updateById(detail);
}
}
return R.status(flag);
}

Step 3:根因分析

当审核状态为”拒绝”(state="4")时,代码通过 monthlyCardId 查询 MonthPersonalEntity,但未进行空值检查

  • 如果 monthlyCardId 对应的月卡记录不存在,getById() 返回 null
  • 后续 detail.setState("11") 抛出 NullPointerException
  • BladeX 框架的全局异常处理器将 NPE 映射为 HTTP 404

为什么”通过”正常?

“通过”时 state="3",不进入 if("4".equals(...)) 分支,因此不会触发 NPE。

NPE导致404的调用链
图2:state=4 时 getById 返回 null,detail.setState 触发 NPE,框架转换为 404

流程对比

1
2
3
4
5
6
7
8
9
graph TD
A[前端调用 /audit] --> B{monthPersonList.getState?}
B -->|state = "3" 通过| C[仅更新变更记录]
C --> D[✅ 正常返回]
B -->|state = "4" 拒绝| E[getById monthlyCardId]
E -->|记录存在| F[detail.setState 11]
F --> D
E -->|记录不存在 null| G[detail.setState 11]
G --> H[❌ NPE → 404]

修复方案

getById 之后添加空值检查:

1
2
3
4
5
6
7
8
9
10
if("4".equals(monthPersonList.getState())){
MonthPersonalEntity detail = monthPersonalService.getById(monthPersonList.getMonthlyCardId());
if(detail != null) { // 添加空值检查
detail.setState("11");
monthPersonalService.updateById(detail);
} else {
log.warn("月卡变更审批拒绝:月卡记录不存在, monthlyCardId={}, personListId={}",
monthPersonList.getMonthlyCardId(), monthPersonList.getId());
}
}

经验总结

  • 数据库查询后必须进行空值检查
  • 全局异常处理器会将未捕获的 RuntimeException 映射为特定 HTTP 状态码
  • 同类代码对比:MonthPersonalBiz.changeRefuse() 做了空值检查,而 MonthPersonListController.audit() 遗漏了这个检查

问题三:月卡变更车位占用校验 Bug

问题现象

个人月卡变更固定车位时,从空闲车位列表中选择了一个空闲车位,但提交变更时系统返回”车位已经被占用”,无法提交申请。

排查过程

Step 1:定位报错位置

搜索后端”已占用”相关错误信息,找到 3 处:

文件 行号 错误信息 场景
UserMonthCompanyController.java 185 “车位XX已被占用” 企业租赁修改
MouthPersonalBiz.java 175 “车位已占用” 个人月卡申请
MouthPersonalBiz.java 605 “车位已经被占用” 个人月卡变更 ← 本次问题

Step 2:分析 change() 方法

MouthPersonalBiz.java 第 594-641 行:

1
2
3
4
5
6
7
8
9
10
11
12
public R change(MouthPersonalRefundApplyDTO dto) {
MonthPersonalEntity monthPersonal = monthPersonalService.getById(dto.getId());
// ...
// 检查车位是否被占用
if(monthPersonal.getParkingSpotId()!=null){
ParkSlotEntity spot = parkSlotService.getById(monthPersonal.getParkingSpotId());
if(!spot.getUsingState().equals("1")){
return R.data(500, null, "车位已经被占用"); // ← BUG
}
}
// ...
}

Step 3:追踪车位状态

月卡申请成功后,车位会被设为占用状态(usingState = "4"):

1
2
3
4
5
6
7
if(monthPersonal.getParkingSpotId()!=null){
ParkSlotEntity entity = parkSlotService.getById(monthPersonal.getParkingSpotId());
entity.setUsingState("4"); // "4" = 占用
entity.setMonthCar(monthPersonal.getCarNumber());
entity.setParkingCar(monthPersonal.getCarNumber());
parkSlotService.updateById(entity);
}

根因分析

这个 Bug 包含 3 个子问题

Bug 1:检查的是老车位而非新车位

change() 方法获取的是当前已绑定的老车位 ID。老车位在月卡申请时已被设为占用(usingState = "4"),所以 !spot.getUsingState().equals("1") 永远为 true

Bug 2:DTO 缺少新车位 ID 字段

MouthPersonalRefundApplyDTO 只包含退款相关字段,没有接收新车位 ID 的字段。前端传入的新车位 ID 无法到达后端。

Bug 3:新车位用老 ID 查询

1
2
3
ParkSlotEntity newSpot = parkSlotService.getById(monthPersonal.getParkingSpotId());
ParkSlotEntity oldSpot = parkSlotService.getById(monthPersonal.getParkingSpotId());
// newSpot 和 oldSpot 查询的是同一记录

车位检查逻辑对比
图3:检查老车位(错误 - 已被月卡绑定)vs 检查新车位(正确 - 应该检查目标车位是否空闲)

修复方案

Step 1:DTO 增加 newParkingSpotId 字段

1
2
3
@ApiModelProperty(value = "新车位ID(变更车位时必传)")
@JsonSerialize(using = ToStringSerializer.class)
private Long newParkingSpotId;

Step 2:重写 change() 车位检查逻辑

1
2
3
4
5
6
7
8
9
10
11
12
// 变更车位
Long newParkingSpotId = dto.getNewParkingSpotId();
if(newParkingSpotId == null){
return R.fail("请选择新车位");
}
ParkSlotEntity newSpot = parkSlotService.getById(newParkingSpotId);
if(newSpot == null){
return R.fail("新车位不存在");
}
if(!"1".equals(newSpot.getUsingState())){
return R.fail("新车位" + newSpot.getName() + "已被占用,请重新选择");
}

Step 3MonthPersonListEntity 增加 afterValueId 字段保存新车位 ID

Step 4changePass() 审批通过时切换车位绑定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 释放老车位
if(monthPersonal.getParkingSpotId() != null){
ParkSlotEntity oldSlot = parkSlotService.getById(monthPersonal.getParkingSpotId());
if(oldSlot != null){
oldSlot.setUsingState("1");
oldSlot.setMonthCar(null);
oldSlot.setParkingCar(null);
parkSlotService.updateById(oldSlot);
}
}
// 占用新车位
ParkSlotEntity newSlot = parkSlotService.getById(entity.getAfterValueId());
if(newSlot != null){
newSlot.setUsingState("4");
newSlot.setMonthCar(monthPersonal.getCarNumber());
newSlot.setParkingCar(monthPersonal.getCarNumber());
parkSlotService.updateById(newSlot);
}
// 更新月卡绑定的车位
monthPersonal.setParkingSpotId(entity.getAfterValueId());

经验总结

  • 车位状态检查必须区分”当前占用车位”和”要变更到的新车位”
  • DTO 设计需覆盖业务操作所需的全部参数
  • 变更操作需要考虑完整的状态流转:提交 → 审批通过/拒绝

总结

本次排查的三个 Bug 覆盖了不同的根因类型:

Bug 类型 根因
个人月卡拒绝 404 前端 接口路径不匹配
月卡变更审批拒绝 404 后端 空指针异常(NPE)
车位占用校验失效 业务逻辑 检查对象错误(老车位 vs 新车位)

通用经验

  1. 前后端接口一致性:接口命名、参数结构必须保持一致,建议通过 Swagger 文档同步
  2. 空值检查:数据库查询后必须进行空值检查,避免 NPE
  3. 业务逻辑验证:校验逻辑必须验证正确的对象,不能混淆”当前状态”和”目标状态”
  4. 测试覆盖:必须覆盖完整的业务流程,包括”通过”和”拒绝”两种路径

💡 提示:月卡管理模块的问题往往涉及状态流转,建议在开发变更功能时同步梳理状态机,避免遗漏边界情况。