Tripo 配置改为启动期失败关闭,缺网关或密钥的进程拒绝启动

- api-server 新增启动期门禁:会提交或会执行 3D job 的角色(api / external-generation-worker / all)缺 TRIPO_BASE_URL 或 TRIPO_API_KEY、值为空白或网关不是合法 HTTP(S) 地址时直接拒绝启动;bgfilter-worker 与 external-generation-controller 不受影响
- 删除内置默认网关常量,AppConfig::default() 的 tripo_base_url 改为空串;请求路径不再把超时静默钳到 .max(1)、重试钳到 .min(10),坏值原样交给 TripoSettings 并映射 503,作为启动期之后的第二道防线
- 两个运行旋钮仍可缺省(沿用内置 60000 / 2),显式声明时零超时、非数字或重试超过 10 都判启动失败
- .env.example 与 deploy/env/api-server.env.example 补齐 TRIPO_* 必填说明;deploy/container/api-server.env.example 与 container-worker-smoke 生成 env 补占位值,避免容器压测与 Jenkins 分支预览起不来
- 文档:新增 ADR 0006 并登记 docs/README.md,同步外部生成 Worker 化方案、开发运维文档、CONTEXT.md、决策记录、踩坑记录与实施计划
This commit is contained in:
2026-09-24 13:53:43 +08:00
parent eb27bb9226
commit d1e11c705d
15 changed files with 346 additions and 15 deletions
+9
View File
@@ -145,6 +145,15 @@ ELEVENLABS_BASE_URL="https://api.elevenlabs.io"
ELEVENLABS_API_KEY=""
ELEVENLABS_REQUEST_TIMEOUT_MS="180000"
# Tripo 3D 生成:地址与密钥必须显式给出,没有内置默认值。
# 缺任意一个,会提交或会执行 3D job 的进程(api / external-generation-worker / all)
# 在启动期直接拒绝启动——不会带着未知网关或空密钥把进程跑起来。
TRIPO_BASE_URL="https://openapi.tripo3d.com/v3"
TRIPO_API_KEY=""
# 两个运行旋钮可缺省(沿用内置值);显式给出时会校验,非法即拒绝启动。
TRIPO_REQUEST_TIMEOUT_MS="60000"
TRIPO_RETRIES="2"
# 阿里云 OSS 配置。
# Rust `server-rs` 的 `api-server` 会优先从 `.env` / `.env.local` 读取这些变量,
# 用于签发浏览器 PostObject 直传票据,并保持 `/generated-*` 旧路径习惯。
+4
View File
@@ -80,6 +80,10 @@ _Avoid_: 将 Tripo taskId 直接当公开 operationId、把 SDK task 状态模
Tripo 任务完成后,服务端下载模型与预览、写入受控对象存储并登记资源元数据,成功后才把 Tripo operation 置为 completed;本期按完整字节写入,流式上传是后续目标。
_Avoid_: 返回临时签名 URL作为永久资源、在 API handler 中把完整模型读成 Vec<u8>
**3D provider 配置门禁**:
Tripo 网关与密钥的可用性在「会提交或会执行 3D job 的进程」(`api` / `external-generation-worker` / `all`)启动期判定:`TRIPO_BASE_URL` 与 `TRIPO_API_KEY` 没有内置默认值,缺失、空白或网关非法即拒绝启动;请求期返回 503 只是第二道防线,不承担发现部署缺失的职责。
_Avoid_: 内置默认网关、缺配置时静默沿用某个地址或兜底密钥、把「少配置」留到用户提交后才发现
**3D 生成底价**:
一次 3D 生成在指定端点和模型版本下、按是否带贴图区分的基准泥点价,不包含任何叠加项,也不随请求的其它参数变化。
_Avoid_: 把底价与加价项合并成一个档位价、把 provider 的 credit 数值当底价
+9
View File
@@ -47,6 +47,15 @@ GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID=
GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET=
GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS=30000
# Tripo 3D 生成:网关与密钥没有内置默认值,会提交 / 执行 3D job 的角色
#(api / external-generation-worker / all)缺任意一项就拒绝启动。
# 下面的占位值只用于容器压测与 Jenkins 分支预览这类不跑真实 3D 请求的环境;
# 要真实跑 3D 必须换成真凭据,否则进程能起、3D 提交会在请求期 503。
TRIPO_BASE_URL=https://openapi.tripo3d.com/v3
TRIPO_API_KEY=CHANGE_ME_FOR_CONTAINER
TRIPO_REQUEST_TIMEOUT_MS=60000
TRIPO_RETRIES=2
GENARRATIVE_OTEL_ENABLED=true
OTEL_SERVICE_NAME=genarrative-api
OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4318
+9
View File
@@ -101,6 +101,15 @@ ELEVENLABS_BASE_URL=https://api.elevenlabs.io
ELEVENLABS_API_KEY=
ELEVENLABS_REQUEST_TIMEOUT_MS=180000
# Tripo 3D 生成:两项都是必填,缺任意一项时 api-server / external-generation-worker
# 会在启动期直接拒绝启动(不兜底到任何默认网关)。网关没有内置默认值;
# 密钥按部署机约定放受保护文件或受保护 env,不要写进仓库,也不要把这一行留空后部署。
TRIPO_BASE_URL=https://openapi.tripo3d.com/v3
TRIPO_API_KEY=
# 可缺省(沿用内置 60000 / 2);显式给出时零值或越界值会让进程拒绝启动。
TRIPO_REQUEST_TIMEOUT_MS=60000
TRIPO_RETRIES=2
HYPER3D_BASE_URL=https://api.hyper3d.com/api/v2
HYPER3D_API_KEY=
HYPER3D_MODEL_REQUEST_TIMEOUT_MS=180000
+1
View File
@@ -96,6 +96,7 @@
- [3D 资源客户端媒体投影与格式真实性 ADR](./adr/【ADR】0003-3D资源客户端媒体投影与格式真实性-2026-09-21.md):3D 资源的客户端投影按类别分叉,模型内容类型按字节识别,格式判定只在查看器包内(声明类型 → 字节魔数 → 地址扩展名),画布不判格式。
- [3D 生成入口的价格真相与幂等身份 ADR](./adr/【ADR】0004-3D生成入口价格与幂等身份-2026-09-21.md):3D 生成入口的价格只读实时定价查询,缺段即不可提交;幂等键由前端铸造,重试必须换键。
- [3D 生成定价迁入 SpacetimeDB 与后台编辑 ADR](./adr/【ADR】0005-3D生成定价迁入SpacetimeDB与后台编辑-2026-09-23.md):3D 定价按端点拆成两段并列设置并改为 SpacetimeDB 权威、后台可编辑;公开读模型与画布 3D 入口不变。
- [Tripo 配置启动期失败关闭 ADR](./adr/【ADR】0006-Tripo配置启动期失败关闭-2026-09-24.md):会提交或会执行 3D job 的角色缺 `TRIPO_BASE_URL` / `TRIPO_API_KEY` 即拒绝启动;网关无内置默认值,两个运行旋钮可缺省但声明即校验。
- [BgFilter 受限资源调度方案](./technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md)
- [Issue225 登录成功 AGC 用户归属修复](./technical/【后端架构】Issue225登录成功AGC用户归属修复方案-2026-09-03.md):登录 route tracking 的真实用户归属、`daily_login` 幂等边界和实施验收。
@@ -0,0 +1,15 @@
# 【ADR】0006-Tripo 配置启动期失败关闭-2026-09-24
状态:已接受
会提交、会执行 3D job 的进程(`api` / `external-generation-worker` / `all`)在启动期校验 `TRIPO_BASE_URL` 与 `TRIPO_API_KEY`:缺失、空白,或网关不是一个带主机名的 HTTP(S) 地址,就拒绝启动。网关不再有内置默认值(`DEFAULT_TRIPO_BASE_URL` 已删除),`AppConfig::default()` 的 `tripo_base_url` 是空串,因此任何没被显式配置过的 `AppConfig` 都过不了这道门。两个运行旋钮 `TRIPO_REQUEST_TIMEOUT_MS` / `TRIPO_RETRIES` 仍可缺省(沿用内置的 `60000` / `2`),但一旦声明就必须合法:零超时、非数字、重试次数超过 `TRIPO_MAX_RETRIES = 10` 都是启动期错误。
选启动期而不是继续只做请求期,是因为请求期的失败点离排查入口太远:HTTP 角色收 3D 单时根本不碰 provider,worker 只在自己真的跑到那个 job 时才构造 `TripoSettings`,于是「进程起来了、`/healthz` 全绿、用户拿到 202」之后任务才失败,现场只剩一条「提交成功但结果一直不来」的记录。而 3D 单次成本比图片 / 音频高一个量级,静默回落到某个运维不知道的网关等于把请求、额度和账单发向另一个账号。两点合起来,「起不来」明显优于「起得来但悄悄坏掉」。
角色范围刻意只覆盖真正会碰 3D 的角色:`bgfilter-worker` 与 `external-generation-controller` 不会因为缺 `TRIPO_*` 而起不来。这是对仓库既有 provider 惯例的一次偏离——VectorEngine / ElevenLabs / ARK / 阿里云抠图 / AGC OSS 全部是请求期 503,唯一在启动期硬失败的先例是 `BgfilterWorkerRuntime::new` 的角色级校验。偏离的理由就是上面那条:失败成本与失败可见性,3D 和其它 provider 不在一个量级。
同时删掉了请求路径上的静默钳制(原来的超时 `.max(1)` 与重试 `.min(10)`)。钳制把一个部署错误翻译成「能跑但行为诡异」,而 `TripoSettings::validate()` 本来就拒绝零超时,钳制反而把这层保护捂住。现在 `tripo_settings()` 原样把值交给构造器,坏配置仍映射成 503。
代价与已知取舍:请求期 503 作为第二道防线保留,兜住「启动后配置被改」或「别的入口拼出非法 `AppConfig`」,行为仍是不可用、不扣费、不入队;所有本地与 CI 环境在启动 api-server / worker 前都必须提供这两项,之前 `.env` 里一份 `TRIPO_*` 都没有的开发者会先撞上一次启动失败;Tripo 密钥没有 `_FILE` 变体(bgfilter 的 `GENARRATIVE_BGFILTER_INTERNAL_TOKEN` 有),密钥交付方式仍按部署机约定走 env。
被否决的做法:**保留内置默认网关**(缺配置时请求会打到运维没预期的账号);**只做请求期 503**(本次要修的就是这种「起得来、检查全绿、提交后失败」的形态);**只在 worker 角色校验**(HTTP 角色照样能把 3D 单收进来并返回 202,失败点仍然远离排查入口);**继续在请求路径静默钳制超时与重试**;**把两个旋钮也改成必填**(它们有明确的内置值、缺省是合法部署形态,强制填写只会制造与默认值等价的噪声配置)。
@@ -30,6 +30,7 @@ Milestone Spec: `docs/project-memory/plans/【里程碑】Tripo生成Worker执
1. **配置接入**
- `AppConfig` 增加 Tripo base url / API Key / 请求超时 / 重试,沿用 provider 示例的环境变量口径。
- `tripo3d/config.rs` 负责构造 `TripoSettings` 与 `TripoProviderClient`;未配置时返回 503 且不扣费、不入队。
- 2026-09-24 修订:网关与密钥改为启动期必填——`TRIPO_BASE_URL` / `TRIPO_API_KEY` 没有内置默认值,缺任意一项时 `api` / `external-generation-worker` / `all` 直接拒绝启动;请求期 503 保留为第二道防线。见 [ADR 0006](../../adr/【ADR】0006-Tripo配置启动期失败关闭-2026-09-24.md)。
- 验收:缺 key 的提交返回 503,不产生 job。
2. **job kind 与队列载荷**
@@ -1,5 +1,14 @@
# 决策记录
## 2026-09-24 Tripo 配置改为启动期失败关闭:网关无内置默认值,缺配置的进程直接起不来
- 背景:`TRIPO_BASE_URL` / `TRIPO_API_KEY` 原来只有请求期一道判断,而请求期根本挡不住部署错误:HTTP 角色收 3D 单时不碰 provider,worker 只在自己真的跑到那个 job 时才构造 `TripoSettings`,于是缺配置的部署形态是「进程起来了、`/healthz` 全绿、用户拿到 202」,失败只在任务记录里现身。同时 `AppConfig::default()` 带着内置网关 `https://openapi.tripo3d.com/v3`,`tripo_settings()` 又把超时钳到 `.max(1)`、重试钳到 `.min(10)`,一个写错的配置会被翻译成「能跑但行为诡异」。
- 决策:会提交、会执行 3D job 的角色(`ProcessRole::Api | ExternalGenerationWorker | All`)在启动期校验 `TRIPO_BASE_URL` / `TRIPO_API_KEY`,缺失、空白或网关不是带主机名的 HTTP(S) 地址即拒绝启动(`validate_tripo_config_for_startup` → `tripo3d::provider::validate_tripo_startup_config`)。`DEFAULT_TRIPO_BASE_URL` 常量删除,`AppConfig::default().tripo_base_url` 为空串。`TRIPO_REQUEST_TIMEOUT_MS` / `TRIPO_RETRIES` 仍可缺省(沿用内置 `60000` / `2`,空串与纯空白视同未声明),但显式声明就必须合法:零超时、非数字、重试超过 `TRIPO_MAX_RETRIES = 10` 都是启动期错误。请求路径的静默钳制一并删除,坏值原样交给 `TripoSettings::new`,仍映射 503。
- 原因:3D 单次成本比图片 / 音频高一个量级,静默回落到某个运维不知道的网关等于把请求、额度和账单发向另一个账号;「起不来」比「起得来但悄悄坏掉」便宜得多,而且启动失败本身就带着变量名,运维一眼能改。
- 代价与取舍:这是对仓库既有 provider 惯例的一次刻意偏离——VectorEngine / ElevenLabs / ARK / 阿里云抠图 / AGC OSS 全部保持请求期 503,唯一在启动期硬失败的先例是 `BgfilterWorkerRuntime::new` 的角色级校验;偏离只因为 3D 的失败成本与可见性不在一个量级。请求期 503 保留作第二道防线(配置启动后被改、或别的入口拼出非法 `AppConfig`)。另外所有本地 / CI 环境起 api-server 或 worker 前都必须提供这两项,`.env` 里一份 `TRIPO_*` 都没有的开发者会先撞一次启动失败;`bgfilter-worker` 与 `external-generation-controller` 不受影响。Tripo 密钥仍没有 `_FILE` 变体(bgfilter 的 `GENARRATIVE_BGFILTER_INTERNAL_TOKEN` 有),本次未加。
- 影响面:`server-rs/crates/api-server/src/{config.rs,main.rs,tripo3d/provider.rs}`、`.env.example`、`deploy/env/api-server.env.example`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、[ADR 0006](../../adr/【ADR】0006-Tripo配置启动期失败关闭-2026-09-24.md)。
- 验证方式:`cargo test -p api-server tripo_startup`(`tripo_startup_gate_covers_every_role_that_touches_3d` / `..._reports_the_missing_variable` / `..._skips_unrelated_roles`)、`cargo test -p api-server tripo3d::provider`(含 `startup_rejects_missing_or_blank_gateway_and_key` / `startup_rejects_unusable_gateway_addresses` / `startup_validates_declared_knobs_without_clamping_them` / `startup_accepts_declared_knobs_at_their_limits` / `request_path_no_longer_clamps_bad_knobs`)、`cargo test -p api-server config`。
## 2026-09-23 引用名不允许空白:素材 / Skill / 附件共用 `normalizeMentionName`
- 背景:自动评审发现 `buildContentFromTextTokens` 在前缀重叠时会多插一枚芯片——素材显示名 `hero` 与 `hero v2` 并存时,粘贴 `看 @hero v2 这一版` 得到 `[chip hero]` + `[chip hero-v2]`(短名先按 index 平局抢位,长名成了补到末尾的孤儿)。根因不是匹配算法,而是**引用名自己带空白**:token 的边界规则是「前后为空白或行首行尾」,`@hero␠` 在 `@hero v2` 内部也算一次合法命中。
@@ -6009,3 +6009,12 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **现象**:模板清单里的封面 URL 失效(或离线)时,卡片封面上出现浏览器的破碎图片图标,比没有封面更难看。
- **处理**:`TemplateCard` 的 `img` 加 `onError` 直接把自身 `visibility` 设为 `hidden`(不进 state,卡片是 memo 的纯展示组件),留下封面容器本身的中性底色;单测用 `fireEvent.error(cover)` 钉住。
- **关联**:`apps/ai-game-creator-shell/src/view/template-library/TemplateCard.tsx`、`apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx`。
## 2026-09-24 缺 provider 配置的进程照常启动,健康检查不会替你说出这件事
- **现象**:部署里没有 `TRIPO_BASE_URL` / `TRIPO_API_KEY` 时,api-server 与 external-generation-worker 都能正常起来,`/healthz` 全绿,用户提交 3D 生成拿到 202,然后任务在 worker 里失败——现场只有一条「提交成功但结果一直不来」的业务记录,没有任何一条指向「机器上少了一个环境变量」。
- **成因**:provider 凭据的可用性判断原本只在请求期(`tripo_settings()` 返回 503),而请求期路径并不覆盖启动:HTTP 角色收单时根本不碰 provider,worker 只在自己真的认领到那个 job 时才构造 `TripoSettings`。健康检查只看进程,不看「这个进程声称自己支持的能力是否具备实现它的配置」。
- **处理(现行口径)**:会提交、会执行 3D job 的角色(`api` / `external-generation-worker` / `all`)在启动期校验 Tripo 配置,缺失即拒绝启动;网关没有内置默认值,密钥没有兜底值。请求期 503 作为第二道防线保留(启动后配置被改、或别的入口拼出非法 `AppConfig`),语义仍是不可用、不扣费、不入队。要点是分层:启动期管「部署是否配全」,请求期管「这一刻能不能调」。
- **易错点**:① 不要把 `AppConfig::default()` 的字段当成「安全兜底」——`tripo_base_url` 现在是空串,任何忘了读 env 的入口都会在启动期被拒,这是故意的;② 不要在请求路径重新加回 `.max(1)` / `.min(10)` 这类静默钳制:钳制会把配置错误翻译成「能跑但行为诡异」,而 `TripoSettings::validate()` 本来就拒绝零超时;③ 新增会碰 provider 的角色时,同步考虑要不要把它加进启动期门禁的角色名单(现在刻意排除了 `bgfilter-worker` 与 `external-generation-controller`);④ 判断「缺配置」时空白字符串等于未配置,`TRIPO_API_KEY=" "` 不允许被当作有效密钥。
- **验证**:`server-rs/crates/api-server/src/main.rs` 的 `tripo_startup_gate_covers_every_role_that_touches_3d` / `tripo_startup_gate_reports_the_missing_variable` / `tripo_startup_gate_skips_unrelated_roles`;`tripo3d/provider.rs` 的 `startup_rejects_missing_or_blank_gateway_and_key` / `startup_rejects_unusable_gateway_addresses` / `startup_validates_declared_knobs_without_clamping_them` / `request_path_no_longer_clamps_bad_knobs`;`config::tests` 里 `AppConfig::default()` 的网关断言已改为「为空」。
- **关联**:`docs/adr/【ADR】0006-Tripo配置启动期失败关闭-2026-09-24.md`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`。
@@ -134,7 +134,7 @@ worker 配置:
- `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS`:任务 lease 时长,默认 `600`;worker 会按约三分之一 lease、最长 30 秒的间隔续租。该值应覆盖一次心跳网络抖动窗口,不需要大于完整外部生成链路耗时。
- `GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS`:普通外部生成 job 的执行预算,默认 `900`。超过预算后当前 worker 停止续租并释放 worker 槽位,但不取消已启动的业务 future,也不主动写入失败 / 重试状态;在途执行交由 lease fencing 仲裁:写回在租约有效期内到达则照常完成,否则被拒绝,租约过期后任务可被重新认领,attempt 耗尽时由认领事务原子标记失败并结算退款。
- `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS`:VectorEngine 图片生成 / 编辑、图标 spritesheet 生成、UI 素材提取以及角色动作、视频等长耗时 job 的执行预算,默认 `1800`。其中四类 VectorEngine 图片 job 固定为 `editor_image_generation`、`editor_image_edit`、`editor_icon_spritesheet_generation` 和 `editor_ui_design_asset_extraction`;手动去背景等不直接调用 VectorEngine 的 job 继续使用普通预算。Tripo 3D 的 `model3d_text_to_model` 与 `model3d_image_to_model` 同样使用该预算:它们在单次 attempt 内把 `get_task` 轮询到 provider 终态再下载产物,因此会持续占用一个 worker 并发位,接入真实流量前必须确认并发数与超时预算。
- `TRIPO_BASE_URL` / `TRIPO_API_KEY` / `TRIPO_REQUEST_TIMEOUT_MS` / `TRIPO_RETRIES`:Tripo 3D provider 的网关、凭据、API 单请求超时(同时也是产物下载的连接与「无数据推进」预算)与重试次数,由 HTTP 角色与 worker 共享同一份 API env。默认网关 `https://openapi.tripo3d.com/v3`;缺 API Key 时 3D 提交返回 503 且不扣费、不入队。worker 只使用 submit、单次 `get_task` 与产物下载三项能力,任务 ID 只作为 checkpoint 存在服务端。
- `TRIPO_BASE_URL` / `TRIPO_API_KEY` / `TRIPO_REQUEST_TIMEOUT_MS` / `TRIPO_RETRIES`:Tripo 3D provider 的网关、凭据、API 单请求超时(同时也是产物下载的连接与「无数据推进」预算)与重试次数,由 HTTP 角色与 worker 共享同一份 API env。网关没有内置默认值,`TRIPO_BASE_URL` 与 `TRIPO_API_KEY` 都必须在 env 里显式给出:缺任意一项时 `api` / `external-generation-worker` / `all` 在启动期直接拒绝启动(见 [ADR 0006](../../adr/【ADR】0006-Tripo配置启动期失败关闭-2026-09-24.md)),请求期仍保留「配置不可用即 503、不扣费、不入队」作为第二道防线。两个运行旋钮可缺省(沿用内置 `60000` / `2`),显式声明时零值或越界值同样让进程拒绝启动。worker 只使用 submit、单次 `get_task` 与产物下载三项能力,任务 ID 只作为 checkpoint 存在服务端。
worker 在单次 job 开始执行时从同一个单调时钟起点计算绝对 `job deadline` 和更早的 `provider deadline`:常规情况下为终态审计、OSS 持久化及 `complete/fail` 回写保留 `60` 秒;当整个 job 预算小于 `120` 秒时,保留其一半,避免 provider 预算被全部吃掉。该 deadline 只通过进程内 `RequestContext` 传给 VectorEngine 图片调用,不写入 HTTP DTO、队列 payload 或 SpacetimeDB;普通 HTTP / `inline` 上下文没有 deadline,保持原有行为。
@@ -240,6 +240,7 @@ spacetime sql <database> "SELECT * FROM runtime_setting LIMIT 1" --server http:/
本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查图片编辑器 Tiantoken 生成链路时,确认 `TIANTOKEN_BASE_URL`、`TIANTOKEN_API_KEY` 和 `TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。VectorEngine 配置仅保留给 Suno 音乐任务。`TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS` 是单次 attempt 的配置上限,默认 `1000000`;配置加载层允许显式值低于该默认值,不再在读取环境变量时强制抬高。业务模型和 Tiantoken provider 首选请求都使用 `gpt-image-2`,符合条件时才回退到兜底模型 `gpt-image-2-c`;图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`,`api-server` 只做编辑器请求编排、OSS / asset 持久化、计费和失败审计落库。`platform-image` 会在 JSON 生成和 multipart 编辑请求发送前按同一 GPT-image-2 family 规则归一显式像素尺寸;若请求发送失败,先按同一 `request_id` 查看 provider 日志与 `external_api_call_failure.metadata_json.errorSource`,当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。
编辑器 ElevenLabs 音效生成只从服务端读取 `ELEVENLABS_BASE_URL`、`ELEVENLABS_API_KEY` 和 `ELEVENLABS_REQUEST_TIMEOUT_MS`,timeout 默认 `180000ms`;base URL 或 Key 缺失时失败关闭,不回退 Vidu。生产 API 与 external-generation worker 通过共享 API env 取得同一配置,模板见 `deploy/env/api-server.env.example`;Key 不得进入 Web/Vite 环境、命令参数、日志、fixture 或仓库。普通测试只使用 loopback mock,禁止把真实付费请求作为 T3 自动验收。
Tripo 3D 生成的 `TRIPO_BASE_URL` / `TRIPO_API_KEY` 是**启动期必填项**:网关没有内置默认值,`api` / `external-generation-worker` / `all` 角色缺任意一项就直接拒绝启动(请求期 503 只是第二道防线),见 [ADR 0006](../adr/【ADR】0006-Tripo配置启动期失败关闭-2026-09-24.md);本地在 `.env.local` / `.env.secrets.local` 补齐,生产在 `/etc/genarrative/api-server.env` 补齐后再部署或重启。`TRIPO_REQUEST_TIMEOUT_MS` / `TRIPO_RETRIES` 可缺省(沿用内置 `60000` / `2`),显式给出时零值或越界值同样让进程拒绝启动。
SFX V2 发布必须使用维护窗:先关闭 SFX 入队,再对显式目标执行只读 `spacetime sql <database> --server <server-url> --format json "SELECT job_id, status, request_payload_json FROM external_generation_job WHERE job_kind = 'editor_sound_effect_generation' AND (status = 'pending' OR status = 'running')"`;结果非零时保持旧 Worker drain,不得删除任务或让新 Worker 解析旧 Vidu payload。禁止依赖默认 server,禁止使用 `--root-dir`。清零后先部署共享 env 已对齐的 api-server / external-generation worker,检查 `/healthz` 和 Worker 启动,再部署 Web 并小流量开放 SFX。灰度对账 job 完成数、退款数、ElevenLabs POST 数、完成资源数和孤儿资源;翻译失败仍调用 provider、单 job provider POST 大于一次、成功退款或失败未退款均应立即停止放量。回滚先停止入队并收口 V2 pending / running job,不自动切回 Vidu,不执行 SpacetimeDB schema 或数据回滚。完整清单见 `docs/【实施记录】SFX生成优化V2.0T6测试与发布门禁-2026-08-07.md`。
+4
View File
@@ -523,6 +523,10 @@ ALIYUN_OSS_BUCKET=
ALIYUN_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com
ALIYUN_OSS_ACCESS_KEY_ID=
ALIYUN_OSS_ACCESS_KEY_SECRET=
# 3D provider 启动期门禁:api / external-generation-worker 角色缺这两项就拒绝启动。
# smoke 只跑 unsupported job,不访问真实 Tripo,这里用占位值让进程能起。
TRIPO_BASE_URL=https://openapi.tripo3d.com/v3
TRIPO_API_KEY=worker-smoke-tripo-key
WECHAT_MINIPROGRAM_MESSAGE_TOKEN=
WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=
`;
+8 -5
View File
@@ -19,8 +19,9 @@ const DEFAULT_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS: u64 = 900;
const DEFAULT_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS: u64 = 1_800;
pub(crate) const DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS: u64 = 1_000_000;
pub(crate) const DEFAULT_ELEVENLABS_REQUEST_TIMEOUT_MS: u64 = 180_000;
/// Tripo 官方网关地址;只作为默认值,部署可用 `TRIPO_BASE_URL` 覆盖。
const DEFAULT_TRIPO_BASE_URL: &str = "https://openapi.tripo3d.com/v3";
/// Tripo 网关没有内置默认值:地址与密钥都必须在 env 里显式给出,缺失即拒绝启动
/// (见 `main::validate_tripo_config_for_startup`)。3D 生成要按真实额度扣费,
/// 静默回落到某个网关等于把请求发向运维不知道的账号。
pub(crate) const DEFAULT_TRIPO_REQUEST_TIMEOUT_MS: u64 = 60_000;
const DEFAULT_TRIPO_RETRIES: u32 = 2;
const DEFAULT_EDITOR_BGFILTER_BASE_URL: &str = "http://58.87.105.82/bgfilter";
@@ -540,7 +541,7 @@ impl Default for AppConfig {
hyper3d_base_url: "https://api.hyper3d.com/api/v2".to_string(),
hyper3d_api_key: None,
hyper3d_model_request_timeout_ms: 180_000,
tripo_base_url: DEFAULT_TRIPO_BASE_URL.to_string(),
tripo_base_url: String::new(),
tripo_api_key: None,
tripo_request_timeout_ms: DEFAULT_TRIPO_REQUEST_TIMEOUT_MS,
tripo_retries: DEFAULT_TRIPO_RETRIES,
@@ -1374,6 +1375,7 @@ impl AppConfig {
config.hyper3d_model_request_timeout_ms = hyper3d_model_request_timeout_ms;
}
// 只做读取,不填默认值:缺失在启动期就被拒,请求期不会拿到一个「猜出来的」网关。
if let Some(tripo_base_url) = read_first_non_empty_env(&["TRIPO_BASE_URL"]) {
config.tripo_base_url = tripo_base_url;
}
@@ -1819,7 +1821,7 @@ mod tests {
DEFAULT_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS, DEFAULT_ELEVENLABS_REQUEST_TIMEOUT_MS,
DEFAULT_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS,
DEFAULT_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS,
DEFAULT_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS, DEFAULT_TRIPO_BASE_URL,
DEFAULT_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS,
DEFAULT_TRIPO_REQUEST_TIMEOUT_MS, DEFAULT_TRIPO_RETRIES, ExternalGenerationMode,
LlmProvider, ProcessRole, parse_bool, parse_external_generation_mode, parse_process_role,
tiantoken_api_key, tiantoken_base_url,
@@ -1995,7 +1997,8 @@ mod tests {
);
assert!(config.ark_character_video_base_url.is_empty());
assert_eq!(config.hyper3d_base_url, "https://api.hyper3d.com/api/v2");
assert_eq!(config.tripo_base_url, DEFAULT_TRIPO_BASE_URL);
// 网关与密钥都没有默认值:缺配置是启动期错误,不是「用内置值顶上」。
assert!(config.tripo_base_url.is_empty());
assert!(config.tripo_api_key.is_none());
assert_eq!(
config.tripo_request_timeout_ms,
+66 -1
View File
@@ -160,6 +160,7 @@ fn main() -> Result<(), io::Error> {
async fn run_server(config: AppConfig) -> Result<(), io::Error> {
validate_bgfilter_internal_token_for_startup(&config).map_err(io::Error::other)?;
validate_tripo_config_for_startup(&config).map_err(io::Error::other)?;
validate_llm_router_config_for_startup(&config).map_err(io::Error::other)?;
init_tracing(
&config.log_filter,
@@ -182,6 +183,24 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> {
run_http_role(config).await
}
/// 3D 生成的启动期门禁:会提交或会执行 Tripo job 的角色,缺 `TRIPO_*` 就必须起不来。
///
/// 放在这里而不是请求路径上,是因为「进程起来了、健康检查全绿、用户拿到 202 之后 job 失败」
/// 这种失败点离排查入口太远:HTTP 角色收单时不碰 provider,worker 只在自己跑 job 时才发现。
fn validate_tripo_config_for_startup(config: &AppConfig) -> Result<(), String> {
if should_validate_tripo_config_for_startup(config.process_role) {
crate::tripo3d::provider::validate_tripo_startup_config(config)?;
}
Ok(())
}
fn should_validate_tripo_config_for_startup(process_role: ProcessRole) -> bool {
matches!(
process_role,
ProcessRole::Api | ProcessRole::ExternalGenerationWorker | ProcessRole::All
)
}
fn validate_llm_router_config_for_startup(config: &AppConfig) -> Result<(), String> {
if !matches!(config.process_role, ProcessRole::Api | ProcessRole::All) {
return Ok(());
@@ -848,8 +867,10 @@ mod tests {
parse_required_bgfilter_worker_capacity, protected_env_keys_from,
should_initialize_editor_generation_pricing_for_startup,
should_restore_auth_store_for_startup, should_start_profile_recharge_expiration_listener,
should_validate_bgfilter_internal_token_for_startup, strip_env_value,
should_validate_bgfilter_internal_token_for_startup,
should_validate_tripo_config_for_startup, strip_env_value,
validate_bgfilter_internal_token_for_startup, validate_llm_router_config_for_startup,
validate_tripo_config_for_startup,
};
use crate::config::{AppConfig, ProcessRole};
@@ -1060,4 +1081,48 @@ mod tests {
ProcessRole::ExternalGenerationController
));
}
#[test]
fn tripo_startup_gate_covers_every_role_that_touches_3d() {
// HTTP 角色收 3D 单、worker 角色跑 3D job,两者缺配置都不许起。
assert!(should_validate_tripo_config_for_startup(ProcessRole::Api));
assert!(should_validate_tripo_config_for_startup(
ProcessRole::ExternalGenerationWorker
));
assert!(should_validate_tripo_config_for_startup(ProcessRole::All));
// 与 3D 无关的角色不因此起不来。
assert!(!should_validate_tripo_config_for_startup(
ProcessRole::BgfilterWorker
));
assert!(!should_validate_tripo_config_for_startup(
ProcessRole::ExternalGenerationController
));
}
#[test]
fn tripo_startup_gate_reports_the_missing_variable() {
let config = AppConfig {
process_role: ProcessRole::Api,
tripo_base_url: String::new(),
tripo_api_key: None,
..AppConfig::default()
};
let error = validate_tripo_config_for_startup(&config)
.expect_err("HTTP 角色缺 3D 配置必须拒绝启动");
assert!(error.contains("TRIPO_BASE_URL"), "{error}");
}
#[test]
fn tripo_startup_gate_skips_unrelated_roles() {
let config = AppConfig {
process_role: ProcessRole::BgfilterWorker,
tripo_base_url: String::new(),
tripo_api_key: None,
..AppConfig::default()
};
assert!(validate_tripo_config_for_startup(&config).is_ok());
}
}
@@ -1,9 +1,12 @@
//! Tripo provider 客户端的构造入口。
//!
//! 这里只做“配置是否可用”的判断:缺地址或密钥属于部署问题,返回 503,
//! 调用方据此在扣费、入队与任何 provider 调用之前停止。
//! 两层防线,职责不同:
//! 1. 启动期([`validate_tripo_startup_config`]):地址与密钥没有默认值,缺失即拒绝启动,
//! 不允许出现「进程起来了、健康检查全绿、用户提交后才失败」的部署形态;
//! 2. 请求期([`tripo_settings`]):仍然按“配置是否可用”返回 503,兜住启动后配置被
//! 改动、或被其它入口拼出非法 AppConfig 的情况。
use std::time::Duration;
use std::{env, time::Duration};
use axum::http::StatusCode;
use platform_tripo::{TripoError, TripoProviderClient, TripoSettings};
@@ -13,6 +16,93 @@ use crate::{config::AppConfig, http_error::AppError};
pub(crate) const TRIPO_PROVIDER: &str = "tripo-3d";
const TRIPO_USER_AGENT: &str = "genarrative-api-server-tripo/1";
const TRIPO_BASE_URL_ENV: &str = "TRIPO_BASE_URL";
const TRIPO_API_KEY_ENV: &str = "TRIPO_API_KEY";
const TRIPO_REQUEST_TIMEOUT_MS_ENV: &str = "TRIPO_REQUEST_TIMEOUT_MS";
const TRIPO_RETRIES_ENV: &str = "TRIPO_RETRIES";
/// 重试次数直接决定产物下载的挂起时长;超过这个数属于配置写错,启动期就拒。
const TRIPO_MAX_RETRIES: u32 = 10;
/// 启动期门禁的输入。
///
/// 地址与密钥没有内置默认值,两个运行旋钮(超时 / 重试)缺失时沿用
/// `config.rs` 的常量,但**显式给出**的值一律按原样校验,不做钳制——
/// 「`TRIPO_REQUEST_TIMEOUT_MS=0` 被悄悄改成 1ms」这类静默兜底会掩盖配置错误。
#[derive(Clone, Copy)]
pub(crate) struct TripoStartupValues<'a> {
pub(crate) base_url: &'a str,
pub(crate) api_key: Option<&'a str>,
pub(crate) request_timeout_ms: Option<&'a str>,
pub(crate) retries: Option<&'a str>,
}
/// 读取原始 env 后校验;返回值直接作为启动失败原因展示给运维。
pub(crate) fn validate_tripo_startup_config(config: &AppConfig) -> Result<(), String> {
validate_tripo_startup_values(TripoStartupValues {
base_url: config.tripo_base_url.as_str(),
api_key: config.tripo_api_key.as_deref(),
request_timeout_ms: env::var(TRIPO_REQUEST_TIMEOUT_MS_ENV).ok().as_deref(),
retries: env::var(TRIPO_RETRIES_ENV).ok().as_deref(),
})
}
/// 纯函数形态的校验,便于用例直接覆盖各种坏配置。
pub(crate) fn validate_tripo_startup_values(values: TripoStartupValues<'_>) -> Result<(), String> {
let base_url = values.base_url.trim();
if base_url.is_empty() {
return Err(format!(
"{TRIPO_BASE_URL_ENV} 未配置:3D 生成要求显式给出 Tripo 网关地址,缺失时 api-server 拒绝启动。"
));
}
let url = reqwest::Url::parse(base_url)
.map_err(|error| format!("{TRIPO_BASE_URL_ENV} 不是合法 URL({error})。"))?;
if !matches!(url.scheme(), "http" | "https") {
return Err(format!(
"{TRIPO_BASE_URL_ENV} 必须是 HTTP/HTTPS 地址,当前为 {}。",
url.scheme()
));
}
if url.host_str().is_none() {
return Err(format!("{TRIPO_BASE_URL_ENV} 缺少主机名。"));
}
if values
.api_key
.map(str::trim)
.filter(|value| !value.is_empty())
.is_none()
{
return Err(format!(
"{TRIPO_API_KEY_ENV} 未配置:3D 生成需要该密钥,缺失时 api-server 拒绝启动。"
));
}
if let Some(raw) = read_declared_env(values.request_timeout_ms) {
let timeout_ms = raw.parse::<u64>().map_err(|_| {
format!("{TRIPO_REQUEST_TIMEOUT_MS_ENV} 必须是正整数毫秒,当前为 {raw}。")
})?;
if timeout_ms == 0 {
return Err(format!(
"{TRIPO_REQUEST_TIMEOUT_MS_ENV} 必须大于 0:零超时在 timeout 与 reqwest \
两处含义相反,放它进来只会得到指向错误方向的报错。"
));
}
}
if let Some(raw) = read_declared_env(values.retries) {
let retries = raw
.parse::<u32>()
.map_err(|_| format!("{TRIPO_RETRIES_ENV} 必须是非负整数,当前为 {raw}。"))?;
if retries > TRIPO_MAX_RETRIES {
return Err(format!(
"{TRIPO_RETRIES_ENV} 不能超过 {TRIPO_MAX_RETRIES},当前为 {retries}。"
));
}
}
Ok(())
}
/// 只把「显式声明过」的取值当输入;空串与纯空白视同未声明。
fn read_declared_env(raw: Option<&str>) -> Option<&str> {
raw.map(str::trim).filter(|value| !value.is_empty())
}
pub(crate) fn tripo_settings(config: &AppConfig) -> Result<TripoSettings, AppError> {
let base_url = config.tripo_base_url.trim().trim_end_matches('/');
@@ -28,14 +118,13 @@ pub(crate) fn tripo_settings(config: &AppConfig) -> Result<TripoSettings, AppErr
.filter(|value| !value.is_empty())
.ok_or_else(|| not_configured("TRIPO_API_KEY 未配置,无法调用 Tripo 3D 生成服务。"))?;
// 上面的取值已经把空地址 / 空密钥挡掉、把超时钳到 >= 1ms,构造器在这里不会再失败;
// 仍然按 503 映射,保持「配置问题在扣费与入队之前失败」的既有口径。
// 取值原样交给构造器:零超时或越界重试是配置错误,由 `TripoSettings::validate`
// 判失败并映射成 503,不再在这里静默钳制(启动期门禁已经先一步拒掉这种配置)。
TripoSettings::new(
api_key.to_string(),
base_url.to_string(),
Duration::from_millis(config.tripo_request_timeout_ms.max(1)),
// 重试次数直接决定产物下载的挂起时长,配置异常时封顶到 10 次。
config.tripo_retries.min(10),
Duration::from_millis(config.tripo_request_timeout_ms),
config.tripo_retries,
TRIPO_USER_AGENT.to_string(),
)
.map_err(map_provider_client_error)
@@ -77,6 +166,109 @@ mod tests {
assert!(tripo_settings(&config).is_err(), "空白密钥同样必须拒绝");
}
fn startup_values<'a>(base_url: &'a str, api_key: Option<&'a str>) -> TripoStartupValues<'a> {
TripoStartupValues {
base_url,
api_key,
request_timeout_ms: None,
retries: None,
}
}
#[test]
fn startup_rejects_missing_or_blank_gateway_and_key() {
let missing_base_url = validate_tripo_startup_values(startup_values(" ", Some("key")))
.expect_err("缺网关必须拒绝启动");
assert!(
missing_base_url.contains("TRIPO_BASE_URL"),
"{missing_base_url}"
);
let missing_key =
validate_tripo_startup_values(startup_values("https://openapi.tripo3d.com/v3", None))
.expect_err("缺密钥必须拒绝启动");
assert!(missing_key.contains("TRIPO_API_KEY"), "{missing_key}");
for blank in ["", " "] {
let error = validate_tripo_startup_values(startup_values(
"https://openapi.tripo3d.com/v3",
Some(blank),
))
.expect_err("空白密钥等同于未配置");
assert!(error.contains("TRIPO_API_KEY"), "{error}");
}
}
#[test]
fn startup_rejects_unusable_gateway_addresses() {
let not_a_url =
validate_tripo_startup_values(startup_values("openapi.tripo3d.com", Some("key")))
.expect_err("不是 URL 必须拒绝");
assert!(not_a_url.contains("TRIPO_BASE_URL"), "{not_a_url}");
let wrong_scheme =
validate_tripo_startup_values(startup_values("ftp://example.com/v3", Some("key")))
.expect_err("非 HTTP(S) 必须拒绝");
assert!(wrong_scheme.contains("HTTP/HTTPS"), "{wrong_scheme}");
}
#[test]
fn startup_validates_declared_knobs_without_clamping_them() {
let mut values = startup_values("https://openapi.tripo3d.com/v3", Some("key"));
values.request_timeout_ms = Some("0");
let zero_timeout = validate_tripo_startup_values(values).expect_err("零超时必须拒绝");
assert!(
zero_timeout.contains("TRIPO_REQUEST_TIMEOUT_MS"),
"{zero_timeout}"
);
let mut values = startup_values("https://openapi.tripo3d.com/v3", Some("key"));
values.request_timeout_ms = Some("soon");
assert!(
validate_tripo_startup_values(values)
.expect_err("非数字超时必须拒绝")
.contains("TRIPO_REQUEST_TIMEOUT_MS")
);
let mut values = startup_values("https://openapi.tripo3d.com/v3", Some("key"));
values.retries = Some("11");
let too_many = validate_tripo_startup_values(values).expect_err("越界重试必须拒绝");
assert!(too_many.contains("TRIPO_RETRIES"), "{too_many}");
let mut values = startup_values("https://openapi.tripo3d.com/v3", Some("key"));
values.retries = Some("many");
assert!(
validate_tripo_startup_values(values)
.expect_err("非数字重试必须拒绝")
.contains("TRIPO_RETRIES")
);
}
#[test]
fn startup_accepts_declared_knobs_at_their_limits() {
let mut values = startup_values("https://openapi.tripo3d.com/v3", Some("key"));
values.request_timeout_ms = Some("1");
values.retries = Some("10");
assert!(validate_tripo_startup_values(values).is_ok());
// 空串与纯空白视同未声明,沿用 config.rs 的常量,不算坏配置。
let mut values = startup_values("https://openapi.tripo3d.com/v3", Some("key"));
values.request_timeout_ms = Some(" ");
values.retries = Some("");
assert!(validate_tripo_startup_values(values).is_ok());
}
#[test]
fn request_path_no_longer_clamps_bad_knobs() {
let mut config = AppConfig::default();
config.tripo_base_url = "https://openapi.tripo3d.com/v3".to_string();
config.tripo_api_key = Some("test-key".to_string());
config.tripo_request_timeout_ms = 0;
// 启动期会先拒掉这种配置;真走到这里也必须 503,而不是被悄悄改成 1ms 继续跑。
let error = tripo_settings(&config).expect_err("零超时不得被静默钳制");
assert_eq!(error.status_code(), StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn configured_settings_trim_trailing_slash() {
let mut config = AppConfig::default();