diff --git a/.env.example b/.env.example index 60dc47b7e..6d43e2f8a 100644 --- a/.env.example +++ b/.env.example @@ -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-*` 旧路径习惯。 diff --git a/CONTEXT.md b/CONTEXT.md index efb6e3aa2..31cf2652b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -80,6 +80,10 @@ _Avoid_: 将 Tripo taskId 直接当公开 operationId、把 SDK task 状态模 Tripo 任务完成后,服务端下载模型与预览、写入受控对象存储并登记资源元数据,成功后才把 Tripo operation 置为 completed;本期按完整字节写入,流式上传是后续目标。 _Avoid_: 返回临时签名 URL作为永久资源、在 API handler 中把完整模型读成 Vec +**3D provider 配置门禁**: +Tripo 网关与密钥的可用性在「会提交或会执行 3D job 的进程」(`api` / `external-generation-worker` / `all`)启动期判定:`TRIPO_BASE_URL` 与 `TRIPO_API_KEY` 没有内置默认值,缺失、空白或网关非法即拒绝启动;请求期返回 503 只是第二道防线,不承担发现部署缺失的职责。 +_Avoid_: 内置默认网关、缺配置时静默沿用某个地址或兜底密钥、把「少配置」留到用户提交后才发现 + **3D 生成底价**: 一次 3D 生成在指定端点和模型版本下、按是否带贴图区分的基准泥点价,不包含任何叠加项,也不随请求的其它参数变化。 _Avoid_: 把底价与加价项合并成一个档位价、把 provider 的 credit 数值当底价 diff --git a/deploy/container/api-server.env.example b/deploy/container/api-server.env.example index e8b929f38..bc7cd35a4 100644 --- a/deploy/container/api-server.env.example +++ b/deploy/container/api-server.env.example @@ -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 diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example index 25ec4bbc4..59fc1713a 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -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 diff --git a/docs/README.md b/docs/README.md index e1aa9f8b1..90c02b19a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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` 幂等边界和实施验收。 diff --git a/docs/adr/【ADR】0006-Tripo配置启动期失败关闭-2026-09-24.md b/docs/adr/【ADR】0006-Tripo配置启动期失败关闭-2026-09-24.md new file mode 100644 index 000000000..726cb0db2 --- /dev/null +++ b/docs/adr/【ADR】0006-Tripo配置启动期失败关闭-2026-09-24.md @@ -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,失败点仍然远离排查入口);**继续在请求路径静默钳制超时与重试**;**把两个旋钮也改成必填**(它们有明确的内置值、缺省是合法部署形态,强制填写只会制造与默认值等价的噪声配置)。 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 5005962a3..44e4483bb 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 @@ -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 与队列载荷** diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 038db2189..cf81aed84 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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` 内部也算一次合法命中。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 5ea28ee7e..440c4278c 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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`。 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index 1a05ce444..2254f5634 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/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,保持原有行为。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index bdf6b42f8..7c36a5e1a 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -240,6 +240,7 @@ spacetime sql "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 --server --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`。 diff --git a/scripts/container-worker-smoke.mjs b/scripts/container-worker-smoke.mjs index 1beed7036..6b4ce8f45 100644 --- a/scripts/container-worker-smoke.mjs +++ b/scripts/container-worker-smoke.mjs @@ -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= `; diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 71f5125a0..2b196579c 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -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, diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 8619794b4..d43e04007 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -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()); + } } diff --git a/server-rs/crates/api-server/src/tripo3d/provider.rs b/server-rs/crates/api-server/src/tripo3d/provider.rs index f9251b230..42e8ce38b 100644 --- a/server-rs/crates/api-server/src/tripo3d/provider.rs +++ b/server-rs/crates/api-server/src/tripo3d/provider.rs @@ -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::().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::() + .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 { let base_url = config.tripo_base_url.trim().trim_end_matches('/'); @@ -28,14 +118,13 @@ pub(crate) fn tripo_settings(config: &AppConfig) -> Result= 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();