个人月卡取消接口缺失:从 Bug 定位到 TDD 修复全记录
在南沙物联网项目的月卡系统中,用户取消个人月卡申请后,列表里竟然找不到”已取消”记录,车位和 VIP 资源也一直被占用。排查后发现,个人月卡模块压根没有 cancel 接口——而企业月卡模块是有的。本文记录了从问题定位、根因分析到 TDD 修复的完整过程。
前言
这是一个典型的”功能遗漏”类 Bug:两个相似模块(个人月卡 vs 企业月卡)中,一个有取消功能,另一个却没有。用户在前端点击取消时,请求发不到后端,导致数据处于不一致状态。这类 Bug 的排查思路和修复策略值得记录,也希望能帮到遇到类似问题的开发者。
问题背景
业务场景
在月卡申请流程中,用户需要经过两个页面:
- 添加月卡页面:填写表单,点击”立即申请”
- 用户协议页面:查看协议,选择”同意”或”取消”
问题出在第 2 步——用户点击”取消”后,返回月卡列表,却看不到”已取消”状态的记录。
用户操作流程

图 1:用户操作流程与问题发生点示意
具体步骤:
- 用户进入”添加月卡”页面,填写表单信息
- 点击”立即申请”,前端调用
POST /v1/monthpersonal/apply - 后端成功创建月卡记录(state=”1” 申请中),同时创建订单、关联车辆、更新车位状态为占用、消耗 VIP
- 前端跳转到”用户协议”页面
- 用户点击”取消”并确认——没有任何后端接口被调用
- 返回列表页——数据库中 state=”1” 的记录未被更新为 state=”10”(已取消)
- 列表中看不到”已取消”记录,且车位、VIP 资源未被释放
排查过程
第一步:搜索 cancel 相关端点
通过搜索 Controller 层的 cancel 关键词,发现了关键线索:
| 文件 | 行号 | 关键信息 | 场景 |
|---|---|---|---|
UserMonthCompanyController.java |
255-259 | POST /v1/user-monthcompany/cancel |
企业月卡有取消 |
MonthPersonalController.java |
全文 | 无 cancel 端点 | 个人月卡缺失 |
第二步:对比 Biz 层方法
企业月卡 MouthCompanyBiz 有 cancel() 方法(第 686-704 行),会校验状态后更新 state 为 CANCEL(“10”)。个人月卡 MouthPersonalBiz 完全没有这个方法。
第三步:确认定时任务补偿机制
项目中存在 MonthlyCardOperationTimeoutTask 定时任务,每半小时检测申请中超 30 分钟的记录并自动取消。但这个任务只更新状态,不回滚车位/VIP 等副作用。
根因分析

图 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 | 申请(1) ──→ 待支付(4) ──→ 已生效(2) ──→ 临期(3) ──→ 已过期(13) |
cancel() 方法允许从”申请中(1)”和”待支付(4)”两个状态取消,目标状态为”已取消(10)”。
修复方案
个人月卡 vs 企业月卡 cancel() 对比

图 3:个人月卡与企业月卡 cancel 实现对比
| 维度 | 个人月卡 cancel() | 企业月卡 cancel() |
|---|---|---|
| 状态校验 | “1” 申请中 + “4” 待支付 | 仅 “1” 申请中 |
| 车位释放 | 有(恢复 usingState、清空 monthCar/parkingCar) | 无(有 TODO 注释) |
| VIP 回滚 | 有(恢复 state=”1”) | 无 |
| 订单取消 | 有(orderStatus=”5”) | 无 |
| 事务保护 | @Transactional | 无 |
| 日志写入 | 有 | 有 |
Step 1:MouthPersonalBiz 新增 cancel() 方法
1 | /** |
关键设计决策:cancel() 方法中的多个 DB 操作需要原子性保护。如果先更新了月卡状态,但释放车位时失败,就会出现脏数据。因此添加了 @Transactional(rollbackFor = Exception.class),确保任一步骤失败时全部回滚。项目中已有此模式(ZombieCarDetectionServiceImpl、SecurityRulesConfigureController 等)。
Step 2:MonthPersonalController 新增 cancel 端点
1 | /** |
端点路径: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 | MouthPersonalBiz.java +50 行 (新增 cancel() 方法,含 @Transactional) |
经验总结
- 相似模块要对齐功能:新增模块时,务必对照已有模块检查功能完整性。企业月卡有 cancel,个人月卡也应该有
- 副作用必须可逆:
apply()方法在申请阶段就执行了车位占用、VIP 消耗等操作,这些副作用必须有对应的回滚路径 - 事务保护不可少:涉及多个 DB 写入的操作必须加
@Transactional,防止部分成功导致数据不一致 - 定时任务应复用业务逻辑:
MonthlyCardOperationTimeoutTask只更新状态不释放资源,后续应重构为复用cancel()方法 - TDD 修复更可靠:先写测试用例复现问题,再编写修复代码,确保新旧测试全部通过
结语
这个 Bug 的本质是一个功能遗漏——个人月卡模块在开发时遗漏了取消接口。修复过程遵循了 TDD 流程,从测试用例到业务代码再到事务保护,每一步都有对应的验证。后续还计划将定时任务的补偿逻辑统一复用 cancel() 方法,彻底解决资源泄漏问题。