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:
@@ -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-*` 旧路径习惯。
|
||||
|
||||
@@ -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 数值当底价
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+9
@@ -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
|
||||
|
||||
@@ -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`。
|
||||
|
||||
|
||||
@@ -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=
|
||||
`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user