diff --git a/docs/adr/【ADR】0002-Tripo生成API集成边界与提交时定价-2026-09-21.md b/docs/adr/【ADR】0002-Tripo生成API集成边界与提交时定价-2026-09-21.md index 664341735..55a432ab5 100644 --- a/docs/adr/【ADR】0002-Tripo生成API集成边界与提交时定价-2026-09-21.md +++ b/docs/adr/【ADR】0002-Tripo生成API集成边界与提交时定价-2026-09-21.md @@ -6,7 +6,7 @@ Tripo 3D 生成以**全新 API** 接入 api-server,不复用 `platform-hyper3d 选择这个边界是因为第三方 SDK 的任务与错误模型会随 provider 变化,一旦穿透到 API 与前端就会把手第三方约束固化成产品契约;同时 Hyper3D 与 Tripo 的参数、状态与计费形态都不同,把它们塞进同一组路径会让“路径名”与“实际 provider”不一致,调用方无法分辨。 -**at-most-once submit**:worker 的崩溃恢复模型是“租约过期后重新 claim,handler 从头重跑”,这对可重跑的 provider 安全,但 Tripo 重跑会二次 submit、二次消耗额度。因此 `external_generation_job` 追加 provider checkpoint 字段并新增受租约栅栏保护的写入 procedure:没有 checkpoint 才允许 submit,submit 成功后必须先落 checkpoint 再轮询,已有 checkpoint 只允许查询、下载与落库。submit 成功但 checkpoint 落库失败的 attempt 只能终态失败,不得退回可重试队列。checkpoint 不写入 `request_payload_json`,因为该字段是请求真相并参与请求指纹与压缩逻辑。 +**at-most-once submit**:其它生成任务的崩溃恢复模型是“租约过期后重新 claim,handler 从头重跑”,这对可重跑的 provider 安全,但 Tripo 重跑会二次 submit、二次消耗额度。Tripo job 固定 `max_attempts = 1`,崩溃后租约耗尽直接终态失败、不重新 claim,因此没有续跑路径。因此 `external_generation_job` 追加 provider checkpoint 字段并新增受租约栅栏保护的写入 procedure:没有 checkpoint 才允许 submit,submit 成功后必须先落 checkpoint 再轮询,已有 checkpoint 只允许查询、下载与落库(防御性硬约束)。submit 成功但 checkpoint 落库失败的 attempt 只能终态失败,不得退回可重试队列。checkpoint 不写入 `request_payload_json`,因为该字段是请求真相并参与请求指纹与压缩逻辑。 **提交时定价与真实扣费**:Tripo 计费形态是“底价 + 可叠加 add-on”,现有“模型 → 档位 → 单价”查表表达不了,因此新增 `model3d` 定价段,全部以泥点计价,底价按 `endpoint × modelVersion × 是否有贴图` 拆分,add-on 按请求参数判定叠加;配置加载即校验全部底价键存在,运行期缺键直接拒绝提交,不复用现有 `unwrap_or(0)` 兜底。扣费依据固定为提交时定价,而不是 provider 返回的实际消耗:用户在提交前即可得到确定价格,失败退款就是 attempt 级单次冲正,不需要按实际用量退差额。provider 的实际消耗只写日志用于成本对账,不进入契约、API 响应或资源行。 diff --git a/docs/project-memory/plans/【实施计划】Tripo生成Worker执行链路与API路由-2026-09-21.md b/docs/project-memory/plans/【实施计划】Tripo生成Worker执行链路与API路由-2026-09-21.md index 6ae7b7da3..5005962a3 100644 --- a/docs/project-memory/plans/【实施计划】Tripo生成Worker执行链路与API路由-2026-09-21.md +++ b/docs/project-memory/plans/【实施计划】Tripo生成Worker执行链路与API路由-2026-09-21.md @@ -59,11 +59,11 @@ Milestone Spec: `docs/project-memory/plans/【里程碑】Tripo生成Worker执 - 执行分支:无 checkpoint 才 submit,submit 成功后先落 checkpoint 再轮询;已有 checkpoint 只查询、下载与落库;checkpoint 写入失败按失败收口。 - 轮询在单次 attempt 内进行并按现有心跳续租,超时按现有 worker 预算语义处理。 - provider 成功但落库失败按失败收口并冲正扣费,checkpoint 保留供对账。 - - 验收:崩溃重放不产生第二次 submit;落库失败不退化成“已交付”。 + - 验收:任何带 checkpoint 的执行路径都不产生第二次 submit;`max_attempts = 1` 使崩溃后不重新 claim(不存在续跑);落库失败不退化成“已交付”。 7. **接线与文档** - `app.rs` merge 新路由;`external_generation_worker.rs` 增加两个分支并纳入长任务超时;`tripo3d/mod.rs` 去掉里程碑一的 `dead_code` 豁免。 - - 主规范把 `max_attempts = 1` 与 checkpoint 的实际语义写实,里程碑状态同步。 + - 主规范把 `max_attempts = 1` 与 checkpoint 的实际语义写实(at-most-once 硬约束 + 人工对账,不是续跑能力),里程碑状态同步。 - 验收:`node scripts/check-doc-index.mjs`、`npm run check:encoding`、`git diff --check` 通过。 ## 进展 diff --git a/docs/project-memory/plans/【里程碑】Tripo生成Worker执行链路与API路由-2026-09-21.md b/docs/project-memory/plans/【里程碑】Tripo生成Worker执行链路与API路由-2026-09-21.md index 1cddbd27f..039daea3b 100644 --- a/docs/project-memory/plans/【里程碑】Tripo生成Worker执行链路与API路由-2026-09-21.md +++ b/docs/project-memory/plans/【里程碑】Tripo生成Worker执行链路与API路由-2026-09-21.md @@ -14,7 +14,7 @@ Implementation Plan: `docs/project-memory/plans/【实施计划】Tripo生成Wor - 两个 Bearer 路由:`/api/assets/tripo/text-to-model`、`/api/assets/tripo/image-to-model`,含 `Idempotency-Key` 校验与错误映射。 - 两个 Tripo job kind 的 worker 分支:submit、单次 `get_task` 轮询、下载、OSS 写入、资源登记、严格结果序列化。 -- checkpoint 规则:无 checkpoint 才 submit,submit 成功后先落 checkpoint 再轮询,已有 checkpoint 只续跑;submit 成功但 checkpoint 落库失败的 attempt 终态失败。 +- checkpoint 规则:无 checkpoint 才 submit,submit 成功后先落 checkpoint 再轮询,已有 checkpoint 只查询(防御性硬约束,当前 `max_attempts = 1` 下不存在续跑路径);submit 成功但 checkpoint 落库失败的 attempt 终态失败。 - 图片输入的归属校验与 OSS 对象解析;结果落点的两个分支。 - 提交时定价扣费、attempt 级退款、幂等重放不重复扣费。 - 真实 Provider smoke 与端到端验收证据。 diff --git a/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md b/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md index d62fd65f5..e94ce81fc 100644 --- a/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md +++ b/docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md @@ -75,14 +75,14 @@ canvasCompletion? 画布占位框回填,只在项目资源落点下生效 本能力复用现有 `external_generation_job` 队列、worker、租约与 owner 过滤,不新建平行队列、不新增状态枚举、不新增查询接口。状态对外仍是 `queued` / `running` / `completed` / `failed`,阶段提示沿用现有 `phase` 与 `phaseLabel` / `phaseDetail`。 -**at-most-once submit** 是本能力与其它生成任务的关键差异。现有 worker 的崩溃恢复模型是“租约过期后由别的 worker 重新 claim,handler 从头重跑”,这对可重跑的 provider 安全,但 Tripo 重跑会二次 submit、二次消耗额度。因此: +**at-most-once submit** 是本能力与其它生成任务的关键差异。其它生成任务的崩溃恢复模型是“租约过期后由别的 worker 重新 claim,handler 从头重跑”,这对可重跑的 provider 安全,但 Tripo 重跑会二次 submit、二次消耗额度。Tripo job 固定 `max_attempts = 1`,任何 attempt 失败都是终态失败,崩溃后租约耗尽同样直接终态失败、不会被重新 claim,因此不存在“重新 claim 后继续查询”的续跑路径 —— checkpoint 是 at-most-once 的硬约束与人工对账凭据,不是续跑能力。因此: 1. 没有 `providerTaskId` 的 job 才允许 submit; 2. submit 成功后必须先把 `providerTaskId` 写回 checkpoint,再进入轮询; 3. 已有 `providerTaskId` 的 job 只允许 `get_task`、下载与落库,绝不允许再次 submit; 4. submit 成功但 checkpoint 写入失败的 attempt 只能终态失败,不得退回可重试队列,否则重试会二次 submit;该 job 保留脱敏对账信息供人工处理。 -失败重试沿用现有语义:`fail` 先按 attempt 冲正扣费,`attempt < maxAttempts` 时回到 `pending` 并延迟重试,否则终态 `failed`。 +失败重试沿用现有语义:`fail` 先按 attempt 冲正扣费,`attempt < maxAttempts` 时回到 `pending` 并延迟重试,否则终态 `failed`。Tripo job 固定 `max_attempts = 1`,这条路径退化为“第一次失败即终态”,永不进入延迟重试。 **provider 成功但落库失败按失败处理**:job 失败、该 attempt 退款,客户端看到 failed。没有正式资源引用就不算交付成功;checkpoint 保留,人工对账可证明这次生成确实发生过。由此产生的“provider 已消耗、用户已退款”净亏损是已知并接受的成本。 @@ -196,7 +196,7 @@ width/height = 预览图像素尺寸 3. 定价配置缺任一底价键时服务启动失败;请求命中 add-on 后扣费等于底价加全部命中 add-on;`fast` 与 `standard` 不加价。 4. 组合校验前置于 provider 副作用:`texture=false` + `textureQuality`、`generateParts=true` 与 `texture=true` 的同现请求被拒绝且不扣费。 5. image-to-model 的 `source` 元数据预检前置于扣费与入队:跨 owner、未登记、已删除与 `kind` 不符的引用返回 400,不产生 operation、不扣费,也不把图片正文读进内存或调用 provider。 -6. worker 崩溃后重新 claim 不会产生第二次 submit:已有 checkpoint 的 job 只轮询、下载与落库。 +6. Tripo job 固定 `max_attempts = 1`:崩溃后租约耗尽即终态失败、不重新 claim,不存在续跑路径;同时任何带 checkpoint 的执行路径都只轮询、下载与落库,绝不二次 submit(防御性硬约束,不依赖配置保证)。 7. submit 成功但 checkpoint 写入失败的 attempt 终态失败且不自动重提。 8. provider 成功但落库失败时 job 失败、该 attempt 退款,checkpoint 保留供对账。 9. worker 落库时构造的完成结果按端点严格类型化,只含模型与预览的正式资源引用,不含 provider task ID 与带签名的临时 URL;标准消费者的队列结果不透传它(与其它画布生成任务一致,结果靠画布 / 素材读回)。 diff --git a/server-rs/crates/api-server/src/tripo3d/worker.rs b/server-rs/crates/api-server/src/tripo3d/worker.rs index 1949eba01..7a27d698a 100644 --- a/server-rs/crates/api-server/src/tripo3d/worker.rs +++ b/server-rs/crates/api-server/src/tripo3d/worker.rs @@ -2,8 +2,12 @@ //! //! 与其它生成任务的关键差异是 **at-most-once submit**:provider 不接受幂等重放, //! 所以只有“没有 checkpoint 的 job”才 submit,submit 成功后必须先落 checkpoint 再轮询; -//! 单次尝试(`max_attempts = 1`)失败即终态收口,不存在第二次 submit 的窗口, -//! checkpoint 只用于继续查询与人工对账。 +//! 单次尝试(`max_attempts = 1`)失败即终态收口。 +//! +//! 注意这不是「崩溃后接着查询」的续跑能力:3D job 的 `max_attempts = 1`,租约耗尽的 job 会被 +//! 直接判终态失败、不会被重新 claim,所以不存在「重新 claim 后继续查询」的真实路径。 +//! [`existing_checkpoint`] 的分支是一条**防御性硬约束**(任何带 checkpoint 的执行都绝不会 +//! 再 submit 一次),checkpoint 的日常用途是人工对账。 use std::time::{Duration, Instant}; @@ -131,7 +135,11 @@ async fn run_model3d_job( .await } -/// checkpoint 是 provider 侧任务的唯一凭据;有值就只能续跑查询。 +/// checkpoint 是 provider 侧任务的唯一凭据,也是 at-most-once 的开关:有值就只能查询, +/// 绝不允许再次 submit。 +/// +/// 当前 `max_attempts = 1` 下 job 不会被重新 claim(租约耗尽直接终态失败),所以这里守住的 +/// 是一条防御性路径;它存在的意义是把「不许二次 submit」写成代码里的硬约束,而不是靠配置保证。 fn existing_checkpoint(job: &ExternalGenerationJobRecord) -> Option { job.provider_task_id .as_deref()