这篇整理几个”生成任务状态”问题。它们单独看都是小 bug,放在一起看,其实是同一个问题:在网络不可靠的情况下,客户端怎么判断一个付费的异步任务到底处于什么状态。
判断错了只有两种后果:要么用户已经扣了费却看不到结果,要么同一个任务被提交两次、扣两次费。
开发是我在 Claude Code 里协作完成的,文中的”我们”指我和 Claude Code。
一、任务模型#
视频生成大概要几十秒到几分钟,流程是:
浏览器 后端 上游供应商
│ 生成客户端任务 ID │ │
│── POST /videos {id, body} ──▶│ 先占位(submitting),再扣费 │
│ │── 提交 ───────────────────▶│
│ │◀─ 上游任务号 ───────────────│
│ │ 标记 accepted │
│◀─────── 200 ─────────────────│ │
│ │ │
│── GET /videos/{id}(轮询)──▶│── 查上游 ─────────────────▶│
│◀─ processing / completed ────│ │plaintext两个关键设计:
- 客户端任务 ID:ID 由浏览器生成,POST 时带上。同一个 ID 重复提交,后端返回原任务而不是新建,这是幂等的基础。
- 后端先占位再调上游:收到 POST 先写一行”提交中”,再扣费、调上游。所以如果 GET 某个 ID 返回 404,说明这次 POST 根本没到达后端,也就没有扣费。
这两条规则后面会反复用到。
二、问题一:提交结果丢失,任务永远”待确认”#
现象#
视频页(不是画布页)上有任务一直显示”提交结果待确认”。更糟的情况是:后端其实已经完成了任务,但视频页列表只拉取进行中的任务,刷新之后也找不回来——用户已扣费,却看不到视频。
根因#
POST 请求在网络抖动中丢失了响应(请求可能到了,也可能没到),前端把任务记为 submissionState: unknown。轮询函数遇到这种任务直接原样返回,而视频页没有任何后续处理逻辑。
修法#
利用”先占位”这条规则:对状态未知的任务,按客户端任务 ID 发一次 GET 去确认。
// 后端先占位再调上游,所以 404 = 这次 POST 从未到达,没有扣费
export async function confirmSubmission(id: string): Promise<VideoTask | null> {
try {
return (await api.get(`/videos/${id}`)).data
} catch (e) {
if (isAxiosError(e) && e.response?.status === 404) return null
throw e
}
}ts- 查到了:按后端返回的任务继续正常轮询。
- 查不到:请求可能还在路上,从创建时间算起最多等 5 分钟;超时后标记失败,明确提示”视频提交未被服务器接收(未扣费),请重试”。
“未扣费”这几个字很重要:用户看到失败时第一个担心就是钱,能确定的事情就明确告诉他。
三、问题二:确认慢或读状态临时失败,被本地判为失败#
现象#
画布上的视频节点显示”已停止自动恢复”或”长时间没有结果”,但后端的任务其实一直在正常生成。
根因#
画布页原来的逻辑比较”心急”:
- 状态未知的任务,GET 几次之后如果还不是”已接受”,就本地判定为失败、停止轮询。
- 后端返回
submitting(提交中,上游还没确认)也不算确认,3 次恢复尝试之后同样判失败。 - 读状态时偶尔遇到 502、429、网络错误,部分路径也会被当成任务失败。
问题在于:“我暂时没确认到”不等于”它失败了”。上游确认慢、后端繁忙、网络抖动,都会让确认变慢,但任务本身是好的。
修法#
把规则改成:
// 后端里已经存在的 submitting/unknown 任务不是失败任务:
// 继续按原 ID 轮询;绝不重放已知任务,也不因为确认慢就让用户重新付费生成
if ((task.submissionState === 'unknown' || task.submissionState === 'submitting') && !completed(task)) {
keepPolling(node, task)
return
}ts- 状态未知的任务也走 GET 去查后端,不再直接跳过。
- 后端返回 submitting/unknown 时,节点保持加载状态,继续轮询原 ID。
- 408/429/5xx 和网络错误一律视为临时读取失败,下次继续查。
- 旧版客户端留下的误报节点(“已停止自动恢复”等)会被重新核查;用户点”重试”只查询原任务,不创建新的付费任务。
只有一种 404 是有次数限制的:后端从来没有接受过的客户端 ID。对已经存在的任务,成功读到 submitting/unknown 不消耗任何”失败预算”。
四、问题三:轮询到 404,该不该重新提交?#
前面说过,404 意味着 POST 没到达后端,所以画布页有一条恢复逻辑:轮询到 404 时,用同一个客户端任务 ID 和保存下来的请求体重新提交(最多 3 次)。因为 ID 相同,即使之前的 POST 其实到达了,后端也只会返回原任务。
这条规则在一种情况下是错的:任务曾经被后端接受过,后来任务记录被删除了。
这时候 GET 也是 404,但它的含义完全不同:不是”还没到”,而是”曾经有、现在没了”。如果此时重新提交,后端找不到这个 ID 的记录,就会当成新任务:重新生成、重新扣费。
修法是把 404 的处理变成一个显式的判断:
type NotFoundAction = 'deleted' | 'resubmit' | 'wait'
// 404 只有在任务从未被接受时才表示"提交还没到达";
// 一旦被接受过,404 说明记录被删除了,用同一 ID 重提会再生成、再扣费
export function onTaskNotFound(meta: NodeMeta): NotFoundAction {
if (meta.submissionState === 'accepted') return 'deleted'
if (meta.requestBody && (meta.recoveryAttempts ?? 0) < 3) return 'resubmit'
return 'wait'
}ts- 曾经被接受过:标记失败,提示”视频任务记录已删除,请重新生成”,由用户自己决定要不要再花钱。
- 没被接受过、并且有请求体:在次数限制内用同一 ID 重提。
- 其他情况:等待。
五、问题四:删文件后,同一个任务可能被重复扣费#
上面的前端修复能挡住大部分情况,但真正的问题出在后端:为什么任务记录会被删掉?
发现#
排查删除流程时,我们发现了一条路径:在特定的操作顺序下,同一个任务会被生成两遍、扣两次费。
根因#
用户在”云空间”里删除了一个生成结果文件。删除流程会顺带删掉引用了这个文件的生成任务记录(结果都没了,任务记录也就没用了)——直接 DELETE 掉那一行。
而用户的画布还开着,那个视频节点还在轮询:
- GET 任务 → 404”视频任务尚未创建”。
- 画布按”404 = 还没到达”的规则,用同一个客户端 ID 重新提交。
- 后端查不到这个 ID 的任何记录,当成新任务:扣费,重新生成。
幂等性依赖”后端记得这个 ID”,而删除流程把这份记忆删掉了。
修法:删除时留下墓碑#
删除任务记录之前,先写一条删除标记(墓碑)。下面是示意代码:
// 删除生成任务时先写删除标记:仍在轮询的浏览器拿到"已删除"而不是 404,
// 同一个客户端任务 ID 也永远不能再被提交(和扣费);
// 未完成、状态未知、失败未退款的任务保留,作为对账依据
func deleteTaskTx(tx *gorm.DB, userID, taskID string, model any) error {
if err := tx.Create(&ResourceDeletion{UserID: userID, ResourceID: taskID}).Error; err != nil {
return err
}
return tx.Where("id = ?", taskID).Delete(model).Error
}go效果:
- 轮询拿到的是 410”任务或所属画布已删除”,而不是 404。前端不会把它误解为”还没到达”。
- 用同一个 ID 再提交,后端查到墓碑,直接拒绝,不扣费。
- 提交中、状态未知、失败但还没退款的任务不删,它们是对账的依据。
前后端两道防线:前端知道”被接受过的任务 404 不能重提”,后端保证”删除过的 ID 永远不能再提交”。任何一边单独出错,都不会导致重复扣费。
六、背后的规则#
把这几次修复整理一下,其实就是一张状态判定表:
| 客户端看到的 | 含义 | 正确的处理 |
|---|---|---|
| POST 无响应 | 不确定是否到达 | 按客户端 ID GET 确认,不要直接重提 |
| GET 404,且从未被接受 | POST 没到达,没扣费 | 限次用同一 ID 重提,或超时后提示”未扣费” |
| GET 404,但曾被接受 | 记录被删除 | 标记失败,让用户决定,不能自动重提 |
| GET 410 | 明确已删除(墓碑) | 标记失败 |
| GET 返回 submitting/unknown | 上游确认慢 | 继续轮询原 ID |
| GET 408/429/5xx/网络错误 | 读状态临时失败 | 下次继续查,不是任务失败 |
| 上游返回 failed | 任务真的失败 | 标记失败,退款 |
七、收获#
- 幂等不只是”带个 ID”,还要保证服务端一直记得这个 ID。任何删除记录的地方,都要考虑会不会破坏幂等,必要时留墓碑。
- 区分”没确认到”和”失败了”。对付费任务,宁可多轮询一会儿,也不要本地判失败,再诱导用户重新付费。
- 同一个状态码在不同上下文里含义不同。404 在”从未被接受”和”曾被接受”时意义完全相反,要把上下文纳入判断。
- 用户看到失败时,明确告诉他钱的状态:“未扣费""已退款”,比”请重试”更让人安心。
- 前后端各守一道防线。前端的规则可能因为旧版本客户端而失效,后端的墓碑才是最终保证。