一次失败的 AI 编程复盘:代码通过了,业务却错了
这是一篇复盘文章。
案例根据 AI 编程中常见的失败模式重新组合,用于说明问题,不对应某一次真实生产事故,也不包含真实用户或业务数据。
需求看起来很简单:
给订单增加取消功能,取消后恢复库存。AI 很快完成了 Controller、Service 和测试。代码可以编译,新增测试也通过了,接口调用后订单状态确实变成了 CANCELLED。
但进一步验证后,我们发现:
- 已支付订单也能被直接取消;
- 重复请求会多次恢复库存;
- 库存服务失败时,订单已经变成取消状态;
- 用户可以尝试取消不属于自己的订单;
- 新增测试通过 Mock 绕开了这些真实问题。
代码“完成”了需求中的几个动词,却没有完成真实业务。
1. 最初的 Prompt
最初给 AI 的任务是:
帮我实现订单取消接口。取消订单后把库存加回去,并补一下测试。这条 Prompt 缺少几乎所有关键边界:
- 哪些状态允许取消;
- 谁可以取消订单;
- 重复取消如何处理;
- 订单状态和库存恢复的先后顺序;
- 库存失败时是否回滚;
- 是否需要退款;
- 并发取消如何避免重复执行;
- 测试需要证明什么。
AI 只能根据常见写法自行补全。
它生成了这样的核心逻辑:
public void cancel(long orderId) { Order order = orderRepository.findById(orderId) .orElseThrow(() -> new BusinessException("订单不存在"));
order.setStatus(OrderStatus.CANCELLED); orderRepository.save(order);
inventoryClient.restore(order.getItems());}从代码表面看,步骤与需求一致:
找到订单 → 修改状态 → 保存 → 恢复库存但业务系统不是把几个动词按顺序执行就结束了。
2. 第一个问题:没有状态规则
订单状态通常不是任意修改的字符串,而是一个有限状态机。
简化后的规则可能是:
CREATED ──取消──> CANCELLEDUNPAID ──取消──> CANCELLEDPAID ──申请──> REFUNDINGSHIPPED ────────> 不允许直接取消CANCELLED ───────> 保持取消,不重复恢复库存AI 生成的代码没有校验当前状态,因此:
PAID -> CANCELLEDSHIPPED -> CANCELLEDCOMPLETED -> CANCELLED全部可以发生。
正确的规则应该先写进 Spec:
## 状态规则
- 只有 CREATED 和 UNPAID 状态允许直接取消。- PAID 订单必须进入退款流程,本接口返回业务错误。- SHIPPED、COMPLETED 状态不能取消。- CANCELLED 状态重复请求时直接返回成功,不重复恢复库存。如果业务规则没有进入上下文,AI 不可能从方法名中推断出公司真正的订单状态机。
3. 第二个问题:没有权限边界
原实现只根据 orderId 查询:
orderRepository.findById(orderId)如果 Controller 接收当前登录用户,却没有把用户 ID 传到 Service,攻击者可能依次尝试订单 ID,取消他人的订单。
更安全的入口至少应该显式携带操作者:
public CancelOrderResult cancel(long currentUserId, long orderId)查询也需要包含权限:
Order order = orderRepository .findByIdAndUserId(orderId, currentUserId) .orElseThrow(() -> new BusinessException("订单不存在或无权操作"));有些系统会区分普通用户、客服和管理员,规则会更复杂。但无论如何,权限不能依赖 AI “应该知道”。
4. 第三个问题:事务边界是假的
AI 给方法加上了 @Transactional:
@Transactionalpublic void cancel(long orderId) { // 更新本地订单 // 调用远程库存服务}然后在总结里写:
使用事务保证了订单状态和库存的一致性。这句话不成立。
Spring 本地事务可以回滚当前数据库连接中的操作,但无法自动回滚一次已经成功或失败的远程 HTTP 调用。
可能出现:
场景 A:订单保存成功,库存服务超时结果:订单已取消,库存没有恢复
场景 B:库存恢复成功,数据库提交失败结果:库存增加,订单仍然未取消
场景 C:客户端没有收到响应并重试结果:库存被重复恢复“方法上有 @Transactional”不等于跨系统一致性已经解决。
真实方案可能使用:
- 本地消息表和异步事件;
- 可靠消息队列;
- 库存接口幂等键;
- 补偿任务和对账;
- 项目已有的 Saga 或状态推进机制。
应该复用系统已有方案,而不是让 AI 在不知道架构的情况下自行发明。
5. 第四个问题:重复请求会重复恢复库存
用户双击按钮、网关重试、客户端超时重试都可能造成重复请求。
原代码每次都会执行:
inventoryClient.restore(order.getItems());如果同一订单调用两次,库存可能增加两次。
最小的状态保护是:
if (order.getStatus() == OrderStatus.CANCELLED) { return CancelOrderResult.alreadyCancelled(order.getId());}但在并发场景中,两个请求可能同时读到 UNPAID,然后都恢复库存。还需要结合项目现有机制,例如:
- 乐观锁版本号;
- 条件更新
where status = 'UNPAID'; - 分布式锁;
- 使用订单 ID 作为库存恢复幂等键。
一种条件更新方式:
@Modifying@Query(""" update Order o set o.status = :targetStatus, o.version = o.version + 1 where o.id = :orderId and o.user.id = :currentUserId and o.status in :allowedStatuses """)int cancelIfAllowed( long orderId, long currentUserId, Set<OrderStatus> allowedStatuses, OrderStatus targetStatus);返回值为 0 时,需要重新读取状态并判断是无权限、状态不允许,还是已经取消。
6. 为什么新增测试全部通过
AI 生成的测试是:
@Testvoid shouldCancelOrderAndRestoreInventory() { Order order = anOrder(OrderStatus.UNPAID); when(orderRepository.findById(1L)).thenReturn(Optional.of(order));
service.cancel(1L);
assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED); verify(orderRepository).save(order); verify(inventoryClient).restore(order.getItems());}这个测试验证了实现写了什么:
状态被修改save 被调用库存接口被调用但它没有验证需求真正关心的风险:
- 已支付订单能否取消;
- 其他用户能否操作;
- 重复取消是否重复恢复库存;
- 库存失败后系统处于什么状态;
- 两个并发请求是否都成功;
- 远程调用超时后怎样恢复。
Mock 把数据库、事务和远程系统全部变成了理想环境,因此测试通过得非常顺利。
测试通过并不表示测试覆盖了正确的问题。
7. 我们应该先写哪些测试
状态边界
@ParameterizedTest@EnumSource(value = OrderStatus.class, names = {"PAID", "SHIPPED", "COMPLETED"})void shouldRejectStatusesThatCannotBeCancelled(OrderStatus status) { Order order = anOrder(status, CURRENT_USER_ID); when(orderRepository.findByIdAndUserId(ORDER_ID, CURRENT_USER_ID)) .thenReturn(Optional.of(order));
assertThatThrownBy(() -> service.cancel(CURRENT_USER_ID, ORDER_ID)) .isInstanceOf(BusinessException.class);
verifyNoInteractions(inventoryClient);}权限边界
@Testvoid shouldNotCancelAnotherUsersOrder() { when(orderRepository.findByIdAndUserId(ORDER_ID, CURRENT_USER_ID)) .thenReturn(Optional.empty());
assertThatThrownBy(() -> service.cancel(CURRENT_USER_ID, ORDER_ID)) .isInstanceOf(BusinessException.class);
verifyNoInteractions(inventoryClient);}重复请求
@Testvoid shouldNotRestoreInventoryForAlreadyCancelledOrder() { Order order = anOrder(OrderStatus.CANCELLED, CURRENT_USER_ID); when(orderRepository.findByIdAndUserId(ORDER_ID, CURRENT_USER_ID)) .thenReturn(Optional.of(order));
CancelOrderResult result = service.cancel(CURRENT_USER_ID, ORDER_ID);
assertThat(result.alreadyCancelled()).isTrue(); verifyNoInteractions(inventoryClient);}库存接口幂等
集成或契约测试需要确认两次使用同一个业务键调用库存恢复,不会增加两次库存:
inventoryClient.restore( "cancel-order:" + order.getId(), order.getItems());如果库存系统不支持幂等键,这就不是在订单 Service 里加一个 if 能彻底解决的问题,需要跨团队明确协议。
8. 失败是怎样一步步发生的
回头看,问题并不是 AI 某一行代码“突然变坏”,而是一连串流程缺口叠加。
第一步:需求过于模糊
只描述了取消和恢复库存,没有状态、权限、一致性和幂等规则。
第二步:没有要求先读项目
AI 没有发现项目已有的状态机、库存事件和权限查询方式。
第三步:没有先 Review 方案
如果方案阶段写出“本地事务保证远程库存一致性”,问题本可以在写代码前被发现。
第四步:一次生成所有代码
Controller、Service、库存调用和测试同时生成,修改范围太大,人工只检查了能否编译。
第五步:测试跟着实现走
测试验证了 AI 写出的调用顺序,没有从业务风险推导用例。
第六步:把 AI 总结当成验收
看到“测试全部通过”后,没有检查实际执行命令、测试数量和未覆盖场景。
最终失败可以表示成:
模糊需求+ 缺少项目上下文+ 未 Review 方案+ 一次性大修改+ 表面测试+ 没有人工业务验收= 代码通过但业务错误9. 如果重新做一次
先阅读系统
请阅读订单状态变更、用户权限、库存恢复、消息事件、幂等和相关测试。只输出当前系统如何处理这些问题,不修改代码。再写 Spec
至少明确:
## 允许取消- CREATED、UNPAID
## 不允许取消- PAID、REFUNDING、SHIPPED、COMPLETED
## 幂等- CANCELLED 重复请求返回成功,不重复恢复库存- 库存恢复使用 orderId 组成幂等键
## 权限- 普通用户只能取消自己的订单
## 一致性- 复用项目现有订单事件机制- 不在数据库事务中假设远程 HTTP 可以自动回滚
## 验收- 状态、权限、重复请求和库存失败均有测试- 并发取消只有一次状态迁移成功Review 方案
方案必须画清楚数据流:
用户请求 ↓权限与状态校验 ↓条件更新订单状态 ↓写入本地订单取消事件 ↓事务提交 ↓异步恢复库存(使用幂等键) ↓失败重试与告警分阶段实现
- 状态和权限规则;
- 条件更新与并发保护;
- 订单事件;
- 库存幂等和失败重试;
- 集成测试与故障场景。
每一步都可以独立 Review 和验证。
10. 如何处理已经生成的错误代码
发现方向错误时,不要继续在错误方案上叠加补丁。
先做这些事情:
git status --shortgit diff --statgit diff然后区分:
- 哪些测试表达了正确业务规则,可以保留;
- 哪些测试只是绑定错误实现,需要重写;
- 哪些代码可以局部修复;
- 哪些代码基于错误架构,应完整撤掉;
- 工作区中是否还有不属于当前任务的用户修改。
如果错误实现已经提交,优先使用可追踪的反向提交或新的修复提交,而不是破坏历史。生产数据已经受到影响时,还要先停止进一步损害,再设计数据修复和审计方案。
复盘的目标不是证明 AI 不可靠,而是找到流程中哪些保护没有生效。
11. 这次失败真正教会了什么
AI 擅长补全代码,不擅长补全私有业务
它知道常见订单系统怎样写,但不知道你的退款规则、权限模型和库存协议。
注解不能代替架构
@Transactional 解决不了所有跨系统一致性,@Retryable 也不能自动保证幂等。
测试必须从风险出发
测试“方法有没有被调用”很容易,测试“重复调用会不会多恢复库存”才接近真实风险。
小任务也需要边界
订单取消代码可能只有几十行,但它连接订单、支付、库存和权限,风险远大于代码量。
人工 Review 不能只看 Java 写法
代码风格正确、命名清楚、测试绿色,都不能代替业务规则审查。
12. 我的失败预防清单
- Prompt 中包含允许和禁止的业务状态;
- AI 修改前已经阅读现有调用链;
- 权限条件进入真实数据查询;
- 重复请求和并发请求有明确策略;
- 本地事务与远程调用边界已经区分;
- 测试从业务风险推导,而不是从实现反推;
- 至少包含失败、重试和部分成功场景;
- 实现方案在写代码前已经 Review;
- 修改被拆成可以独立验证的小阶段;
- AI 的完成总结有命令、输出和未覆盖项支撑;
- 上线前由熟悉业务的人做最终判断。
总结
这次失败表面上是订单取消代码不完整,根本原因却是我们把太多未定义的业务问题交给 AI 猜测。
AI 没有违反 Spec,因为当时根本没有 Spec;测试没有发现关键问题,因为测试只验证了生成的实现;本地事务没有保证一致性,因为系统跨越了远程服务。
真正可靠的 AI 编程流程应该是:
人定义业务边界→ AI 阅读现有系统→ 双方确认实现方案→ AI 分阶段生成代码→ 测试覆盖业务风险→ 人根据证据决定是否交付AI 可以把一个错误方案实现得非常完整。所以开发者最重要的能力,不只是让 AI 写代码,而是在代码出现之前定义正确的问题,在代码出现之后验证它解决的确实是那个问题。
If this article helped you, please share it with others!
Some information may be outdated






