From 2cf3953bad6caad18a985b3a8d5a733cbbd5afcc Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 9 Jul 2026 01:32:23 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E5=A4=96=E9=83=A8=E7=94=9F?= =?UTF-8?q?=E6=88=90=E4=BB=BB=E5=8A=A1=E7=A7=9F=E7=BA=A6=E5=92=8C=E8=B6=85?= =?UTF-8?q?=E6=97=B6=E4=BF=9D=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 缩短 worker lease 并新增普通和长任务执行预算配置 让 worker 超过执行预算后写失败并释放槽位 降低 BgFilter 默认超时并加入连续失败熔断兜底 补充资产计费 future 取消时的退款补偿保护 同步生产 env 示例、部署脚本和运维文档 --- deploy/container/api-server.env.example | 7 +- deploy/env/api-server.env.example | 7 +- .../external-generation-worker.env.example | 4 +- .../shared-memory/decision-log.md | 3 +- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- ...端架构】外部生成Worker化方案-2026-06-03.md | 6 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 + scripts/deploy/production-api-deploy.sh | 55 +++++- scripts/jenkins-server-provision.sh | 46 ++++- .../crates/api-server/src/asset_billing.rs | 142 +++++++++++++--- server-rs/crates/api-server/src/config.rs | 160 +++++++++++++++++- .../crates/api-server/src/editor_project.rs | 107 +++++++++++- .../src/external_generation_worker.rs | 80 +++++++++ 14 files changed, 587 insertions(+), 38 deletions(-) diff --git a/deploy/container/api-server.env.example b/deploy/container/api-server.env.example index f46cb36a1..e0f28bdbf 100644 --- a/deploy/container/api-server.env.example +++ b/deploy/container/api-server.env.example @@ -15,7 +15,9 @@ GENARRATIVE_EXTERNAL_GENERATION_MODE=queue GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID= GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY=2 GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS=2000 -GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=3600 +GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=600 +GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS=900 +GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS=1800 GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512 GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320 GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64 @@ -30,6 +32,9 @@ GENARRATIVE_WALLET_REFUND_OUTBOX_DIR=/var/lib/genarrative/wallet-refund-outbox GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE=100 GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS=1000 GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES=67108864 +GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=45000 +GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3 +GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300 GENARRATIVE_OTEL_ENABLED=true OTEL_SERVICE_NAME=genarrative-api diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example index 7d11c33f4..cbf014f0c 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -14,7 +14,9 @@ GENARRATIVE_EXTERNAL_GENERATION_MODE=queue GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID= GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY=2 GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS=2000 -GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=3600 +GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=600 +GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS=900 +GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS=1800 GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512 GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320 GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64 @@ -30,6 +32,9 @@ GENARRATIVE_WALLET_REFUND_OUTBOX_DIR=/var/lib/genarrative/wallet-refund-outbox GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE=100 GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS=1000 GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES=67108864 +GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=45000 +GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3 +GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300 GENARRATIVE_OTEL_ENABLED=true OTEL_SERVICE_NAME=genarrative-api OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 diff --git a/deploy/env/external-generation-worker.env.example b/deploy/env/external-generation-worker.env.example index 04a85f13b..0b9849dee 100644 --- a/deploy/env/external-generation-worker.env.example +++ b/deploy/env/external-generation-worker.env.example @@ -8,6 +8,8 @@ GENARRATIVE_SPACETIME_POOL_SIZE=1 GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY=2 GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS=2000 # 单次 lease 会由 worker 自动续租;该值覆盖心跳抖动窗口即可。 -GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=3600 +GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=600 +GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS=900 +GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS=1800 GENARRATIVE_API_LOG=info,tower_http=info OTEL_SERVICE_NAME=genarrative-external-generation-worker diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 244dfaa0f..aee4e1725 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -43,7 +43,7 @@ ## 2026-07-03 图片画布生成纯色背景资产接入 BgFilter - 背景:独立 BgFilter 服务已部署在 image host,并提供 `POST /bgfilter/remove-background`,支持显式 `screen_color` 和 `seg_model`。手动去背景已有独立 BiRefNet BFF,不能把两个服务的配置或语义混在一起。 -- 决策:角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取在保存带纯色背景源图后,统一调用 BgFilter 生成透明 PNG;请求 multipart 字段为 `file`、`screen_color=` 和 `seg_model=`,前端用户路径固定提交 `segModel=birefnet` 且不展示抠图模型选择;`anime-seg` 路径保留为后端可识别的内部能力但不对用户可见。BgFilter 使用独立配置 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN`、`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`,默认 base URL 为 `http://58.87.105.82/bgfilter`,token 未配置时复用 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。手动 `POST /api/editor/images/background-removals` 继续使用独立 BiRefNet 配置 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL`,不受 BgFilter 影响。BgFilter 参数里的 `seg_model=birefnet` 只表示 BgFilter 内部分割后端,不等于手动去背景的独立 BiRefNet 服务。若 BgFilter 失败,api-server 对这些标准纯色背景生成图使用本地 `editor_green_screen` 兜底。角色动作抽帧仍保留 legacy `#00FF00` 与本地 `editor_green_screen` 透明化。 +- 决策:角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取在保存带纯色背景源图后,统一调用 BgFilter 生成透明 PNG;请求 multipart 字段为 `file`、`screen_color=` 和 `seg_model=`,前端用户路径固定提交 `segModel=birefnet` 且不展示抠图模型选择;`anime-seg` 路径保留为后端可识别的内部能力但不对用户可见。BgFilter 使用独立配置 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN`、`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`,默认 base URL 为 `http://58.87.105.82/bgfilter`,默认请求超时 `45000ms`,token 未配置时复用 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。手动 `POST /api/editor/images/background-removals` 继续使用独立 BiRefNet 配置 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL`,不受 BgFilter 影响。BgFilter 参数里的 `seg_model=birefnet` 只表示 BgFilter 内部分割后端,不等于手动去背景的独立 BiRefNet 服务。若 BgFilter 失败,api-server 对这些标准纯色背景生成图使用本地 `editor_green_screen` 兜底;连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`(默认 `3`)后,`GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`(默认 `300`)内直接本地兜底。角色动作抽帧仍保留 legacy `#00FF00` 与本地 `editor_green_screen` 透明化。 - 影响范围:`server-rs/crates/api-server/src/config.rs`、`server-rs/crates/api-server/src/editor_project.rs`、图片画布 MVP 文档和角色形象生成设计文档。 - 验证方式:运行 `cargo test -p api-server config::tests::from_env_reads_editor_bgfilter_settings_and_reuses_background_token editor_project::tests::editor_canvas_screen_background_generation_uses_bgfilter_postprocess --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。 @@ -1215,6 +1215,7 @@ - 2026-06-07 追加:`GENARRATIVE_EXTERNAL_GENERATION_MODE` 使用 `queue|inline` 显式策略;生产和容器扩缩容验证保持 `queue`。本地开发若需要同步等待结果,应通过 `.env.local` 或本机环境显式配置为 `inline`,由 HTTP handler 复用同一 worker executor 直接返回 `completed`,不创建 `external_generation_job`,不支持 worker 动态扩缩容;脚本不得硬编码该策略。拼图写回 guard 字段改为可选,queue 路径仍必须完整校验 `job_id + worker_id + lease_token`;inline 路径只允许三项同时为空,半空 guard 仍拒绝。 - 2026-06-11 追加:生产新增固定 `external-generation-controller` 进程角色和 `genarrative-external-generation-controller.service`。controller 只读取 `get_external_generation_queue_stats_and_return` 队列统计并管理 `genarrative-external-generation-worker@N.service`,不监听 HTTP、不执行外部生成任务;默认保留 `@1`,按 `claimable_pending + running_active + expired_running` 计算目标实例数,上限由 `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_MAX_WORKERS` 控制,缩容需要连续空闲轮数且每轮只停最高编号一个实例。 - 2026-07-08 追加:生产 worker/controller 作为轻量 SpacetimeDB 客户端运行,专属 env 示例默认 `GENARRATIVE_SPACETIME_POOL_SIZE=1`;非 HTTP 角色只保留 `external_generation_job` 队列窄订阅作为响应式唤醒信号,实际抢占和扩缩容判断仍走 SpacetimeDB procedure,且不再订阅 API 读模型连接池。worker/controller poll interval 只作为订阅失效、漏事件和 lease 过期这类时间条件的兜底,不作为正常领取任务的主路径。 +- 2026-07-08 追加:worker lease 默认从 `3600s` 收短到 `600s`,普通 job 执行预算默认 `900s`,视频 / 角色动作等长 job 默认 `1800s`;预算到期后当前 worker 结束本次尝试、写失败 / 重试状态并释放 worker 槽位,若 SpacetimeDB 当时不可写则最多等到较短 lease 过期后重新领取,避免无进展续租无限延长。资产计费层对已扣费但 future 被取消的外部生成操作做异步补偿退款,避免超时取消绕过退款 outbox。 - 影响范围:`server-rs/crates/spacetime-module/src/external_generation.rs`、`server-rs/crates/spacetime-client/src/external_generation.rs`、`server-rs/crates/api-server/src/external_generation_worker.rs`、`server-rs/crates/api-server/src/external_generation_worker_controller.rs`、`deploy/systemd/genarrative-external-generation-worker@.service`、`deploy/systemd/genarrative-external-generation-controller.service`、`deploy/env/external-generation-controller.env.example`、`scripts/deploy/production-api-deploy.sh`、`scripts/jenkins-server-provision.sh`、拼图 `compile_puzzle_draft`、拼图 `generate_puzzle_images`、拼图 `generate_puzzle_ui_background`、生产 env 模板和运维文档。 - 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run check:server-rs-ddd`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`,并在 queue 模式下用 `GENARRATIVE_PROCESS_ROLE=all npm run dev` smoke 至少一次 queued -> worker 完成链路;本地 inline 排查只确认不创建 `external_generation_job`。 - 关联文档:`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index a3d14d34b..1b1ac5a2d 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -21,7 +21,7 @@ - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 - 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。重绘 / 改造输入框只允许从 `generationInputs.fields` 中恢复用户可见输入快照,例如普通生成提示词、视频描述、音效 `prompt`、背景音乐 `gpt_description_prompt`、角色设定、UI 用户输入、图标素材描述、规范表单和宣发素材字段;禁止回退展示资源 `prompt` / `actualPrompt` 中的后端拼接 Prompt、固定生成模板或模型默认提示词。没有用户输入快照的旧图层打开改造时保持空输入,等待用户重新填写。 - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后走同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片源读取为图片 Data URL;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 -- 图片画布抠图分两类:手动去除背景面向用户任意图片,走登录态同源 BFF `POST /api/editor/images/background-removals` 并转发远端 BiRefNet;编辑器自己生成的标准纯色背景抠图资产在保存源图后统一调用独立 BgFilter 服务 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`,默认 `http://58.87.105.82/bgfilter/remove-background`。角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取的前端用户路径都固定把 `screenColor=auto` 注入请求体,但用户可见 `generationInputs.fields` 不再记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 11 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt 和 BgFilter `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。三条 BgFilter 路径还必须固定把默认 `segModel=birefnet` 传为 `seg_model`;后端仍保留识别 `anime-seg` 的内部兼容能力,但前端用户入口不展示也不提交该值。这里的 `birefnet` 只是 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。后端在调用 BgFilter 前必须先把带纯色背景 / 绿幕源图写入 OSS;若 BgFilter 请求失败、返回非成功状态、空图片或非法图片,api-server 对这些标准纯色背景生成图使用本地 `editor_green_screen` 按同一 `screenColor` 兜底去背。角色动作抽帧后的序列帧暂不接用户背景色选择,仍沿用 legacy `#00FF00` 绿幕和本地 `editor_green_screen` 透明化,不依赖 BiRefNet 或 BgFilter。BiRefNet 手动去背景服务地址为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL/remove-background`,默认 `http://58.87.105.82/remove-background`;BgFilter 可选访问令牌来自 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,未配置时复用 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`,所有令牌都只在服务端注入,前端不持有令牌。api-server 对上游结果做响应字节和图片尺寸上限保护,并先落 OSS / asset object,再返回 `imageSrc/objectKey/assetObjectId/taskId`;queue 模式下手动去背景进入 SpacetimeDB 外部生成队列,画布任务侧栏只展示服务器任务阶段,生成中才显示耗时,不显示百分比;有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端,完成后由后端写入结果图层和最新项目快照。 +- 图片画布抠图分两类:手动去除背景面向用户任意图片,走登录态同源 BFF `POST /api/editor/images/background-removals` 并转发远端 BiRefNet;编辑器自己生成的标准纯色背景抠图资产在保存源图后统一调用独立 BgFilter 服务 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`,默认 `http://58.87.105.82/bgfilter/remove-background`,默认请求超时 `45000ms`。角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取的前端用户路径都固定把 `screenColor=auto` 注入请求体,但用户可见 `generationInputs.fields` 不再记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 11 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt 和 BgFilter `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。三条 BgFilter 路径还必须固定把默认 `segModel=birefnet` 传为 `seg_model`;后端仍保留识别 `anime-seg` 的内部兼容能力,但前端用户入口不展示也不提交该值。这里的 `birefnet` 只是 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。后端在调用 BgFilter 前必须先把带纯色背景 / 绿幕源图写入 OSS;若 BgFilter 请求失败、返回非成功状态、空图片或非法图片,api-server 对这些标准纯色背景生成图使用本地 `editor_green_screen` 按同一 `screenColor` 兜底去背;连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后,冷却 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 秒内直接使用本地兜底。角色动作抽帧后的序列帧暂不接用户背景色选择,仍沿用 legacy `#00FF00` 绿幕和本地 `editor_green_screen` 透明化,不依赖 BiRefNet 或 BgFilter。BiRefNet 手动去背景服务地址为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL/remove-background`,默认 `http://58.87.105.82/remove-background`;BgFilter 可选访问令牌来自 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,未配置时复用 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`,所有令牌都只在服务端注入,前端不持有令牌。api-server 对上游结果做响应字节和图片尺寸上限保护,并先落 OSS / asset object,再返回 `imageSrc/objectKey/assetObjectId/taskId`;queue 模式下手动去背景进入 SpacetimeDB 外部生成队列,画布任务侧栏只展示服务器任务阶段,生成中才显示耗时,不显示百分比;有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端,完成后由后端写入结果图层和最新项目快照。 - 图片快速编辑面板只保留一个提示词输入框和模型选择,不展示额外参考图或比例 / 尺寸控件;原图 / 原素材作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。完成框选后,画布红色细框显示连续序号,提示词可按这些编号填写每个区域怎么改。点击 `修改` 后仍停留在当前快速编辑面板显示修改中,不创建独立 `Quick Edit Generator` 画布占位;生成成功后直接用结果覆盖原图图层,失败时保留当前面板并显示错误。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 - 画布右上角提供自动隐藏任务侧栏。列表为空且侧栏关闭时只保留图标开关;生成或去背景任务进入时默认打开;用户可手动切换开关状态。 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index 48afc98ec..6908adc2c 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -112,7 +112,9 @@ worker 配置: - `GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID`:实例 ID;未配置时用 hostname/pid 派生。 - `GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY`:单进程并发领取/执行数量。 - `GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS`:空队列轮询间隔。 -- `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS`:任务 lease 时长;worker 会按约三分之一 lease、最长 30 秒的间隔续租。该值应覆盖一次心跳网络抖动窗口,不需要大于完整外部生成链路耗时。 +- `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS`:任务 lease 时长,默认 `600`;worker 会按约三分之一 lease、最长 30 秒的间隔续租。该值应覆盖一次心跳网络抖动窗口,不需要大于完整外部生成链路耗时。 +- `GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS`:普通外部生成 job 的执行预算,默认 `900`。超过预算后当前 worker 停止当前尝试、写入失败 / 重试状态并释放 worker 槽位,避免任务长期保持 `running_active`;若业务 future 已在计费操作内被取消,计费层会按外部生成 job id 异步补偿退款。 +- `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS`:视频、角色动作等长耗时 job 的执行预算,默认 `1800`。 controller 配置: @@ -124,7 +126,7 @@ controller 配置: - `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_SERVICE_TEMPLATE`:systemd worker 模板,默认 `genarrative-external-generation-worker@{}.service`。 - `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_DRY_RUN`:只记录决策不执行 systemctl,默认 `false`。 -动态缩扩容方式:生产默认由 `deploy/systemd/genarrative-external-generation-controller.service` 启动 `GENARRATIVE_PROCESS_ROLE=external-generation-controller`,controller 读取 `get_external_generation_queue_stats_and_return` 后对 `genarrative-external-generation-worker@N.service` 执行精确 `systemctl start/stop`;无需改变 HTTP 进程数。controller 只操作 `@1..@MAX` 中的缺口或最高编号多余实例,保留 `@1` 作为保底 worker。缩容或发布重启 worker 时,进程收到 SIGINT/SIGTERM 后会停止 claim 新任务并等待当前任务完成;若进程被硬杀、机器断电或超过 systemd `TimeoutStopSec`,未完成任务会在 lease 过期后被其它 worker 重新领取。容器链路已有独立 `external-generation-worker` compose service;扩 worker 必须扩这个 worker service,不能只扩 `api-server` HTTP service。 +动态缩扩容方式:生产默认由 `deploy/systemd/genarrative-external-generation-controller.service` 启动 `GENARRATIVE_PROCESS_ROLE=external-generation-controller`,controller 读取 `get_external_generation_queue_stats_and_return` 后对 `genarrative-external-generation-worker@N.service` 执行精确 `systemctl start/stop`;无需改变 HTTP 进程数。controller 只操作 `@1..@MAX` 中的缺口或最高编号多余实例,保留 `@1` 作为保底 worker。缩容或发布重启 worker 时,进程收到 SIGINT/SIGTERM 后会停止 claim 新任务并等待当前任务完成;若进程被硬杀、机器断电或超过 systemd `TimeoutStopSec`,未完成任务会在 lease 过期后被其它 worker 重新领取。若 worker 内业务 future 长时间无返回,执行预算到期后会结束当前尝试并释放槽位;如果当时 SpacetimeDB 写回失败,任务也会按较短 lease 进入可重领窗口,避免无进展续租无限延长。容器链路已有独立 `external-generation-worker` compose service;扩 worker 必须扩这个 worker service,不能只扩 `api-server` HTTP service。 ## 已接入的拼图纵切 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 2e84c0168..39d2e59f4 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -220,7 +220,7 @@ npm run check:server-rs-ddd - LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`;未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 `VECTOR_ENGINE_API_KEY`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5.4-mini 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 - 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id 和结果摘要,不再写回过时的 `ai_task`。DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations` 和 `/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required`。`request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt`、`max_attempts`、`retry_delay_ms`、`reference_image_bytes_total` 和 `request_params`,不要把 `SendRequest` 当成上游业务错误。 -- 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 继续代理独立 BiRefNet 服务,配置为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL`、`GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 和 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS`。角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取的生成后纯色背景透明化改走独立 BgFilter 服务,配置为 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN` 和 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`,默认 base URL 为 `http://58.87.105.82/bgfilter`,token 未配置时复用 BiRefNet token。BgFilter 请求必须显式传 `screen_color=` 和 `seg_model=`;前端用户路径不展示抠图模型选择并固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,其中 `birefnet` 只表示 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。角色动作抽帧仍沿用 legacy `#00FF00` 和本地 `editor_green_screen` 透明化。 +- 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 继续代理独立 BiRefNet 服务,配置为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL`、`GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 和 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS`。角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取的生成后纯色背景透明化改走独立 BgFilter 服务,配置为 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN` 和 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`,默认 base URL 为 `http://58.87.105.82/bgfilter`,默认请求超时为 `45000ms`,token 未配置时复用 BiRefNet token。BgFilter 请求必须显式传 `screen_color=` 和 `seg_model=`;前端用户路径不展示抠图模型选择并固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,其中 `birefnet` 只表示 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。BgFilter 连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`(默认 `3`)后,会在 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`(默认 `300`)内直接使用本地 `editor_green_screen` 兜底,避免上游故障占住 worker。角色动作抽帧仍沿用 legacy `#00FF00` 和本地 `editor_green_screen` 透明化。 - Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。 - Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。 - Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!` 随 `api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 1efc27ee2..ed37c4222 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -59,6 +59,10 @@ Windows 本地如果已在 `%LOCALAPPDATA%\Genarrative\ffmpeg\bin` 安装 FFmpeg 生产拆分角色时,`external-generation-worker` 和 `external-generation-controller` 的专属 env 示例会把 `GENARRATIVE_SPACETIME_POOL_SIZE` 覆盖为 `1`;非 HTTP 角色只保留 `external_generation_job` 队列窄订阅作为响应式唤醒信号,实际抢占和扩缩容判断仍走 SpacetimeDB procedure,且不再订阅 API 读模型连接池。`GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS` 与 controller poll interval 只作为订阅失效、漏事件和 lease 过期这类时间条件的兜底,不作为正常领取任务的主路径。 +生产 worker 默认 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=600`,只覆盖 worker 心跳抖动和短暂断连窗口,不再把 lease 当成完整任务时长;默认 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS=900`,角色动画 / 视频类长任务使用 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS=1800`。worker 在单次尝试超过执行预算后会停止当前尝试、写入失败 / 重试状态并释放 worker 槽位;如果 SpacetimeDB 当时不可写,当前租约最多再保留到 lease 过期,之后任务重新变为可领取。生产部署和 provision 脚本会给 `/etc/genarrative/api-server.env` 与 `/etc/genarrative/external-generation-worker.env` 补齐这些变量;已有自定义值不覆盖,只会把历史旧默认 `3600` 迁移为 `600`。 + +图片画布角色图、图标素材和 UI 素材提取在绿色 / 蓝色幕布去背景时优先调用 BgFilter;默认 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=45000`,连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后熔断 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 秒,熔断期直接走本地幕布色去背景兜底。修改这些变量后需要重启对应 `api-server` / worker 进程;排查时先从 worker 启动日志确认 lease 和 job timeout,再看 `editor_bgfilter_request_start`、`editor_bgfilter_fallback_to_local_screen_background_removal` 与 `editor_bgfilter_circuit_open_fallback_to_local_screen_background_removal` 日志。 + `我的` 页签或排障面板展示队列等待时,只读取 BFF 队列接口:`GET /api/runtime/external-generation/queue-overview` 查看当前用户可见队列概览,`GET /api/runtime/external-generation/jobs/{jobId}` 查看单 job 状态。生成页 / 进度页不承接队列概览,只展示当前玩法业务进度;队列接口只提供等待 / 运行 / 失败 / 完成状态补充,最终草稿、作品和结果页仍要轮询对应玩法 session/detail 接口收敛到 ready 或 failed;不要直接查询 `external_generation_job` private table,也不要把 worker 内部 payload 暴露到前端。 需要验证“更新 API 不停 worker”和“worker 是否持续消费队列”时,优先使用隔离容器 smoke:`npm run container:worker-smoke -- smoke`。该脚本生成 gitignored 的 `deploy/container/worker-smoke/api-server.env`,启动独立 compose project 与独立 SpacetimeDB,发布当前 `spacetime-module` 后写入 `worker_smoke_unsupported` 测试 job;预期 worker claim 后执行 unsupported 失败分支,再执行 API-only recreate 并确认 worker 容器 ID 不变,最后再次入队验证 API 更新后队列仍可消费。`external_generation_job` 是 private table,脚本通过 worker 日志确认 job_id 被消费,不用 CLI SQL 查询私表。该 smoke 不读取 `.env.local`,也不依赖真实 VectorEngine / OSS 密钥;真实生图链路联调再在本地私有 env 中补齐 provider 配置。worker-smoke 默认把本机 `spacetime` CLI 打成轻量 SpacetimeDB 镜像,避免本机首次 smoke 依赖官方大镜像下载。若容器内 Cargo 拉取 crates.io 依赖不稳定,可用 `npm run container:worker-smoke -- smoke --local-binary` 让容器内 Cargo 复用本机 Cargo 缓存构建当前二进制,再打入 Debian bookworm smoke runtime 临时镜像;可用 `GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE` 覆盖运行时基础镜像;若隔离端口或库数据需要重建,追加 `--force`。完成 queue 链路验证时,还要用队列概览 BFF 和单 job 状态接口确认 job 从 queued/running 收敛,并用对应玩法 session/detail 接口确认业务状态同步完成。 diff --git a/scripts/deploy/production-api-deploy.sh b/scripts/deploy/production-api-deploy.sh index 0f3877046..d541e5d0e 100644 --- a/scripts/deploy/production-api-deploy.sh +++ b/scripts/deploy/production-api-deploy.sh @@ -5,7 +5,7 @@ set -euo pipefail usage() { cat <<'EOF' 用法: - ./scripts/deploy/production-api-deploy.sh --source-dir build/ [--version ] [--release-root /opt/genarrative/releases] [--current-link /opt/genarrative/current] [--service genarrative-api.service] [--pingora-service genarrative-pingora-gateway.service] [--require-pingora-gateway] [--worker-service-pattern 'genarrative-external-generation-worker@*.service'] [--no-worker-services] [--worker-controller-service genarrative-external-generation-controller.service] [--no-worker-controller] [--health-url http://127.0.0.1:8082/readyz] [--api-env-file /etc/genarrative/api-server.env] [--database genarrative-prod] [--spacetime-server-url http://127.0.0.1:3101] + ./scripts/deploy/production-api-deploy.sh --source-dir build/ [--version ] [--release-root /opt/genarrative/releases] [--current-link /opt/genarrative/current] [--service genarrative-api.service] [--pingora-service genarrative-pingora-gateway.service] [--require-pingora-gateway] [--worker-service-pattern 'genarrative-external-generation-worker@*.service'] [--no-worker-services] [--worker-controller-service genarrative-external-generation-controller.service] [--no-worker-controller] [--health-url http://127.0.0.1:8082/readyz] [--api-env-file /etc/genarrative/api-server.env] [--worker-env-file /etc/genarrative/external-generation-worker.env] [--database genarrative-prod] [--spacetime-server-url http://127.0.0.1:3101] 说明: 进入维护模式,校验并发布 api-server 单文件,更新 current 链接,重启 systemd 服务并执行 readiness 检查。 @@ -183,10 +183,30 @@ ensure_env_value() { return fi - echo "[production-api-deploy] 补齐 api-server 环境变量: ${key} -> ${file_path}" + echo "[production-api-deploy] 补齐运行态环境变量: ${key} -> ${file_path}" write_env_value "${file_path}" "${key}" "${default_value}" } +ensure_env_value_migrates_old_default() { + local file_path="$1" + local key="$2" + local old_default="$3" + local new_default="$4" + local current_value + + current_value="$(read_env_value "${file_path}" "${key}")" + if [[ -z "${current_value}" ]]; then + ensure_env_value "${file_path}" "${key}" "${new_default}" + return + fi + if [[ "${current_value}" != "${old_default}" ]]; then + return + fi + + echo "[production-api-deploy] 迁移运行态环境变量旧默认值: ${key} ${old_default} -> ${new_default} (${file_path})" + write_env_value "${file_path}" "${key}" "${new_default}" +} + run_privileged() { if [[ "$(id -u)" -eq 0 ]]; then "$@" @@ -232,6 +252,12 @@ ensure_runtime_env_and_dirs() { ensure_env_value "${api_env_file}" "GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE" "100" ensure_env_value "${api_env_file}" "GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS" "1000" ensure_env_value "${api_env_file}" "GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES" "67108864" + ensure_env_value_migrates_old_default "${api_env_file}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS" "3600" "600" + ensure_env_value "${api_env_file}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS" "900" + ensure_env_value "${api_env_file}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS" "1800" + ensure_env_value "${api_env_file}" "GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS" "45000" + ensure_env_value "${api_env_file}" "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD" "3" + ensure_env_value "${api_env_file}" "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS" "300" tracking_enabled="$(read_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_ENABLED")" tracking_outbox_dir="$(read_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_DIR")" @@ -245,6 +271,22 @@ ensure_runtime_env_and_dirs() { fi } +ensure_worker_runtime_env_defaults() { + local worker_env_file="$1" + + if [[ -z "${worker_env_file}" ]]; then + return + fi + if [[ ! -f "${worker_env_file}" ]]; then + echo "[production-api-deploy] worker 环境文件不存在,跳过运行态默认值补齐: ${worker_env_file}" + return + fi + + ensure_env_value_migrates_old_default "${worker_env_file}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS" "3600" "600" + ensure_env_value "${worker_env_file}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS" "900" + ensure_env_value "${worker_env_file}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS" "1800" +} + extract_pingora_env_files_from_unit() { local service_name="$1" local unit_content @@ -585,6 +627,7 @@ WORKER_SERVICE_PATTERN="genarrative-external-generation-worker@*.service" WORKER_CONTROLLER_SERVICE="genarrative-external-generation-controller.service" HEALTH_URL="http://127.0.0.1:8082/readyz" API_ENV_FILE="/etc/genarrative/api-server.env" +WORKER_ENV_FILE="/etc/genarrative/external-generation-worker.env" DATABASE="" SPACETIME_SERVER_URL="" DEPLOY_COMPLETED=0 @@ -653,6 +696,10 @@ while [[ $# -gt 0 ]]; do API_ENV_FILE="${2:?缺少 --api-env-file 的值}" shift 2 ;; + --worker-env-file) + WORKER_ENV_FILE="${2:?缺少 --worker-env-file 的值}" + shift 2 + ;; --database) DATABASE="${2:?缺少 --database 的值}" shift 2 @@ -673,6 +720,9 @@ require_argument "${SOURCE_DIR}" "--source-dir" require_absolute_path "${RELEASE_ROOT}" "--release-root" require_absolute_path "${CURRENT_LINK}" "--current-link" require_absolute_path "${API_ENV_FILE}" "--api-env-file" +if [[ -n "${WORKER_ENV_FILE}" ]]; then + require_absolute_path "${WORKER_ENV_FILE}" "--worker-env-file" +fi if [[ -n "${DATABASE}" ]]; then validate_spacetime_database_name "${DATABASE}" @@ -979,6 +1029,7 @@ if [[ -n "${SPACETIME_SERVER_URL}" ]]; then fi ensure_runtime_env_and_dirs "${API_ENV_FILE}" +ensure_worker_runtime_env_defaults "${WORKER_ENV_FILE}" if [[ "${PINGORA_INCLUDED}" -eq 1 ]]; then PINGORA_SHADOW_ENV_FILE="$(check_pingora_shadow_service_config "${PINGORA_SERVICE_NAME}")" diff --git a/scripts/jenkins-server-provision.sh b/scripts/jenkins-server-provision.sh index fcf78a087..edea458aa 100755 --- a/scripts/jenkins-server-provision.sh +++ b/scripts/jenkins-server-provision.sh @@ -427,12 +427,34 @@ ensure_env_value() { return fi - echo "[server-provision] 补齐 api-server 环境变量: ${key} -> ${file}" + echo "[server-provision] 补齐运行态环境变量: ${key} -> ${file}" if [[ "${DRY_RUN}" != "true" ]]; then write_env_value "${file}" "${key}" "${default_value}" fi } +ensure_env_value_migrates_old_default() { + local file="$1" + local key="$2" + local old_default="$3" + local new_default="$4" + local current_value + + current_value="$(read_env_value "${file}" "${key}")" + if [[ -z "${current_value}" ]]; then + ensure_env_value "${file}" "${key}" "${new_default}" + return + fi + if [[ "${current_value}" != "${old_default}" ]]; then + return + fi + + echo "[server-provision] 迁移运行态环境变量旧默认值: ${key} ${old_default} -> ${new_default} (${file})" + if [[ "${DRY_RUN}" != "true" ]]; then + write_env_value "${file}" "${key}" "${new_default}" + fi +} + ensure_api_runtime_env_defaults() { if [[ "${DRY_RUN}" == "true" ]]; then echo "+ ensure api-server runtime env defaults in ${API_ENV_FILE}" @@ -454,6 +476,27 @@ ensure_api_runtime_env_defaults() { ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE" "100" ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS" "1000" ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES" "67108864" + ensure_env_value_migrates_old_default "${API_ENV_FILE}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS" "3600" "600" + ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS" "900" + ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS" "1800" + ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS" "45000" + ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD" "3" + ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS" "300" +} + +ensure_worker_runtime_env_defaults() { + if [[ "${DRY_RUN}" == "true" ]]; then + echo "+ ensure external-generation worker runtime env defaults in ${WORKER_ENV_FILE}" + return + fi + if [[ ! -f "${WORKER_ENV_FILE}" ]]; then + echo "[server-provision] worker 环境文件不存在,无法补齐运行态变量: ${WORKER_ENV_FILE}" >&2 + exit 1 + fi + + ensure_env_value_migrates_old_default "${WORKER_ENV_FILE}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS" "3600" "600" + ensure_env_value "${WORKER_ENV_FILE}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS" "900" + ensure_env_value "${WORKER_ENV_FILE}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS" "1800" } parse_json_string_field() { @@ -964,6 +1007,7 @@ if [[ ! -f "${WORKER_ENV_FILE}" ]]; then else echo "[server-provision] 已存在 worker 环境文件,保留不覆盖: ${WORKER_ENV_FILE}" fi +ensure_worker_runtime_env_defaults if [[ ! -f "${CONTROLLER_ENV_FILE}" ]]; then echo "+ create ${CONTROLLER_ENV_FILE} from example" diff --git a/server-rs/crates/api-server/src/asset_billing.rs b/server-rs/crates/api-server/src/asset_billing.rs index 990bd3cd7..47899fcd6 100644 --- a/server-rs/crates/api-server/src/asset_billing.rs +++ b/server-rs/crates/api-server/src/asset_billing.rs @@ -65,24 +65,127 @@ where let points_consumed = consume_asset_operation_points(state, owner_user_id, asset_kind, asset_id, points_cost) .await?; + let mut refund_on_drop = AssetOperationRefundOnDrop::new( + state.clone(), + owner_user_id, + asset_kind, + asset_id, + points_cost, + points_consumed, + current_external_generation_job_id(), + ); match operation.await { - Ok(value) => Ok(value), + Ok(value) => { + refund_on_drop.disarm(); + Ok(value) + } Err(error) => { if points_consumed && should_refund_asset_operation_error(&error) { - refund_asset_operation_points( - state, - owner_user_id, - asset_kind, - asset_id, - points_cost, - ) - .await; + refund_on_drop.refund_now().await; + } else { + refund_on_drop.disarm(); } Err(error) } } } +struct AssetOperationRefundOnDrop { + state: AppState, + owner_user_id: String, + asset_kind: String, + asset_id: String, + points_cost: u64, + external_generation_job_id: Option, + active: bool, +} + +impl AssetOperationRefundOnDrop { + fn new( + state: AppState, + owner_user_id: &str, + asset_kind: &str, + asset_id: &str, + points_cost: u64, + points_consumed: bool, + external_generation_job_id: Option, + ) -> Self { + Self { + state, + owner_user_id: owner_user_id.to_string(), + asset_kind: asset_kind.to_string(), + asset_id: asset_id.to_string(), + points_cost, + external_generation_job_id, + active: points_consumed, + } + } + + fn disarm(&mut self) { + self.active = false; + } + + async fn refund_now(&mut self) { + if !self.active { + return; + } + refund_asset_operation_points_with_job_id( + self.state.clone(), + self.owner_user_id.clone(), + self.asset_kind.clone(), + self.asset_id.clone(), + self.points_cost, + self.external_generation_job_id.clone(), + ) + .await; + self.active = false; + } +} + +impl Drop for AssetOperationRefundOnDrop { + fn drop(&mut self) { + if !self.active { + return; + } + let state = self.state.clone(); + let owner_user_id = self.owner_user_id.clone(); + let asset_kind = self.asset_kind.clone(); + let asset_id = self.asset_id.clone(); + let points_cost = self.points_cost; + let external_generation_job_id = self.external_generation_job_id.clone(); + let Ok(handle) = tokio::runtime::Handle::try_current() else { + tracing::error!( + owner_user_id, + asset_kind, + asset_id, + external_generation_job_id, + points_cost, + "资产操作 future 被取消,但当前没有 Tokio runtime,无法异步补偿退款" + ); + return; + }; + handle.spawn(async move { + tracing::warn!( + owner_user_id, + asset_kind, + asset_id, + external_generation_job_id, + points_cost, + "资产操作 future 被取消,异步补偿退款" + ); + refund_asset_operation_points_with_job_id( + state, + owner_user_id, + asset_kind, + asset_id, + points_cost, + external_generation_job_id, + ) + .await; + }); + } +} + pub(crate) fn should_refund_asset_operation_error(error: &AppError) -> bool { let message = error.body_text(); // 中文注释:worker lease guard 拒绝表示当前进程已失去队列写权限; @@ -129,25 +232,24 @@ async fn consume_asset_operation_points( } } -/// 外部生成或发布 mutation 失败后补偿退款;立即退款失败会进入 outbox,避免覆盖原始业务错误。 -async fn refund_asset_operation_points( - state: &AppState, - owner_user_id: &str, - asset_kind: &str, - asset_id: &str, +async fn refund_asset_operation_points_with_job_id( + state: AppState, + owner_user_id: String, + asset_kind: String, + asset_id: String, points_cost: u64, + external_generation_job_id: Option, ) { let ledger_id = format!( "asset_operation_refund:{}:{}:{}", owner_user_id, asset_kind, asset_id ); let created_at_micros = current_utc_micros(); - let external_generation_job_id = current_external_generation_job_id(); let metadata_json = wallet_metadata_json(external_generation_job_id.as_deref()); if let Err(error) = state .spacetime_client() .refund_profile_wallet_points_with_metadata( - owner_user_id.to_string(), + owner_user_id.clone(), points_cost, ledger_id.clone(), created_at_micros, @@ -159,12 +261,12 @@ async fn refund_asset_operation_points( if let Some(outbox) = state.wallet_refund_outbox() { match outbox .enqueue(WalletRefundOutboxRecord { - owner_user_id: owner_user_id.to_string(), + owner_user_id: owner_user_id.clone(), amount: points_cost, ledger_id: ledger_id.clone(), created_at_micros, - asset_kind: asset_kind.to_string(), - asset_id: asset_id.to_string(), + asset_kind: asset_kind.clone(), + asset_id: asset_id.clone(), external_generation_job_id: external_generation_job_id.clone(), }) .await diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 8a93c51d7..ad6ffa74f 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -13,11 +13,16 @@ use platform_speech::{ const DEFAULT_INTERNAL_API_SECRET: &str = "genarrative-dev-internal-bridge"; const SPACETIME_LOCAL_CONFIG_FILE: &str = "spacetime.local.json"; const DEFAULT_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS: u64 = 2; +const DEFAULT_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS: u64 = 600; +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; const DEFAULT_EDITOR_BACKGROUND_REMOVAL_BASE_URL: &str = "http://58.87.105.82"; const DEFAULT_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS: u64 = 120_000; const DEFAULT_EDITOR_BGFILTER_BASE_URL: &str = "http://58.87.105.82/bgfilter"; -const DEFAULT_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS: u64 = 120_000; +const DEFAULT_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS: u64 = 45_000; +const DEFAULT_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD: u32 = 3; +const DEFAULT_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS: u64 = 300; // 集中管理 api-server 的启动配置,避免入口层直接散落环境变量解析逻辑。 #[derive(Clone, Debug)] @@ -32,6 +37,8 @@ pub struct AppConfig { pub external_generation_worker_concurrency: usize, pub external_generation_worker_poll_interval: Duration, pub external_generation_worker_lease: Duration, + pub external_generation_worker_job_timeout: Duration, + pub external_generation_worker_long_job_timeout: Duration, pub external_generation_controller_min_workers: usize, pub external_generation_controller_max_workers: usize, pub external_generation_controller_target_jobs_per_worker: usize, @@ -61,6 +68,8 @@ pub struct AppConfig { pub editor_bgfilter_base_url: String, pub editor_bgfilter_token: Option, pub editor_bgfilter_request_timeout_ms: u64, + pub editor_bgfilter_circuit_failure_threshold: u32, + pub editor_bgfilter_circuit_cooldown: Duration, pub image_editor_agent_sidebar_enabled: bool, pub log_filter: String, pub otel_enabled: bool, @@ -258,7 +267,15 @@ impl Default for AppConfig { external_generation_worker_id: default_external_generation_worker_id(), external_generation_worker_concurrency: 2, external_generation_worker_poll_interval: Duration::from_millis(2_000), - external_generation_worker_lease: Duration::from_secs(3_600), + external_generation_worker_lease: Duration::from_secs( + DEFAULT_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS, + ), + external_generation_worker_job_timeout: Duration::from_secs( + DEFAULT_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS, + ), + external_generation_worker_long_job_timeout: Duration::from_secs( + DEFAULT_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS, + ), external_generation_controller_min_workers: 1, external_generation_controller_max_workers: 8, external_generation_controller_target_jobs_per_worker: 2, @@ -292,6 +309,11 @@ impl Default for AppConfig { editor_bgfilter_base_url: DEFAULT_EDITOR_BGFILTER_BASE_URL.to_string(), editor_bgfilter_token: None, editor_bgfilter_request_timeout_ms: DEFAULT_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS, + editor_bgfilter_circuit_failure_threshold: + DEFAULT_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD, + editor_bgfilter_circuit_cooldown: Duration::from_secs( + DEFAULT_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS, + ), image_editor_agent_sidebar_enabled: false, log_filter: "info,tower_http=info".to_string(), otel_enabled: false, @@ -496,6 +518,16 @@ impl AppConfig { { config.editor_bgfilter_request_timeout_ms = timeout_ms; } + if let Some(threshold) = + read_first_u32_env(&["GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD"]) + { + config.editor_bgfilter_circuit_failure_threshold = threshold; + } + if let Some(cooldown_seconds) = read_first_duration_seconds_env(&[ + "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS", + ]) { + config.editor_bgfilter_circuit_cooldown = Duration::from_secs(cooldown_seconds.max(1)); + } if let Some(enabled) = read_first_bool_env(&["GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR"]) { @@ -538,6 +570,18 @@ impl AppConfig { ]) { config.external_generation_worker_lease = Duration::from_secs(lease_seconds.max(1)); } + if let Some(timeout_seconds) = read_first_duration_seconds_env(&[ + "GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS", + ]) { + config.external_generation_worker_job_timeout = + Duration::from_secs(timeout_seconds.max(1)); + } + if let Some(timeout_seconds) = read_first_duration_seconds_env(&[ + "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS", + ]) { + config.external_generation_worker_long_job_timeout = + Duration::from_secs(timeout_seconds.max(1)); + } if let Some(min_workers) = read_first_usize_env(&["GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_MIN_WORKERS"]) { @@ -1503,9 +1547,14 @@ mod tests { use super::{ AppConfig, DEFAULT_EDITOR_BACKGROUND_REMOVAL_BASE_URL, DEFAULT_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS, DEFAULT_EDITOR_BGFILTER_BASE_URL, - DEFAULT_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, - ExternalGenerationMode, LlmProvider, ProcessRole, parse_bool, - parse_external_generation_mode, parse_process_role, + DEFAULT_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS, + DEFAULT_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD, + DEFAULT_EDITOR_BGFILTER_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_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, ExternalGenerationMode, LlmProvider, + ProcessRole, parse_bool, parse_external_generation_mode, parse_process_role, }; use std::sync::{Mutex, OnceLock}; @@ -1536,7 +1585,27 @@ mod tests { config.editor_bgfilter_request_timeout_ms, DEFAULT_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS ); + assert_eq!( + config.editor_bgfilter_circuit_failure_threshold, + DEFAULT_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD + ); + assert_eq!( + config.editor_bgfilter_circuit_cooldown.as_secs(), + DEFAULT_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS + ); assert!(config.editor_bgfilter_token.is_none()); + assert_eq!( + config.external_generation_worker_lease.as_secs(), + DEFAULT_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS + ); + assert_eq!( + config.external_generation_worker_job_timeout.as_secs(), + DEFAULT_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS + ); + assert_eq!( + config.external_generation_worker_long_job_timeout.as_secs(), + DEFAULT_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS + ); assert!(config.ark_character_video_base_url.is_empty()); assert_eq!(config.hyper3d_base_url, "https://api.hyper3d.com/api/v2"); assert!(config.ark_character_video_model.is_empty()); @@ -2263,6 +2332,87 @@ mod tests { } } + #[test] + fn from_env_reads_editor_bgfilter_circuit_settings() { + let _guard = ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("env lock should not poison"); + + unsafe { + std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD"); + std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS"); + std::env::set_var("GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD", "4"); + std::env::set_var("GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS", "90"); + } + + let config = AppConfig::from_env(); + assert_eq!(config.editor_bgfilter_circuit_failure_threshold, 4); + assert_eq!(config.editor_bgfilter_circuit_cooldown.as_secs(), 90); + + unsafe { + std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD"); + std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS"); + } + } + + #[test] + fn from_env_allows_zero_editor_bgfilter_circuit_threshold() { + let _guard = ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("env lock should not poison"); + + unsafe { + std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD"); + std::env::set_var("GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD", "0"); + } + + let config = AppConfig::from_env(); + assert_eq!(config.editor_bgfilter_circuit_failure_threshold, 0); + + unsafe { + std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD"); + } + } + + #[test] + fn from_env_reads_external_generation_worker_timeout_settings() { + let _guard = ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("env lock should not poison"); + + unsafe { + std::env::remove_var("GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS"); + std::env::remove_var("GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS"); + std::env::remove_var("GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS"); + std::env::set_var("GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS", "5m"); + std::env::set_var( + "GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS", + "12m", + ); + std::env::set_var( + "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS", + "30m", + ); + } + + let config = AppConfig::from_env(); + assert_eq!(config.external_generation_worker_lease.as_secs(), 300); + assert_eq!(config.external_generation_worker_job_timeout.as_secs(), 720); + assert_eq!( + config.external_generation_worker_long_job_timeout.as_secs(), + 1_800 + ); + + unsafe { + std::env::remove_var("GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS"); + std::env::remove_var("GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS"); + std::env::remove_var("GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS"); + } + } + #[test] fn from_env_reads_image_editor_agent_sidebar_runtime_flag() { let _guard = ENV_LOCK diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 7f080430d..81253a6e8 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -1,4 +1,10 @@ -use std::{borrow::Cow, collections::BTreeMap, io::Cursor, time::Instant}; +use std::{ + borrow::Cow, + collections::BTreeMap, + io::Cursor, + sync::{Mutex, OnceLock}, + time::{Duration, Instant}, +}; use axum::{ Json, @@ -113,6 +119,14 @@ const EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG: &str = "anime-seg"; const EDITOR_PUBLICATION_MATERIAL_ASSET_KIND: &str = "editor_publication_material"; const EDITOR_LEGACY_INLINE_IMAGE_ASSET_KIND: &str = "editor_legacy_inline_image"; +static EDITOR_BGFILTER_CIRCUIT: OnceLock> = OnceLock::new(); + +#[derive(Clone, Copy, Debug, Default)] +struct EditorBgfilterCircuitState { + consecutive_failures: u32, + open_until: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EditorProjectCreateRequest { @@ -2205,6 +2219,17 @@ async fn remove_editor_generated_screen_background_with_bgfilter( screen_color: EditorScreenBackgroundColor, seg_model: &str, ) -> Result { + if let Some(remaining) = editor_bgfilter_circuit_open_remaining(state) { + tracing::warn!( + provider = "bgfilter", + screen_color = screen_color.hex, + seg_model, + cooldown_ms = remaining.as_millis() as u64, + "editor_bgfilter_circuit_open_fallback_to_local_screen_background_removal" + ); + return remove_editor_generated_green_screen_background(image, screen_color); + } + match request_editor_generated_screen_background_with_bgfilter( state, image, @@ -2213,8 +2238,12 @@ async fn remove_editor_generated_screen_background_with_bgfilter( ) .await { - Ok(image) => Ok(image), + Ok(image) => { + record_editor_bgfilter_success(); + Ok(image) + } Err(error) => { + record_editor_bgfilter_failure(state); tracing::warn!( provider = "bgfilter", screen_color = screen_color.hex, @@ -2228,6 +2257,57 @@ async fn remove_editor_generated_screen_background_with_bgfilter( } } +fn editor_bgfilter_circuit_open_remaining(state: &AppState) -> Option { + let threshold = state.config.editor_bgfilter_circuit_failure_threshold; + if threshold == 0 { + return None; + } + + let mut circuit = editor_bgfilter_circuit_state().lock().ok()?; + let open_until = circuit.open_until?; + let now = Instant::now(); + if open_until > now { + return Some(open_until.duration_since(now)); + } + + circuit.open_until = None; + circuit.consecutive_failures = 0; + None +} + +fn record_editor_bgfilter_success() { + if let Ok(mut circuit) = editor_bgfilter_circuit_state().lock() { + circuit.consecutive_failures = 0; + circuit.open_until = None; + } +} + +fn record_editor_bgfilter_failure(state: &AppState) { + let threshold = state.config.editor_bgfilter_circuit_failure_threshold; + if threshold == 0 { + return; + } + + let cooldown = state.config.editor_bgfilter_circuit_cooldown; + if let Ok(mut circuit) = editor_bgfilter_circuit_state().lock() { + circuit.consecutive_failures = circuit.consecutive_failures.saturating_add(1); + if circuit.consecutive_failures >= threshold { + circuit.open_until = Some(Instant::now() + cooldown); + } + } +} + +fn editor_bgfilter_circuit_state() -> &'static Mutex { + EDITOR_BGFILTER_CIRCUIT.get_or_init(|| Mutex::new(EditorBgfilterCircuitState::default())) +} + +#[cfg(test)] +fn reset_editor_bgfilter_circuit_for_tests() { + if let Ok(mut circuit) = editor_bgfilter_circuit_state().lock() { + *circuit = EditorBgfilterCircuitState::default(); + } +} + async fn request_editor_generated_screen_background_with_bgfilter( state: &AppState, image: &DownloadedOpenAiImage, @@ -7141,6 +7221,29 @@ mod tests { assert!(parse_editor_bgfilter_seg_model(Some("u2net")).is_err()); } + #[test] + fn editor_bgfilter_circuit_opens_after_consecutive_failures_and_resets_on_success() { + reset_editor_bgfilter_circuit_for_tests(); + let state = AppState::new(AppConfig { + editor_bgfilter_circuit_failure_threshold: 2, + editor_bgfilter_circuit_cooldown: Duration::from_secs(60), + ..AppConfig::default() + }) + .expect("state should build"); + + assert!(editor_bgfilter_circuit_open_remaining(&state).is_none()); + + record_editor_bgfilter_failure(&state); + assert!(editor_bgfilter_circuit_open_remaining(&state).is_none()); + + record_editor_bgfilter_failure(&state); + assert!(editor_bgfilter_circuit_open_remaining(&state).is_some()); + + record_editor_bgfilter_success(); + assert!(editor_bgfilter_circuit_open_remaining(&state).is_none()); + reset_editor_bgfilter_circuit_for_tests(); + } + #[test] fn editor_image_and_spritesheet_generation_are_billable() { let source = include_str!("editor_project.rs"); diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index 491da8e78..d3ca68e0b 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -19,6 +19,7 @@ use crate::{ character_animation_assets::{ generate_editor_character_animation_for_owner, generate_editor_video_for_owner, }, + config::AppConfig, editor_generation_queue::{ EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND, EDITOR_BACKGROUND_REMOVAL_JOB_KIND, EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND, @@ -77,6 +78,14 @@ pub(crate) async fn run_external_generation_worker(state: AppState) -> Result<() concurrency, poll_interval_ms = poll_interval.as_millis(), lease_seconds = lease.as_secs(), + job_timeout_seconds = state + .config + .external_generation_worker_job_timeout + .as_secs(), + long_job_timeout_seconds = state + .config + .external_generation_worker_long_job_timeout + .as_secs(), "external generation worker 已启动" ); @@ -296,6 +305,7 @@ async fn process_external_generation_job( job: ExternalGenerationJobRecord, ) -> Result<(), String> { let heartbeat_interval = external_generation_worker_heartbeat_interval(lease); + let job_timeout = external_generation_worker_job_timeout(&state.config, job.job_kind.as_str()); let work = with_external_generation_billing_task_id( job.job_id.clone(), process_external_generation_job_once(state.clone(), worker_id.clone(), job.clone()), @@ -303,11 +313,24 @@ async fn process_external_generation_job( tokio::pin!(work); let heartbeat = sleep(heartbeat_interval); tokio::pin!(heartbeat); + let job_deadline = sleep(job_timeout); + tokio::pin!(job_deadline); loop { tokio::select! { biased; result = &mut work => return result, + _ = &mut job_deadline => { + let message = external_generation_worker_timeout_message(&job, job_timeout); + warn!( + job_id = %job.job_id, + job_kind = %job.job_kind, + timeout_seconds = job_timeout.as_secs(), + "external generation worker 任务超过执行预算,停止当前尝试并释放 worker 槽位" + ); + fail_job(&state, &worker_id, &job, message.clone()).await?; + return Err(message); + } _ = &mut heartbeat => { renew_job_lease(&state, &worker_id, &job, lease).await?; heartbeat.as_mut().reset(Instant::now() + heartbeat_interval); @@ -1075,6 +1098,27 @@ fn external_generation_worker_heartbeat_interval(lease: Duration) -> Duration { Duration::from_millis(heartbeat_millis) } +fn external_generation_worker_job_timeout(config: &AppConfig, job_kind: &str) -> Duration { + match job_kind { + EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND | EDITOR_VIDEO_GENERATION_JOB_KIND => { + config.external_generation_worker_long_job_timeout + } + _ => config.external_generation_worker_job_timeout, + } +} + +fn external_generation_worker_timeout_message( + job: &ExternalGenerationJobRecord, + timeout: Duration, +) -> String { + format!( + "外部生成任务 {}({})超过 worker 执行预算 {} 秒,已停止当前尝试", + job.job_id, + job.job_kind, + timeout.as_secs() + ) +} + fn current_utc_micros() -> i64 { offset_datetime_to_unix_micros(time::OffsetDateTime::now_utc()) } @@ -1133,6 +1177,42 @@ mod tests { assert_eq!(message, "VECTOR_ENGINE_API_KEY 未配置"); } + #[test] + fn worker_job_timeout_uses_long_budget_for_video_jobs() { + let config = AppConfig { + external_generation_worker_job_timeout: Duration::from_secs(60), + external_generation_worker_long_job_timeout: Duration::from_secs(600), + ..AppConfig::default() + }; + + assert_eq!( + external_generation_worker_job_timeout(&config, EDITOR_IMAGE_GENERATION_JOB_KIND), + Duration::from_secs(60) + ); + assert_eq!( + external_generation_worker_job_timeout( + &config, + EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND + ), + Duration::from_secs(600) + ); + assert_eq!( + external_generation_worker_job_timeout(&config, EDITOR_VIDEO_GENERATION_JOB_KIND), + Duration::from_secs(600) + ); + } + + #[test] + fn worker_timeout_message_mentions_job_and_budget() { + let job = external_generation_job_record_fixture(Some("lease-1")); + + let message = external_generation_worker_timeout_message(&job, Duration::from_secs(90)); + + assert!(message.contains("extgen-1")); + assert!(message.contains(PUZZLE_GENERATE_IMAGES_JOB_KIND)); + assert!(message.contains("90 秒")); + } + fn external_generation_job_record_fixture( lease_token: Option<&str>, ) -> ExternalGenerationJobRecord {