个人月卡取消接口缺失:从 Bug 定位到 TDD 修复全记录

在南沙物联网项目的月卡系统中,用户取消个人月卡申请后,列表里竟然找不到”已取消”记录,车位和 VIP 资源也一直被占用。排查后发现,个人月卡模块压根没有 cancel 接口——而企业月卡模块是有的。本文记录了从问题定位、根因分析到 TDD 修复的完整过程。

前言

这是一个典型的”功能遗漏”类 Bug:两个相似模块(个人月卡 vs 企业月卡)中,一个有取消功能,另一个却没有。用户在前端点击取消时,请求发不到后端,导致数据处于不一致状态。这类 Bug 的排查思路和修复策略值得记录,也希望能帮到遇到类似问题的开发者。

问题背景

业务场景

在月卡申请流程中,用户需要经过两个页面:

  1. 添加月卡页面:填写表单,点击”立即申请”
  2. 用户协议页面:查看协议,选择”同意”或”取消”

问题出在第 2 步——用户点击”取消”后,返回月卡列表,却看不到”已取消”状态的记录。

用户操作流程

用户操作流程与问题发生点
图 1:用户操作流程与问题发生点示意

具体步骤:

  1. 用户进入”添加月卡”页面,填写表单信息
  2. 点击”立即申请”,前端调用 POST /v1/monthpersonal/apply
  3. 后端成功创建月卡记录(state=”1” 申请中),同时创建订单、关联车辆、更新车位状态为占用、消耗 VIP
  4. 前端跳转到”用户协议”页面
  5. 用户点击”取消”并确认——没有任何后端接口被调用
  6. 返回列表页——数据库中 state=”1” 的记录未被更新为 state=”10”(已取消)
  7. 列表中看不到”已取消”记录,且车位、VIP 资源未被释放

排查过程

第一步:搜索 cancel 相关端点

通过搜索 Controller 层的 cancel 关键词,发现了关键线索:

文件 行号 关键信息 场景
UserMonthCompanyController.java 255-259 POST /v1/user-monthcompany/cancel 企业月卡有取消
MonthPersonalController.java 全文 无 cancel 端点 个人月卡缺失

第二步:对比 Biz 层方法

企业月卡 MouthCompanyBizcancel() 方法(第 686-704 行),会校验状态后更新 state 为 CANCEL(“10”)。个人月卡 MouthPersonalBiz 完全没有这个方法。

第三步:确认定时任务补偿机制

项目中存在 MonthlyCardOperationTimeoutTask 定时任务,每半小时检测申请中超 30 分钟的记录并自动取消。但这个任务只更新状态,不回滚车位/VIP 等副作用。

根因分析

根因分析:三层 Bug 的关系
图 2:三层 Bug 的关系与影响范围

问题可以拆解为三个相互关联的 Bug:

Bug 1:缺少 cancel 端点

MonthPersonalController 没有 cancel 端点,MouthPersonalBiz 也没有 cancel() 方法。对比企业月卡,后者在第 255 行有 POST /cancel,第 686 行有 cancel() 方法。

Bug 2:apply() 方法副作用不可逆

MouthPersonalBiz.apply() 在用户点击”立即申请”时立即执行了以下操作:

  • 创建月卡记录(state=”1”)
  • 创建订单(orderStatus=”1”)
  • 保存车辆关联
  • 更新车位状态为占用(usingState=”4”)
  • 消耗 VIP(state=”5”)

这些操作在用户取消时无法回滚,因为没有对应的清理接口。

Bug 3:定时任务不回滚副作用

MonthlyCardOperationTimeoutTask 只将 state 更新为”10”,但不释放车位、不回滚 VIP、不取消订单。这意味着即使超时自动取消,资源仍然被锁定。

个人月卡状态流转

1
2
3
4
5
6
7
申请(1) ──→ 待支付(4) ──→ 已生效(2) ──→ 临期(3) ──→ 已过期(13)
│ │
├──→ 已取消(10) ├──→ 续期中(5)

├──→ 变更中(6) ──→ 变更批准(15) / 变更拒绝(11)

└──→ 退款申请中(8) ──→ 退款待转账(12) ──→ 已退款(9) / 退款拒绝(16)

cancel() 方法允许从”申请中(1)”和”待支付(4)”两个状态取消,目标状态为”已取消(10)”。

修复方案

个人月卡 vs 企业月卡 cancel() 对比

个人月卡与企业月卡 cancel 实现对比
图 3:个人月卡与企业月卡 cancel 实现对比

维度 个人月卡 cancel() 企业月卡 cancel()
状态校验 “1” 申请中 + “4” 待支付 仅 “1” 申请中
车位释放 有(恢复 usingState、清空 monthCar/parkingCar) 无(有 TODO 注释)
VIP 回滚 有(恢复 state=”1”)
订单取消 有(orderStatus=”5”)
事务保护 @Transactional
日志写入

