新增Tripo生成API集成主规范与决策记录

- 新增主规范,固定两个端点的请求、异步执行、结果持久化与泥点扣费合同
- 新增 ADR 0002,记录不复用 Hyper3D、at-most-once submit、提交时定价与落库失败退款
This commit is contained in:
2026-09-21 12:29:08 +08:00
parent 49b6451548
commit 699f321651
2 changed files with 190 additions and 0 deletions
@@ -0,0 +1,16 @@
# 【ADR】0002-Tripo 生成 API 集成边界与提交时定价-2026-09-21
状态:已接受
Tripo 3D 生成以**全新 API** 接入 api-server,不复用 `platform-hyper3d` 的路由、契约、错误映射或实现,也不做 provider 静默切换;旧 Hyper3D 能力保持原样。两个首期端点是 text-to-model 与 image-to-modelmultiview 与 Splat 不接入。异步执行复用现有 `external_generation_job` 队列、worker、租约与状态查询,不新建平行队列、状态枚举或查询接口。对外只暴露 Genarrative 自己的 operation 标识,Tripo task 仅以服务端 checkpoint 形式存在,任何 API 都不返回 provider task ID、SDK 类型或带签名的临时 URL。
选择这个边界是因为第三方 SDK 的任务与错误模型会随 provider 变化,一旦穿透到 API 与前端就会把手第三方约束固化成产品契约;同时 Hyper3D 与 Tripo 的参数、状态与计费形态都不同,把它们塞进同一组路径会让“路径名”与“实际 provider”不一致,调用方无法分辨。
**at-most-once submit**:worker 的崩溃恢复模型是“租约过期后重新 claim,handler 从头重跑”,这对可重跑的 provider 安全,但 Tripo 重跑会二次 submit、二次消耗额度。因此 `external_generation_job` 追加 provider checkpoint 字段并新增受租约栅栏保护的写入 procedure:没有 checkpoint 才允许 submitsubmit 成功后必须先落 checkpoint 再轮询,已有 checkpoint 只允许查询、下载与落库。submit 成功但 checkpoint 落库失败的 attempt 只能终态失败,不得退回可重试队列。checkpoint 不写入 `request_payload_json`,因为该字段是请求真相并参与请求指纹与压缩逻辑。
**提交时定价与真实扣费**:Tripo 计费形态是“底价 + 可叠加 add-on”,现有“模型 → 档位 → 单价”查表表达不了,因此新增 `model3d` 定价段,全部以泥点计价,底价按 `endpoint × modelVersion × 是否有贴图` 拆分,add-on 按请求参数判定叠加;配置加载即校验全部底价键存在,运行期缺键直接拒绝提交,不复用现有 `unwrap_or(0)` 兜底。扣费依据固定为提交时定价,而不是 provider 返回的实际消耗:用户在提交前即可得到确定价格,失败退款就是 attempt 级单次冲正,不需要按实际用量退差额。provider 的实际消耗只写日志用于成本对账,不进入契约、API 响应或资源行。
**provider 成功但落库失败按失败处理并退款**:没有正式资源引用就不算交付成功。这会带来“provider 已消耗、用户已退款”的净亏损,是明确接受的成本,换取的是“用户看到成功就一定拿得到资源”的确定性。首期不提供取消,因为 SDK 没有取消能力,提供本地取消会让调用方误以为 provider 任务已停止。
代价与已知取舍:复用队列意味着要在一个被多个 job kind 共用的表上追加 provider 专用列;真实扣费意味着上线前必须先把 20 个底价键与 add-on 价格确认到位,否则接口无法开放;产物下载沿用完整字节缓冲,40MiB 级模型会占用内存,流式上传作为后续 TODO 处理。
@@ -0,0 +1,174 @@
# 【技术方案】Tripo 3D 生成 API 集成-2026-09-21
更新时间:`2026-09-21`
文档状态:`current`
父规范:`docs/technical/【技术方案】Tripo 3D模型Provider集成-2026-09-18.md`(provider 层合同,本文件承接应用层)
## 目标
`platform-tripo` provider 已就绪的前提下,把 Tripo 的 text-to-model 与 image-to-model 接入 api-server 内部 API:鉴权提交、异步执行、产物落 OSS 并登记为正式资源、按显式泥点定价真实扣费。
## 非目标
- 不接 multiview-to-model、Gaussian Splat、rig、animation、texture、convert 等其它 Tripo 能力。
- 不进入 `/api/external/v1`,不改 External OpenAPI,不新增外部 scope 或 API Key 语义。
- 不复用、不修改、不删除 `platform-hyper3d` 及其 `/api/assets/hyper3d/*` 路由与契约;本能力是全新 API,不是 Hyper3D 的 provider 替换。
- 不接前端与 `packages/model3d-viewer`
- 不提供取消能力:客户端停止查询,worker 继续推进到 provider 终态。
- 本期不改 `platform-oss` 为流式或分片上传,沿用现有完整字节写入路径;流式上传作为后续 TODO。
## 参与入口与分层
| 入口 | 方法 | 鉴权 | 职责 |
| --- | --- | --- | --- |
| `/api/assets/tripo/text-to-model` | POST | Bearer | 校验、定价、入队,返回 operation |
| `/api/assets/tripo/image-to-model` | POST | Bearer | 校验、输入解析、定价、入队 |
| `/api/runtime/external-generation/jobs/{jobId}` | GET | Bearer | 复用现有任务查询,返回 operation 与严格结果 |
分层职责固定为:
```text
api-server HTTP、鉴权、参数组合校验、泥点定价、入队、错误映射
platform-tripo SDK 隔离、submit、单次 get_task、下载、provider 错误归一
platform-oss / 资源登记 产物持久化与正式资源引用
```
Tripo task 只以 checkpoint 形式存在于服务端,任何 API 都不返回 provider task ID、SDK 类型或带签名的临时 URL。
## 请求契约
两个端点的请求 = 现有 `shared-contracts::model3d` 请求 + 平台字段,全部经 ts-rs 目录化生成 TypeScript binding。
**图片输入**(仅 image-to-model)使用 tagged enum,服务端按分支做归属校验并解析 OSS 对象:
```text
source = { kind: "resource", resourceId } | { kind: "asset", assetId }
```
不接受裸 `input` 字符串、任意远程 URL、data URL,也不接受同时给两个 ID。
**结果落点**(两个端点都必填)使用 tagged enum
```text
target = { kind: "projectResource", projectId, canvasCompletion? }
| { kind: "assetLibrary", folderId, label }
```
`projectResource` 分支必须给 `projectId`,可选 `canvasCompletion` 表达画布占位框回填;`assetLibrary` 分支的 `folderId``label` 必填。不存在“都不给就默认落素材库”的口径。
**定价相关参数在 API 层必填并做组合校验**`texture``textureQuality``geometryQuality``quad``smartLowPoly``generateParts` 必须显式给出。`texture=false` 时禁止出现 `textureQuality`,且 `pbr` 必须显式 `false``texture=true``textureQuality` 必填。理由是 provider 的隐式默认值会直接改变价格,一旦依赖默认值,报价与扣费会在“调用方少传字段”时分叉。
两个 submit 都必须携带 `Idempotency-Key`,复用现有头部校验;缺失或格式非法直接 400,不静默生成键。
## 异步执行与状态
本能力复用现有 `external_generation_job` 队列、worker、租约与 owner 过滤,不新建平行队列、不新增状态枚举、不新增查询接口。状态对外仍是 `queued` / `running` / `completed` / `failed`,阶段提示沿用现有 `phase``phaseLabel` / `phaseDetail`
**at-most-once submit** 是本能力与其它生成任务的关键差异。现有 worker 的崩溃恢复模型是“租约过期后由别的 worker 重新 claim,handler 从头重跑”,这对可重跑的 provider 安全,但 Tripo 重跑会二次 submit、二次消耗额度。因此:
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`
**provider 成功但落库失败按失败处理**job 失败、该 attempt 退款,客户端看到 failed。没有正式资源引用就不算交付成功;checkpoint 保留,人工对账可证明这次生成确实发生过。由此产生的“provider 已消耗、用户已退款”净亏损是已知并接受的成本。
**完成结果使用按端点的严格 tagged enum**,不使用由多个可选字段拼成的宽松结果:
```text
TextToModelResult = { modelArtifact, renderedPreview }
ImageToModelResult = { modelArtifact, renderedPreview }
```
每个 artifact 只携带正式资源引用与对象元数据,不携带 provider 临时 URL。
## 定价与扣费
定价是**真实扣费**,不是计量占位。Tripo 的计费形态是“底价 + 可叠加 add-on”,现有图片/视频那套“模型 → 档位 → 单价”查表表达不了,因此在同一份定价配置中新增 `model3d` 段,全部以泥点计价:
```json
"model3d": {
"unit": "perGeneration",
"basePrices": {
"text-to-model": { "<modelVersion>": { "noTexture": , "texture": } },
"image-to-model": { "<modelVersion>": { "noTexture": , "texture": } }
},
"addOnPrices": {
"hdTexture": , "ultraTexture": , "hdGeometry": ,
"quadMesh": , "smartLowPoly": , "generateParts":
}
}
```
模型版本键使用 `Model3dModelVersion` 的枚举值,不另造字符串。add-on 由请求参数判定,判定规则固定为:
```text
hdTexture ← texture=true && textureQuality=detailed
ultraTexture ← texture=true && textureQuality=extreme
hdGeometry ← geometryQuality=detailed
quadMesh ← quad=true
smartLowPoly ← smartLowPoly=true
generateParts ← generateParts=true
textureQuality=fast | standard → 不给任何贴图加价
```
最终价格:
```text
price = basePrices[endpoint][modelVersion][texture ? "texture" : "noTexture"]
+ Σ addOnPrices[命中的 add-on]
```
两条硬约束:
1. **加载即校验**:两个 endpoint × 每个受支持模型版本 × 两种贴图态必须全部存在,缺一即配置非法、服务拒绝启动;运行期不存在“查不到价”的分支。
2. **提交即拒绝**:价格计算发生在调用 provider 之前;拿不到价格就不提交、不扣费、不入队。
扣费依据固定为**提交时定价**:用户在提交前就能得到确定价格,失败退款是单次冲正,不需要按实际消耗退差额。provider 返回的实际消耗只写日志用于成本对账,不进入契约、不进 API 响应、不进资源行。
价格随 job 写入 `price_mud_points`,扣费与冲正沿用现有资产操作账务(attempt 级 consume / refund ledger);素材侧继续用 `generation_cost_mud_points` 记录单次成本。同一 `Idempotency-Key` 命中已有 job 时直接返回原 operation,不新建、不重复扣费。
## 数据与契约
**队列表**`external_generation_job` 追加 provider checkpoint 字段(`providerKind``providerTaskId`),字段追加在结构体末尾并给显式默认值;新增一个受租约栅栏保护的写入 procedure,供 worker 在 submit 成功后落 checkpoint。checkpoint 不写入 `request_payload_json`,因为该字段是请求真相,参与请求指纹与压缩逻辑。
**资源表不新增列**`EditorAsset` / `EditorProjectResource` 复用既有字段表达 3D 结果:
```text
assetKind = "model3d"
objectKey = 模型对象
imageSrc = 预览图稳定引用(该列非空,必须填预览)
thumbnailSrc = 预览图
width/height = 预览图像素尺寸
```
模型格式与体积由 `AssetObject` 承担(`content_type``content_length``content_hash`),因此不新增 `model_format``size_bytes``poly_count` 等列。
`external_generation_job.phase` 的取值集合不变,仍只允许现有两种执行阶段;阶段文案由 api-server 映射,不扩展 schema 常量。
## 兼容与迁移
-`/api/assets/hyper3d/*``platform-hyper3d` 保持原样,不因本能力上线而改变行为,也不做 provider 静默切换。
- schema 变更必须同步 `migration.rs`、表目录、生成绑定,并运行 `npm run check:spacetime-schema`
- 新契约继续由 Rust `ts-rs` 目录化生成到 `packages/shared/src/contracts/model3d/`,不手写 TypeScript。
## 验收标准
1. 两个 submit 路由要求 Bearer 与 `Idempotency-Key`;缺头或非法头返回 400,且不产生 job、不扣费。
2. 同一 `Idempotency-Key` 重复提交返回同一 operation,钱包只有一条扣费流水。
3. 定价配置缺任一底价键时服务启动失败;请求命中 add-on 后扣费等于底价加全部命中 add-on;`fast``standard` 不加价。
4. 组合校验前置于 provider 副作用:`texture=false` + `textureQuality``generateParts=true``texture=true` 的同现请求被拒绝且不扣费。
5. worker 崩溃后重新 claim 不会产生第二次 submit:已有 checkpoint 的 job 只轮询、下载与落库。
6. submit 成功但 checkpoint 写入失败的 attempt 终态失败且不自动重提。
7. provider 成功但落库失败时 job 失败、该 attempt 退款,checkpoint 保留供对账。
8. 完成结果按端点严格类型返回,只含模型与预览的正式资源引用,不含 provider task ID 与带签名的临时 URL。
9. `assetLibrary` 分支产出 `assetId``projectResource` 分支产出 `resourceId`,且 `assetKind=model3d``AssetObject.content_type` 为模型 mime、`content_length` 与实际对象一致。
10. 真实 Provider smoke 覆盖 text-to-model 与 image-to-model 各一次,下载字节数与 `Content-Length` 一致。
11. 定向测试、`cargo check``npm run check:spacetime-schema``npm run check:encoding``node scripts/check-doc-index.mjs``git diff --check` 全部通过。
## 未决问题
- add-on 价格目前是跨模型统一一份;若 Tripo 后续对某个模型版本差异化加价,再补“按模型版本覆盖”一层,现在不预留。
- 前端与 `packages/model3d-viewer` 的接入另行安排,不在本规范范围。