Step 1:MouthPersonalBiz 新增 cancel() 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/**
* 取消申请
* @param id 月卡ID
* @return 操作结果
*/
@Transactional(rollbackFor = Exception.class)
public R cancel(Long id) {
MonthPersonalEntity monthPersonal = monthPersonalService.getById(id);
if (ObjUtil.isNull(monthPersonal)) {
return R.fail("月卡不存在");
}
// 只有申请中(1)和待支付(4)状态可以取消
if (!MonthPersonalStateEnum.APPLY.getCode().equals(monthPersonal.getState())
&& !MonthPersonalStateEnum.WAITING_PAY.getCode().equals(monthPersonal.getState())) {
return R.fail("非法操作");
}
monthPersonal.setState(MonthPersonalStateEnum.CANCEL.getCode());
boolean flag = monthPersonalService.updateById(monthPersonal);
if (flag) {
// 释放车位
if (monthPersonal.getParkingSpotId() != null) {
ParkSlotEntity slotEntity = parkSlotService.getById(monthPersonal.getParkingSpotId());
if (slotEntity != null) {
slotEntity.setUsingState("1"); // 空闲
slotEntity.setMonthCar(null);
slotEntity.setParkingCar(null);
parkSlotService.updateById(slotEntity);
}
}
// 回滚VIP
if (monthPersonal.getVipId() != null) {
MonthVipEntity monthVip = monthVipService.getById(monthPersonal.getVipId());
if (monthVip != null) {
monthVip.setState("1"); // 恢复有效
monthVipService.updateById(monthVip);
}
}
// 取消订单
if (monthPersonal.getOrderNo() != null) {
OrderInfoEntity orderInfo = orderInfoService.getById(monthPersonal.getOrderNo());
if (orderInfo != null) {
orderInfo.setOrderStatus("5"); // 已取消
orderInfoService.updateById(orderInfo);
}
}
// 写入日志
LogInfoEntity infoEntity = new LogInfoEntity();
infoEntity.setName(LogBusinessType.PERSONMONTHCARD.getCode());
infoEntity.setKeyId(monthPersonal.getId() + "");
infoEntity.setOperateAction("取消");
infoEntity.setRemark("用户取消月卡申请");
logInfoService.save(infoEntity);
}
return R.status(flag);
}

关键设计决策:cancel() 方法中的多个 DB 操作需要原子性保护。如果先更新了月卡状态,但释放车位时失败,就会出现脏数据。因此添加了 @Transactional(rollbackFor = Exception.class),确保任一步骤失败时全部回滚。项目中已有此模式(ZombieCarDetectionServiceImplSecurityRulesConfigureController 等)。

Step 2:MonthPersonalController 新增 cancel 端点

1
2
3
4
5
6
7
8
9
/**
* 取消申请
*/
@PostMapping("/cancel")
@ApiOperationSupport(order = 16)
@ApiOperation(value = "取消申请")
public R cancel(@RequestParam Long id) {
return mouthPersonalBiz.cancel(id);
}

端点路径:POST /v1/monthpersonal/cancel?id={id}

Step 3:TDD 测试验证

新增 11 个测试用例,覆盖以下场景:

测试场景 预期结果
申请中(state=1)取消 state 变为 10,成功
待支付(state=4)取消 state 变为 10,成功
已生效(state=2)取消 返回”非法操作”
取消后车位状态 usingState 恢复为 1
取消后 VIP 状态 state 恢复为 1
取消后订单状态 orderStatus 变为 5
取消后列表展示 列表出现”已取消”记录
不存在的 ID 返回”月卡不存在”
事务回滚测试 中间失败时全部回滚

全部 11 个测试用例通过。

修改汇总

1
2
3
MouthPersonalBiz.java              +50 行  (新增 cancel() 方法,含 @Transactional)
MonthPersonalController.java +8 行 (新增 POST /cancel 端点)
MonthPersonalCancelTest.java NEW (新增 11 个测试用例)

经验总结

  • 相似模块要对齐功能:新增模块时,务必对照已有模块检查功能完整性。企业月卡有 cancel,个人月卡也应该有
  • 副作用必须可逆apply() 方法在申请阶段就执行了车位占用、VIP 消耗等操作,这些副作用必须有对应的回滚路径
  • 事务保护不可少:涉及多个 DB 写入的操作必须加 @Transactional,防止部分成功导致数据不一致
  • 定时任务应复用业务逻辑MonthlyCardOperationTimeoutTask 只更新状态不释放资源,后续应重构为复用 cancel() 方法
  • TDD 修复更可靠:先写测试用例复现问题,再编写修复代码,确保新旧测试全部通过

结语

这个 Bug 的本质是一个功能遗漏——个人月卡模块在开发时遗漏了取消接口。修复过程遵循了 TDD 流程,从测试用例到业务代码再到事务保护,每一步都有对应的验证。后续还计划将定时任务的补偿逻辑统一复用 cancel() 方法,彻底解决资源泄漏问题。