From 41f578f0acb8f57eb53dde3711d02de89b6e3dac Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 20 Jul 2026 13:02:59 +0000 Subject: [PATCH 01/27] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E5=8A=A8=E4=BD=9C=E6=8A=A0=E5=9B=BE=E5=89=8D=E9=80=8F=E6=98=8E?= =?UTF-8?q?=E8=A1=A5=E8=BE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 动作视频帧在抠图前改为 RGB8 等比缩放且不补边 抠图完成后保留 RGBA8 最终尺寸透明补边 补充像素回归测试并同步权威文档与长期决策 --- .../shared-memory/decision-log.md | 11 ++ ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 4 + ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 3 +- ...辑器】画板角色形象生成入口设计-2026-06-15.md | 4 + .../src/character_animation_assets.rs | 176 +++++++++++++++++- 5 files changed, 190 insertions(+), 8 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 57a8898bf..7cc7d5ab0 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,17 @@ --- +## 2026-07-20 角色动作抠图前禁止透明 padding + +- 背景:图片画布角色动作此前在 BgFilter 前复用最终帧 finalizer,把 FFmpeg 抽帧先转成目标尺寸 RGBA 画布并用透明黑像素补边;透明区域进入 BgFilter、阿里云和本地键色共同读取的 OSS 源帧后,会干扰主体边缘判断并降低抠图质量。 +- 决策:仅图片画布角色动作链路在抠图前把 FFmpeg 帧转为 RGB8,按最终帧宽高的 contain 比例使用 `Triangle` 缩放到内容尺寸,不创建最终目标画布、不引入 Alpha、不插入 padding;该 RGB8 PNG owned 上传 OSS 后由三段抠图链共享。抠图返回后继续复用原最终帧 finalizer,转为 RGBA8、居中放入最终目标尺寸,并以 `RGBA(0,0,0,0)` 补边。`560×752 → 323×480` 的固定验收结果为 `323×434 RGB8` 抠图输入和上下各 `23px` 透明补边的 `323×480 RGBA8` 最终帧。 +- 边界:不修改 `contain_rgba_image`、最终透明帧格式、BgFilter 请求、OSS 上传与签名、抽帧数量和采样时间,也不改变旧 `/api/assets/character-animation/*` 动作发布链路;因为降级链复用同一个 object key,阿里云和本地键色同样读取新的无补边 RGB8 源帧。 +- 影响范围:`server-rs/crates/api-server/src/character_animation_assets.rs`、后端融合架构、角色动作专题和图片画布当前接入方案;不涉及 DTO、前端接口、SpacetimeDB schema 或运维配置。 +- 验证方式:像素测试断言 `560×752 RGB8 → 323×434 RGB8` 且无 Alpha/补边,并断言抠图结果最终成为上下各 `23px` 透明补边的 `323×480 RGBA8`;运行 `cargo test -p api-server character_animation --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/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +--- + ## 2026-07-20 角色动画帧 OSS 请求使用专用连接池、并发保护与结构化重试 - 背景:角色动作逐帧流水线会同时发起源帧 PUT、透明帧 PUT 和最终帧 HEAD;原路径每次请求新建 `reqwest::Client`,且 OSS 请求错误丢失 HTTP 状态和 timeout/connect/transport 分类,多个动画任务叠加时无法在进程级限制 OSS 在途请求,也无法安全区分 PUT 与 HEAD 的失败。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 478583ba6..4cfbf2236 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -31,6 +31,10 @@ - 画布底部工具栏 / 面板 Dock 提供“画布 Agent”入口。点击后打开右侧独立 Agent 对话面板;桌面端为右侧窄面板,移动端占满可用宽度。该面板只与右上角任务侧栏互斥;素材 / 图层侧栏允许与 Agent 同时展开,切换左侧栏不得关闭 Agent。Agent 面板不得在当前画布内容下方追加内联内容,也不默认展示大段功能说明文案。 - 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标或原图右侧固定偏移。当前覆盖入口包括 `生成图片`、`生成规范`、`生成角色形象`、`生成图标素材`、`生成视频`、`生成UI设计图` 和 `生成角色动作`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变;图片快速编辑不属于新建占位入口,提交后覆盖源图。 +### 角色动作帧抠图像素边界 + +- 图片画布角色动作的 FFmpeg 抽帧在上传 OSS 前转为 RGB8,并按最终帧宽高 contain 到内容尺寸;抠图前不创建最终尺寸画布、不引入 Alpha 通道、不增加 padding。同一个无补边 object key 供 `BgFilter → 阿里云通用抠图 → 本地键色` 三段链路使用。抠图完成后才转为最终目标尺寸 RGBA8,并以 `RGBA(0,0,0,0)` 居中补边。`560×752 → 323×480` 的验收样例中,抠图输入为 `323×434 RGB8 PNG`,最终输出为上下各 `23px` 透明补边的 `323×480 RGBA8 PNG`。该规则只作用于图片画布角色动作输入准备,不改变旧动作发布、采样、BgFilter 请求或 OSS 流程。 + ## 交互规则 - `适合视图` 的正式语义为“显示画布所有可见元素”,不再回到固定 `x/y/scale`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index d9d38b5e9..3935de1c8 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -2,7 +2,7 @@ > 2026-07-18 状态更新:旧创作入口、全部模板业务 API/worker/运行态及 SpacetimeDB 业务逻辑已退役。本文逐玩法路由、流程和 DTO 章节仅作为历史设计记录;相关持久化表仍按原结构作为最小 schema 数据壳编译,当前编译与运行边界以 `server-rs/Cargo.toml`、`server-rs/crates/api-server/src/app.rs` 和 `docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md` 为准。 -更新时间:`2026-07-18` +更新时间:`2026-07-20` ## 后端主线 @@ -241,6 +241,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` 当成上游业务错误。 - 抠图输入以私有 OSS 作为内存生命周期边界:生成原图和角色动作抽取帧上传时消费图片字节所有权,上传完成后不保留原图缓冲;手动去背景直接解析并校验已有 OSS object key,不下载原图。BgFilter 必须为 object key 签发 600 秒 GET URL 并通过 multipart `image_url` 提交,不用 `file` 重传;flat 链路进入阿里云 fallback 时由 `platform-matting` URL 接口单独下载并上传 `AuthorizeFileUpload` 临时对象,在推理前释放下载缓冲,继续 fallback 到本地键色时再单独下载一次原图,本地产出后释放本次原图下载缓冲。签名 URL 不得写入日志、审计或持久化。 +- 角色动作抠图输入像素边界:仅图片画布角色动作链路在 FFmpeg 抽帧后、源帧上传 OSS 前,把帧解码为 RGB8,并按最终 `frameWidth × frameHeight` 的 contain 比例使用 `Triangle` 只缩放到内容尺寸;该阶段不得创建最终目标尺寸画布、不得引入 Alpha 通道,也不得插入任何 padding。BgFilter、阿里云通用抠图和本地键色降级共享这个无补边源帧 object key。抠图返回后才统一转为 RGBA8,按相同比例居中放入最终目标尺寸画布,并用 `RGBA(0,0,0,0)` 补齐透明 padding。以 `560×752 → 323×480` 为例,抠图输入固定为无 Alpha、无补边的 `323×434 RGB8 PNG`,最终输出为上下各 `23px` 透明补边的 `323×480 RGBA8 PNG`。旧 `/api/assets/character-animation/*` 动作发布链路继续保留原有帧 finalizer,不适用该输入规则。 - 阿里云通用抠图的非上海地域输入不得使用 `viapiutils/GetOssStsToken`、固定 `viapi-customer-temp` 或 OSS V1 PUT。`platform-matting` 必须按官方新版 SDK Advance 协议调用 `AuthorizeFileUpload`,使用动态返回的单对象 Policy 执行 multipart POST,再把临时上海 OSS URL 交给 `SegmentCommonImage`;输入归一化、结果下载与原尺寸 Alpha 回贴继续留在同一适配器内。该协议仍上传图片字节,不等同于阿里云服务端直接抓取任意公网 URL,也不改变上层 BgFilter → 阿里云 → 本地降级顺序。 - 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 与角色形象生成、图标 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`,默认请求超时为 `180000ms`(BgFilter 当前为 CPU 推理,单次抠图较慢,必须留足超时);旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 BgFilter token 的兼容回退别名,原手动去背景专用 base URL / timeout 配置已经删除。手动去背景固定传 `image_url`、`background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传 `file` 或 `screen_color`;该 API 接收 `objectKey`、`resourceId` 或 `assetId` 候选引用;BFF 入队前统一拒绝 `data:` / `blob:`;worker 不重复入口校验,只调用 `resolve_editor_reference_object_key_for_owner`,底层 resolver 在解析引用前拒绝内联媒体,并在签名前完成登记状态和 owner 校验;直接签发 OSS URL,不下载原图。标准纯色背景四条链路固定传 `background_mode=flat`,并显式传 `image_url`、`screen_color=`、`seg_model=` 和 `cross_check=`,其中角色形象生成和角色动作逐帧去背传 `cross_check=on`,图标 spritesheet 生成和 UI 设计图素材提取传 `cross_check=off`。前端用户路径不展示抠图模型、模式或 cross-check,固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`;这些参数只属于后端内部供应商策略,不进入前端或外部 OpenAPI。标准纯色背景 BgFilter 调用失败,或连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`(默认 `3`)并在 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`(默认 `300`)内打开熔断时,继续复用“阿里云通用抠图 → 本地 `editor_green_screen` 键色扣除”兜底链,熔断期不得直接退化到本地兜底。角色动作视频生成的背景色已与生图链路统一:`screenColor=auto` 时由视觉 LLM(`gpt-5-mini`,Responses 协议、low 推理档)读源角色图自动决策,并经硬过滤器剔除与前景 / 皮肤撞色的候选,手动 hex 则尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色;抽帧后每帧先上传私有 OSS 并释放原帧缓冲,再以该 object key 的签名 URL 固定使用 `seg_model=birefnet`、`cross_check=on` 进入上述三段式链路。阿里云通用抠图配置为 `GENARRATIVE_ALIYUN_MATTING_ENABLED`、`GENARRATIVE_ALIYUN_MATTING_ENDPOINT`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET` 和 `GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS`;未配置专用 AK/SK 时可复用 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`,默认 endpoint 为 `imageseg.cn-shanghai.aliyuncs.com`。标准纯色背景链路中,BgFilter 调用失败和阿里云抠图链路已开始后的失败(包括源 OSS GET 成功后的解码、尺寸校验和归一化失败)都写入 `external_api_call_failure` 审计;真正开始外部调用前的本地预检不写该审计,并在 `failureStage` 中保留 `source_decode`、`source_validate` 等阶段。 - BgFilter 连接复用、重试与动作帧流水线:api-server 必须在 `AppState` 复用同一个 BgFilter HTTP Client 及 keep-alive 连接池。`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 是所有路径的基准请求超时;角色动作逐帧 BgFilter 的每一次 HTTP attempt 使用“基准超时 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧分别为 `244000 / 260000 / 276000ms`,角色形象单图、图标、UI 和手动去背景仍使用基准值。api-server 只在共享 Client 的单次 RequestBuilder 上覆盖该值;它覆盖从请求发起到响应体读取完成,是单次 attempt 的总 deadline,不是整批帧或 worker job 超时,重试会重新签发 600 秒 OSS URL 并获得同样的 request deadline,整项任务仍受 worker long-job 预算约束。flat 与 complex 请求首次失败后都立即重试 `1` 次;标准纯色背景 flat 请求第二次仍失败才进入“阿里云通用抠图 → 本地键色”降级链,手动 complex 请求第二次仍失败则返回最终错误,不接入依赖纯色键值的降级链,也不改变 flat 路径的熔断状态。角色动作全部 `32 / 40 / 48` 帧按“单帧绿幕源图 owned 上传 OSS 并释放原帧 → 以签名 URL 调 BgFilter/按 object key 降级 → 透明帧落 OSS”独立流水化,使用覆盖本次全部帧的无序在途集合连续发射;不限制 BgFilter、阿里云或本地处理,但角色动画源帧 PUT、透明帧 PUT 和最终帧 HEAD 统一复用 `AppState` 内初始化一次的 OSS HTTP Client(连接池参数为 connect 30 秒、request 60 秒、idle 300 秒、每 host 8 个 idle 连接、TCP keepalive 60 秒),并受进程级 8 路 OSS semaphore 限制。每个 OSS 网络 attempt 单独获取 permit,退避期间释放;PUT/HEAD 动画帧请求最多 3 次(250ms、500ms 退避),只重试无 HTTP 响应的传输错误、timeout、OSS PutObject 的 `400 + RequestTimeout`、PUT `400` 错误体读取失败(未解析出 `Code`,按 timeout/transport 归类)、408、429 和 5xx。动作帧 PUT 只在 400 响应中有界读取最多 16 KiB OSS 错误 XML,并保留 `Code` 与响应头优先的 `x-oss-request-id`;错误体读取超时/断流时保留已读字节,已解析出的 `Code` 优先生效,未解析出 `Code` 则按 timeout/transport 归类重试;除 `RequestTimeout` 与该错误体读取失败情形外的其他 400、401/403/404、配置、URL/签名和空请求体错误不重试。返回结果携带原始帧序并在收口时排序。任一帧最终失败时必须先排空全部已启动 Future,再让整个动作任务失败退款,不能发布缺帧动画。 diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index 057f22910..f9a920fe9 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -2,6 +2,8 @@ 日期:`2026-06-15` +更新时间:`2026-07-20` + ## 背景 图片画布编辑器已有普通图片生成与“生成规范”能力。本次新增“生成角色形象”入口,用于在同一画布内生成标注为“角色”的单张角色形象图片,并支持绑定角色规范与常规参考图。 @@ -162,7 +164,9 @@ - 视频生成完成后,后端先把带纯色背景的预览视频登记为 OSS 私有对象、`asset_object`、项目资源和账号素材,再按面板选择抽取对应帧数:`32`、`40` 或 `48`。未传 `assetFolderId` 时进入默认“项目”素材文件夹;后续抽帧或抠图失败不能抹掉这份已经生成成功的可恢复视频。 - 抽帧采样必须按目标帧数预留视频尾部安全步长,例如 `32帧·4秒` 最后一帧采 `3.875s`,避免 FFmpeg 在尾点附近返回成功但输出 `0` 帧。 +- 图片画布角色动作的 FFmpeg 原始帧在上传 OSS 前必须转为 RGB8,并按最终帧宽高的 contain 比例使用 `Triangle` 只缩放到内容尺寸;不得提前创建最终目标尺寸 RGBA 画布,不得引入 Alpha 通道或透明 padding。以 `560×752` 原始帧、`323×480` 最终目标为例,上传给抠图链路的源帧必须是 `323×434 RGB8 PNG`,没有上下补边。 - 每帧绿幕源图字节由上传 owned 消费(`frame.bytes` 移入 put,上传完成后释放原帧缓冲,不克隆保留);后续只持 object key。每次 BgFilter attempt 重新签发 600 秒 GET URL,multipart 仅传 `image_url`(加 `background_mode=flat`、`seg_model=birefnet`、`cross_check=on` 与同一次生成已选定的 `screenColor`),不传 `file`。BgFilter 主路径不重新下载原帧;失败后走 `阿里云通用抠图(按签名 URL 单独下载)→ 本地 editor_green_screen(再按 object key 独立下载一次并在产出后释放)`。BgFilter 每一次 HTTP attempt 的 timeout 使用“`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 基准值 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧分别为 `244000 / 260000 / 276000ms`;首次失败后重试 `1` 次。 +- BgFilter、阿里云或本地键色返回透明结果后,后端继续通过现有最终帧 finalizer 转为 RGBA8,按宽高比居中放入最终目标尺寸,并使用 `RGBA(0,0,0,0)` 补边。上述样例最终输出必须为 `323×480 RGBA8 PNG`,顶部和底部各 `23px` 透明 padding,内容区域完整保留抠图结果。 - 全部 `32 / 40 / 48` 帧以覆盖本次所有帧的无序在途集合连续发射,允许乱序完成并最终按 `frameIndex` 排序;任一帧最终失败时先排空全部已启动 Future,再让整项任务失败退款,不发布缺帧动画。 - 抽帧结果写入 OSS,并返回帧路径、帧尺寸、帧数、fps、预览视频路径、模型、价格和实际 prompt。 - 画板前端回填角色动作结果时,必须以 `frames[0].imageSrc` 创建 `mediaType: "image-sequence"`、`assetKind: "character-animation"` 图层,并把完整 `frames` 保存为图层 `imageSequenceFrames`;`previewVideoPath` 只保留为上游预览视频来源,不作为画布主媒体。 diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index 83b3e074c..0400a0ef8 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -2320,6 +2320,7 @@ async fn extract_and_persist_editor_character_animation_frames( let plan = AnimationFrameExtractionPlan { frame_count: request.frame_count, apply_chroma_key: false, + prepare_for_bgfilter_input: true, sample_start_ratio: 0.0, sample_end_ratio: 1.0, }; @@ -4077,6 +4078,7 @@ fn normalize_animation_frame_extraction_plan( AnimationFrameExtractionPlan { frame_count, apply_chroma_key, + prepare_for_bgfilter_input: false, sample_start_ratio, sample_end_ratio, } @@ -4149,13 +4151,23 @@ async fn extract_animation_frames_from_preview_video( "message": format!("读取动作抽帧结果失败:{error}"), })) })?; - finalized_frames.push(finalize_animation_frame_payload( - frame_bytes.as_slice(), - "image/png", - frame_width, - frame_height, - plan.apply_chroma_key, - )?); + let finalized_frame = if plan.prepare_for_bgfilter_input { + prepare_editor_character_animation_bgfilter_input( + frame_bytes.as_slice(), + "image/png", + frame_width, + frame_height, + )? + } else { + finalize_animation_frame_payload( + frame_bytes.as_slice(), + "image/png", + frame_width, + frame_height, + plan.apply_chroma_key, + )? + }; + finalized_frames.push(finalized_frame); } Ok::<_, AppError>(finalized_frames) @@ -4368,6 +4380,67 @@ fn run_process_with_timeout( } } +fn prepare_editor_character_animation_bgfilter_input( + source: &[u8], + mime_type: &str, + target_width: u32, + target_height: u32, +) -> Result { + let image_format = match mime_type { + "image/png" => Some(ImageFormat::Png), + "image/jpeg" | "image/jpg" => Some(ImageFormat::Jpeg), + "image/webp" => Some(ImageFormat::WebP), + _ => None, + }; + let image = match image_format { + Some(format) => image::load_from_memory_with_format(source, format), + None => image::load_from_memory(source), + } + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "character-animation", + "message": format!("解析 BgFilter 输入动作帧图片失败:{error}"), + })) + })? + .to_rgb8(); + + let target_width = target_width.max(1); + let target_height = target_height.max(1); + let source_width = image.width().max(1); + let source_height = image.height().max(1); + let scale = (target_width as f32 / source_width as f32) + .min(target_height as f32 / source_height as f32); + let draw_width = ((source_width as f32 * scale).round() as u32) + .max(1) + .min(target_width); + let draw_height = ((source_height as f32 * scale).round() as u32) + .max(1) + .min(target_height); + let resized = image::imageops::resize(&image, draw_width, draw_height, FilterType::Triangle); + + let mut encoded = Vec::new(); + let encoder = PngEncoder::new(&mut encoded); + encoder + .write_image( + resized.as_raw(), + resized.width(), + resized.height(), + ColorType::Rgb8.into(), + ) + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "character-animation", + "message": format!("编码 BgFilter 输入动作帧 PNG 失败:{error}"), + })) + })?; + + Ok(FinalizedAnimationFrame { + bytes: encoded, + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }) +} + fn finalize_animation_frame_payload( source: &[u8], mime_type: &str, @@ -5759,6 +5832,7 @@ struct BackendFrameExtractionSettings { struct AnimationFrameExtractionPlan { frame_count: u32, apply_chroma_key: bool, + prepare_for_bgfilter_input: bool, sample_start_ratio: f32, sample_end_ratio: f32, } @@ -6267,6 +6341,77 @@ mod tests { ); } + #[test] + fn editor_character_animation_bgfilter_input_is_rgb_without_padding() { + let source = image::RgbImage::from_pixel(560, 752, image::Rgb([17, 99, 201])); + let mut source_png = Vec::new(); + PngEncoder::new(&mut source_png) + .write_image( + source.as_raw(), + source.width(), + source.height(), + ColorType::Rgb8.into(), + ) + .expect("source RGB frame should encode"); + + let prepared = prepare_editor_character_animation_bgfilter_input( + source_png.as_slice(), + "image/png", + 323, + 480, + ) + .expect("BgFilter input should be prepared"); + let decoded = + image::load_from_memory_with_format(prepared.bytes.as_slice(), ImageFormat::Png) + .expect("prepared BgFilter input should decode"); + + assert_eq!(prepared.mime_type, "image/png"); + assert_eq!(prepared.extension, "png"); + assert_eq!(decoded.width(), 323); + assert_eq!(decoded.height(), 434); + assert_eq!(decoded.color(), ColorType::Rgb8); + assert!( + decoded + .to_rgb8() + .pixels() + .all(|pixel| pixel.0 == [17, 99, 201]) + ); + } + + #[test] + fn editor_character_animation_final_frame_adds_transparent_vertical_padding() { + let source = RgbaImage::from_pixel(323, 434, Rgba([31, 127, 223, 191])); + let mut source_png = Vec::new(); + PngEncoder::new(&mut source_png) + .write_image( + source.as_raw(), + source.width(), + source.height(), + ColorType::Rgba8.into(), + ) + .expect("source RGBA frame should encode"); + + let finalized = + finalize_animation_frame_payload(source_png.as_slice(), "image/png", 323, 480, false) + .expect("final transparent frame should be finalized"); + let decoded = + image::load_from_memory_with_format(finalized.bytes.as_slice(), ImageFormat::Png) + .expect("final transparent frame should decode"); + + assert_eq!(decoded.width(), 323); + assert_eq!(decoded.height(), 480); + assert_eq!(decoded.color(), ColorType::Rgba8); + let output = decoded.to_rgba8(); + for (x, y, pixel) in output.enumerate_pixels() { + let expected = if (23..457).contains(&y) { + [31, 127, 223, 191] + } else { + [0, 0, 0, 0] + }; + assert_eq!(pixel.0, expected, "unexpected pixel at ({x}, {y})"); + } + } + #[test] fn editor_character_animation_frames_use_three_stage_matting_fallback() { let source = include_str!("character_animation_assets.rs"); @@ -6276,6 +6421,7 @@ mod tests { "async fn publish_animation_set", &[ "apply_chroma_key: false", + "prepare_for_bgfilter_input: true", "editor_character_animation_bgfilter_request_timeout_ms", "state.config.editor_bgfilter_request_timeout_ms", "process_and_persist_editor_character_animation_frame", @@ -6286,6 +6432,22 @@ mod tests { "frame_errors.sort_by_key", ], ); + assert_function_contains( + source, + "fn normalize_animation_frame_extraction_plan", + "fn normalize_sample_ratio", + &["apply_chroma_key", "prepare_for_bgfilter_input: false"], + ); + assert_function_contains_in_order( + source, + "async fn extract_animation_frames_from_preview_video", + "fn create_animation_temp_dir", + &[ + "plan.prepare_for_bgfilter_input", + "prepare_editor_character_animation_bgfilter_input", + "finalize_animation_frame_payload", + ], + ); assert_function_contains_in_order( source, "async fn process_and_persist_editor_character_animation_frame", -- 2.52.0 From 75184d07714be40a470eb5257b84c64ce6247977 Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 20 Jul 2026 13:37:55 +0000 Subject: [PATCH 02/27] =?UTF-8?q?=E5=9B=BA=E5=AE=9A=E6=8A=BD=E5=B8=A7?= =?UTF-8?q?=E5=83=8F=E7=B4=A0=E6=A0=BC=E5=BC=8F=E5=B9=B6=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E5=8A=A8=E4=BD=9C=E5=B8=A7=E5=9B=BE=E5=83=8F=E5=A4=84=E7=90=86?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ffmpeg 抽帧显式指定 -pix_fmt rgb24,消除 to_rgb8 截断 alpha 的隐患 抽出 compute_contain_dimensions 与 image_format_from_mime 供两条帧路径共用 尺寸已匹配时跳过无效的 Triangle 重采样 Co-Authored-By: Claude Fable 5 --- .../src/character_animation_assets.rs | 79 +++++++++++-------- 1 file changed, 45 insertions(+), 34 deletions(-) diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index 0400a0ef8..370d82ca8 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -4287,6 +4287,8 @@ fn extract_video_frame_to_png( input_path.to_string_lossy().as_ref(), "-frames:v", "1", + "-pix_fmt", + "rgb24", "-f", "image2", output_path.to_string_lossy().as_ref(), @@ -4386,13 +4388,7 @@ fn prepare_editor_character_animation_bgfilter_input( target_width: u32, target_height: u32, ) -> Result { - let image_format = match mime_type { - "image/png" => Some(ImageFormat::Png), - "image/jpeg" | "image/jpg" => Some(ImageFormat::Jpeg), - "image/webp" => Some(ImageFormat::WebP), - _ => None, - }; - let image = match image_format { + let image = match image_format_from_mime(mime_type) { Some(format) => image::load_from_memory_with_format(source, format), None => image::load_from_memory(source), } @@ -4404,19 +4400,13 @@ fn prepare_editor_character_animation_bgfilter_input( })? .to_rgb8(); - let target_width = target_width.max(1); - let target_height = target_height.max(1); - let source_width = image.width().max(1); - let source_height = image.height().max(1); - let scale = (target_width as f32 / source_width as f32) - .min(target_height as f32 / source_height as f32); - let draw_width = ((source_width as f32 * scale).round() as u32) - .max(1) - .min(target_width); - let draw_height = ((source_height as f32 * scale).round() as u32) - .max(1) - .min(target_height); - let resized = image::imageops::resize(&image, draw_width, draw_height, FilterType::Triangle); + let (draw_width, draw_height) = + compute_contain_dimensions(image.width(), image.height(), target_width, target_height); + let resized = if (draw_width, draw_height) == (image.width(), image.height()) { + image + } else { + image::imageops::resize(&image, draw_width, draw_height, FilterType::Triangle) + }; let mut encoded = Vec::new(); let encoder = PngEncoder::new(&mut encoded); @@ -4448,13 +4438,7 @@ fn finalize_animation_frame_payload( frame_height: u32, apply_chroma_key: bool, ) -> Result { - let image_format = match mime_type { - "image/png" => Some(ImageFormat::Png), - "image/jpeg" | "image/jpg" => Some(ImageFormat::Jpeg), - "image/webp" => Some(ImageFormat::WebP), - _ => None, - }; - let mut image = match image_format { + let mut image = match image_format_from_mime(mime_type) { Some(format) => image::load_from_memory_with_format(source, format), None => image::load_from_memory(source), } @@ -4501,8 +4485,39 @@ fn finalize_animation_frame_payload( fn contain_rgba_image(source: &RgbaImage, target_width: u32, target_height: u32) -> RgbaImage { let mut canvas = RgbaImage::from_pixel(target_width, target_height, Rgba([0, 0, 0, 0])); - let source_width = source.width().max(1); - let source_height = source.height().max(1); + let (draw_width, draw_height) = + compute_contain_dimensions(source.width(), source.height(), target_width, target_height); + let offset_x = ((target_width - draw_width) / 2) as i64; + let offset_y = ((target_height - draw_height) / 2) as i64; + if (draw_width, draw_height) == (source.width(), source.height()) { + image::imageops::overlay(&mut canvas, source, offset_x, offset_y); + } else { + let resized = + image::imageops::resize(source, draw_width, draw_height, FilterType::Triangle); + image::imageops::overlay(&mut canvas, &resized, offset_x, offset_y); + } + canvas +} + +fn image_format_from_mime(mime_type: &str) -> Option { + match mime_type { + "image/png" => Some(ImageFormat::Png), + "image/jpeg" | "image/jpg" => Some(ImageFormat::Jpeg), + "image/webp" => Some(ImageFormat::WebP), + _ => None, + } +} + +fn compute_contain_dimensions( + source_width: u32, + source_height: u32, + target_width: u32, + target_height: u32, +) -> (u32, u32) { + let target_width = target_width.max(1); + let target_height = target_height.max(1); + let source_width = source_width.max(1); + let source_height = source_height.max(1); let scale = (target_width as f32 / source_width as f32) .min(target_height as f32 / source_height as f32); let draw_width = ((source_width as f32 * scale).round() as u32) @@ -4511,11 +4526,7 @@ fn contain_rgba_image(source: &RgbaImage, target_width: u32, target_height: u32) let draw_height = ((source_height as f32 * scale).round() as u32) .max(1) .min(target_height); - let resized = image::imageops::resize(source, draw_width, draw_height, FilterType::Triangle); - let offset_x = ((target_width - draw_width) / 2) as i64; - let offset_y = ((target_height - draw_height) / 2) as i64; - image::imageops::overlay(&mut canvas, &resized, offset_x, offset_y); - canvas + (draw_width, draw_height) } async fn load_media_source_payload( -- 2.52.0 From 0f0a8d52df19037ea23428d759d5f19bd6479278 Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 21 Jul 2026 03:12:06 +0000 Subject: [PATCH 03/27] =?UTF-8?q?=E8=BF=98=E5=8E=9F=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E6=8A=BD=E5=B8=A7=E5=83=8F=E7=B4=A0=E6=A0=BC=E5=BC=8F=E5=B9=B6?= =?UTF-8?q?=E5=9C=A8=20BgFilter=20=E6=B6=88=E8=B4=B9=E7=82=B9=E5=90=88?= =?UTF-8?q?=E6=88=90=20alpha?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 撤掉 extract_video_frames_to_png 里无条件的 -pix_fmt rgb24,共享抽帧不再携带 BgFilter 专属策略 prepare_editor_character_animation_bgfilter_input 解码后先按白底合成 alpha 再转 RGB,补上对应回归测试 Co-Authored-By: Claude Fable 5 --- .../src/character_animation_assets.rs | 57 +++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index 62d308a7b..43aa0111b 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -4334,8 +4334,6 @@ fn extract_video_frames_to_png( format!("[f{frame_index}]"), "-frames:v".to_string(), "1".to_string(), - "-pix_fmt".to_string(), - "rgb24".to_string(), "-f".to_string(), "image2".to_string(), output_path.to_string_lossy().into_owned(), @@ -4471,6 +4469,25 @@ fn run_process_with_timeout( } } +/// BgFilter 只接受不透明 RGB 输入;直接丢弃 alpha 会让全透明像素下未定义的 +/// RGB 值以杂色进入抠图,这里先按白底合成再转 RGB。 +fn flatten_alpha_onto_white_rgb(image: image::DynamicImage) -> image::RgbImage { + if !image.color().has_alpha() { + return image.to_rgb8(); + } + let rgba = image.to_rgba8(); + let mut flattened = image::RgbImage::new(rgba.width(), rgba.height()); + for (source, target) in rgba.pixels().zip(flattened.pixels_mut()) { + let [red, green, blue, alpha] = source.0; + let opacity = f32::from(alpha) / 255.0; + let blend = |channel: u8| -> u8 { + (f32::from(channel) * opacity + 255.0 * (1.0 - opacity)).round() as u8 + }; + target.0 = [blend(red), blend(green), blend(blue)]; + } + flattened +} + fn prepare_editor_character_animation_bgfilter_input( source: &[u8], mime_type: &str, @@ -4486,8 +4503,8 @@ fn prepare_editor_character_animation_bgfilter_input( "provider": "character-animation", "message": format!("解析 BgFilter 输入动作帧图片失败:{error}"), })) - })? - .to_rgb8(); + })?; + let image = flatten_alpha_onto_white_rgb(image); let (draw_width, draw_height) = compute_contain_dimensions(image.width(), image.height(), target_width, target_height); @@ -6478,6 +6495,38 @@ mod tests { ); } + #[test] + fn editor_character_animation_bgfilter_input_flattens_alpha_onto_white() { + let mut source = RgbaImage::from_pixel(8, 8, Rgba([17, 99, 201, 255])); + source.put_pixel(0, 0, Rgba([255, 0, 0, 0])); + source.put_pixel(1, 0, Rgba([0, 0, 0, 127])); + let mut source_png = Vec::new(); + PngEncoder::new(&mut source_png) + .write_image( + source.as_raw(), + source.width(), + source.height(), + ColorType::Rgba8.into(), + ) + .expect("source RGBA frame should encode"); + + let prepared = prepare_editor_character_animation_bgfilter_input( + source_png.as_slice(), + "image/png", + 8, + 8, + ) + .expect("BgFilter input should be prepared"); + let decoded = + image::load_from_memory_with_format(prepared.bytes.as_slice(), ImageFormat::Png) + .expect("prepared BgFilter input should decode") + .to_rgb8(); + + assert_eq!(decoded.get_pixel(0, 0).0, [255, 255, 255]); + assert_eq!(decoded.get_pixel(1, 0).0, [128, 128, 128]); + assert_eq!(decoded.get_pixel(2, 0).0, [17, 99, 201]); + } + #[test] fn editor_character_animation_final_frame_adds_transparent_vertical_padding() { let source = RgbaImage::from_pixel(323, 434, Rgba([31, 127, 223, 191])); -- 2.52.0 From 7831a23956dc93769f633a493edb9f816d524263 Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 21 Jul 2026 05:55:27 +0000 Subject: [PATCH 04/27] =?UTF-8?q?=E8=A1=A5=E8=AE=B0=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E5=8A=A8=E4=BD=9C=E6=8A=BD=E5=B8=A7=E5=90=AB=20alpha=20?= =?UTF-8?q?=E5=85=88=E7=99=BD=E5=BA=95=E5=90=88=E6=88=90=E7=9A=84=E6=8A=A0?= =?UTF-8?q?=E5=9B=BE=E8=BE=93=E5=85=A5=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 决策日志、数据契约、画布接入方案和角色形象入口设计四份文档同步最终实现: 解码帧携带 Alpha 时必须先按白底合成再转 RGB8,禁止直接丢弃 Alpha, 共享 FFmpeg 抽帧命令不固定 -pix_fmt,合成职责只在 BgFilter 输入准备阶段 Co-Authored-By: Claude Fable 5 --- docs/project-memory/shared-memory/decision-log.md | 1 + .../【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md | 1 + docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md | 4 ++-- docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md | 4 ++-- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 23d78e71f..a1b883946 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -20,6 +20,7 @@ - 背景:图片画布角色动作此前在 BgFilter 前复用最终帧 finalizer,把 FFmpeg 抽帧先转成目标尺寸 RGBA 画布并用透明黑像素补边;透明区域进入 BgFilter、阿里云和本地键色共同读取的 OSS 源帧后,会干扰主体边缘判断并降低抠图质量。 - 决策:仅图片画布角色动作链路在抠图前把 FFmpeg 帧转为 RGB8,按最终帧宽高的 contain 比例使用 `Triangle` 缩放到内容尺寸,不创建最终目标画布、不引入 Alpha、不插入 padding;该 RGB8 PNG owned 上传 OSS 后由三段抠图链共享。抠图返回后继续复用原最终帧 finalizer,转为 RGBA8、居中放入最终目标尺寸,并以 `RGBA(0,0,0,0)` 补边。`560×752 → 323×480` 的固定验收结果为 `323×434 RGB8` 抠图输入和上下各 `23px` 透明补边的 `323×480 RGBA8` 最终帧。 +- 补充(2026-07-21 实现收口):转 RGB8 时若解码帧携带 Alpha 通道(共享 FFmpeg 抽帧命令不固定 `-pix_fmt`,源视频为 alpha 格式时 PNG 可能是 RGBA),必须先把像素按白底合成为不透明再转 RGB8(`flatten_alpha_onto_white_rgb`),禁止直接丢弃 Alpha——全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入本决策要消除的杂色边缘。该白底合成职责只属于图片画布角色动作的 BgFilter 输入准备阶段,不得为此在共享抽帧命令里固定像素格式。 - 边界:不修改 `contain_rgba_image`、最终透明帧格式、BgFilter 请求、OSS 上传与签名、抽帧数量和采样时间,也不改变旧 `/api/assets/character-animation/*` 动作发布链路;因为降级链复用同一个 object key,阿里云和本地键色同样读取新的无补边 RGB8 源帧。 - 影响范围:`server-rs/crates/api-server/src/character_animation_assets.rs`、后端融合架构、角色动作专题和图片画布当前接入方案;不涉及 DTO、前端接口、SpacetimeDB schema 或运维配置。 - 验证方式:像素测试断言 `560×752 RGB8 → 323×434 RGB8` 且无 Alpha/补边,并断言抠图结果最终成为上下各 `23px` 透明补边的 `323×480 RGBA8`;运行 `cargo test -p api-server character_animation --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 8024fc550..59b7c2141 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -34,6 +34,7 @@ ### 角色动作帧抠图像素边界 - 图片画布角色动作的 FFmpeg 抽帧在上传 OSS 前转为 RGB8,并按最终帧宽高 contain 到内容尺寸;抠图前不创建最终尺寸画布、不引入 Alpha 通道、不增加 padding。同一个无补边 object key 供 `BgFilter → 阿里云通用抠图 → 本地键色` 三段链路使用。抠图完成后才转为最终目标尺寸 RGBA8,并以 `RGBA(0,0,0,0)` 居中补边。`560×752 → 323×480` 的验收样例中,抠图输入为 `323×434 RGB8 PNG`,最终输出为上下各 `23px` 透明补边的 `323×480 RGBA8 PNG`。该规则只作用于图片画布角色动作输入准备,不改变旧动作发布、采样、BgFilter 请求或 OSS 流程。 +- 转 RGB8 时若解码帧携带 Alpha 通道,必须先按白底合成为不透明再转 RGB8,禁止直接丢弃 Alpha:全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入杂色边缘。共享 FFmpeg 抽帧命令保持不固定 `-pix_fmt`,白底合成只发生在 BgFilter 输入准备阶段。 ## 交互规则 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 62caff5a8..d45b627c0 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -2,7 +2,7 @@ > 2026-07-18 状态更新:旧创作入口、全部模板业务 API/worker/运行态及 SpacetimeDB 业务逻辑已退役。本文逐玩法路由、流程和 DTO 章节仅作为历史设计记录;相关持久化表仍按原结构作为最小 schema 数据壳编译,当前编译与运行边界以 `server-rs/Cargo.toml`、`server-rs/crates/api-server/src/app.rs` 和 `docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md` 为准。 -更新时间:`2026-07-20` +更新时间:`2026-07-21` ## 后端主线 @@ -241,7 +241,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` 当成上游业务错误。 - 抠图输入以私有 OSS 作为内存生命周期边界:生成原图和角色动作抽取帧上传时消费图片字节所有权,上传完成后不保留原图缓冲;手动去背景直接解析并校验已有 OSS object key,不下载原图。BgFilter 必须为 object key 签发 600 秒 GET URL 并通过 multipart `image_url` 提交,不用 `file` 重传;flat 链路进入阿里云 fallback 时由 `platform-matting` URL 接口单独下载并上传 `AuthorizeFileUpload` 临时对象,在推理前释放下载缓冲,继续 fallback 到本地键色时再单独下载一次原图,本地产出后释放本次原图下载缓冲。签名 URL 不得写入日志、审计或持久化。 -- 角色动作抠图输入像素边界:仅图片画布角色动作链路在 FFmpeg 抽帧后、源帧上传 OSS 前,把帧解码为 RGB8,并按最终 `frameWidth × frameHeight` 的 contain 比例使用 `Triangle` 只缩放到内容尺寸;该阶段不得创建最终目标尺寸画布、不得引入 Alpha 通道,也不得插入任何 padding。BgFilter、阿里云通用抠图和本地键色降级共享这个无补边源帧 object key。抠图返回后才统一转为 RGBA8,按相同比例居中放入最终目标尺寸画布,并用 `RGBA(0,0,0,0)` 补齐透明 padding。以 `560×752 → 323×480` 为例,抠图输入固定为无 Alpha、无补边的 `323×434 RGB8 PNG`,最终输出为上下各 `23px` 透明补边的 `323×480 RGBA8 PNG`。旧 `/api/assets/character-animation/*` 动作发布链路继续保留原有帧 finalizer,不适用该输入规则。 +- 角色动作抠图输入像素边界:仅图片画布角色动作链路在 FFmpeg 抽帧后、源帧上传 OSS 前,把帧解码为 RGB8,并按最终 `frameWidth × frameHeight` 的 contain 比例使用 `Triangle` 只缩放到内容尺寸;该阶段不得创建最终目标尺寸画布、不得引入 Alpha 通道,也不得插入任何 padding。BgFilter、阿里云通用抠图和本地键色降级共享这个无补边源帧 object key。抠图返回后才统一转为 RGBA8,按相同比例居中放入最终目标尺寸画布,并用 `RGBA(0,0,0,0)` 补齐透明 padding。以 `560×752 → 323×480` 为例,抠图输入固定为无 Alpha、无补边的 `323×434 RGB8 PNG`,最终输出为上下各 `23px` 透明补边的 `323×480 RGBA8 PNG`。旧 `/api/assets/character-animation/*` 动作发布链路继续保留原有帧 finalizer,不适用该输入规则。抽帧解码后若携带 Alpha 通道,必须先把像素按白底合成为不透明再转 RGB8,禁止直接丢弃 Alpha——全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入杂色边缘;共享 FFmpeg 抽帧命令保持不固定 `-pix_fmt`,白底合成只属于该链路的 BgFilter 输入准备阶段。 - 阿里云通用抠图的非上海地域输入不得使用 `viapiutils/GetOssStsToken`、固定 `viapi-customer-temp` 或 OSS V1 PUT。`platform-matting` 必须按官方新版 SDK Advance 协议调用 `AuthorizeFileUpload`,使用动态返回的单对象 Policy 执行 multipart POST,再把临时上海 OSS URL 交给 `SegmentCommonImage`;输入归一化、结果下载与原尺寸 Alpha 回贴继续留在同一适配器内。该协议仍上传图片字节,不等同于阿里云服务端直接抓取任意公网 URL,也不改变上层 BgFilter → 阿里云 → 本地降级顺序。 - 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 与角色形象生成、图标 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`,默认请求超时为 `180000ms`(BgFilter 当前为 CPU 推理,单次抠图较慢,必须留足超时);旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 BgFilter token 的兼容回退别名,原手动去背景专用 base URL / timeout 配置已经删除。手动去背景固定传 `image_url`、`background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传 `file` 或 `screen_color`;该 API 接收 `objectKey`、`resourceId` 或 `assetId` 候选引用;BFF 入队前统一拒绝 `data:` / `blob:`;worker 不重复入口校验,只调用 `resolve_editor_reference_object_key_for_owner`,底层 resolver 在解析引用前拒绝内联媒体,并在签名前完成登记状态和 owner 校验;直接签发 OSS URL,不下载原图。标准纯色背景四条链路固定传 `background_mode=flat`,并显式传 `image_url`、`screen_color=`、`seg_model=` 和 `cross_check=`,其中角色形象生成和角色动作逐帧去背传 `cross_check=on`,图标 spritesheet 生成和 UI 设计图素材提取传 `cross_check=off`。前端用户路径不展示抠图模型、模式或 cross-check,固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`;这些参数只属于后端内部供应商策略,不进入前端或外部 OpenAPI。标准纯色背景 BgFilter 调用失败,或连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`(默认 `3`)并在 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`(默认 `300`)内打开熔断时,继续复用“阿里云通用抠图 → 本地 `editor_green_screen` 键色扣除”兜底链,熔断期不得直接退化到本地兜底。角色动作视频生成的背景色已与生图链路统一:`screenColor=auto` 时由视觉 LLM(`gpt-5-mini`,Responses 协议、low 推理档)读源角色图自动决策,并经硬过滤器剔除与前景 / 皮肤撞色的候选,手动 hex 则尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色;抽帧后每帧先上传私有 OSS 并释放原帧缓冲,再以该 object key 的签名 URL 固定使用 `seg_model=birefnet`、`cross_check=on` 进入上述三段式链路。阿里云通用抠图配置为 `GENARRATIVE_ALIYUN_MATTING_ENABLED`、`GENARRATIVE_ALIYUN_MATTING_ENDPOINT`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET` 和 `GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS`;未配置专用 AK/SK 时可复用 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`,默认 endpoint 为 `imageseg.cn-shanghai.aliyuncs.com`。标准纯色背景链路中,BgFilter 调用失败和阿里云抠图链路已开始后的失败(包括源 OSS GET 成功后的解码、尺寸校验和归一化失败)都写入 `external_api_call_failure` 审计;真正开始外部调用前的本地预检不写该审计,并在 `failureStage` 中保留 `source_decode`、`source_validate` 等阶段。 - BgFilter 连接复用、重试与动作帧流水线:api-server 必须在 `AppState` 复用同一个 BgFilter HTTP Client 及 keep-alive 连接池。`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 是所有路径的基准请求超时;角色动作逐帧 BgFilter 的每一次 HTTP attempt 使用“基准超时 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧分别为 `244000 / 260000 / 276000ms`,角色形象单图、图标、UI 和手动去背景仍使用基准值。api-server 只在共享 Client 的单次 RequestBuilder 上覆盖该值;它覆盖从请求发起到响应体读取完成,是单次 attempt 的总 deadline,不是整批帧或 worker job 超时,重试会重新签发 600 秒 OSS URL 并获得同样的 request deadline,整项任务仍受 worker long-job 预算约束。flat 与 complex 请求首次失败后都立即重试 `1` 次;标准纯色背景 flat 请求第二次仍失败才进入“阿里云通用抠图 → 本地键色”降级链,手动 complex 请求第二次仍失败则返回最终错误,不接入依赖纯色键值的降级链,也不改变 flat 路径的熔断状态。角色动作全部 `32 / 40 / 48` 帧按“单帧绿幕源图 owned 上传 OSS 并释放原帧 → 以签名 URL 调 BgFilter/按 object key 降级 → 透明帧落 OSS”独立流水化,使用覆盖本次全部帧的无序在途集合连续发射;不限制 BgFilter、阿里云或本地处理,但角色动画源帧 PUT、透明帧 PUT 和最终帧 HEAD 统一复用 `AppState` 内初始化一次的 OSS HTTP Client(连接池参数为 connect 30 秒、request 60 秒、idle 300 秒、每 host 8 个 idle 连接、TCP keepalive 60 秒),并受进程级 8 路 OSS semaphore 限制。每个 OSS 网络 attempt 单独获取 permit,退避期间释放;PUT/HEAD 动画帧请求最多 3 次(250ms、500ms 退避),只重试无 HTTP 响应的传输错误、timeout、OSS PutObject 的 `400 + RequestTimeout`、PUT `400` 错误体读取失败(未解析出 `Code`,按 timeout/transport 归类)、408、429 和 5xx。动作帧 PUT 只在 400 响应中有界读取最多 16 KiB OSS 错误 XML,并保留 `Code` 与响应头优先的 `x-oss-request-id`;错误体读取超时/断流时保留已读字节,已解析出的 `Code` 优先生效,未解析出 `Code` 则按 timeout/transport 归类重试;除 `RequestTimeout` 与该错误体读取失败情形外的其他 400、401/403/404、配置、URL/签名和空请求体错误不重试。返回结果携带原始帧序并在收口时排序。任一帧最终失败时必须先排空全部已启动 Future,再让整个动作任务失败退款,不能发布缺帧动画。 diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index 5282f2ddc..edd32af5b 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -2,7 +2,7 @@ 日期:`2026-06-15` -更新时间:`2026-07-20` +更新时间:`2026-07-21` ## 背景 @@ -164,7 +164,7 @@ - 视频生成完成后,后端先把带纯色背景的预览视频登记为 OSS 私有对象、`asset_object`、项目资源和账号素材,再按面板选择抽取对应帧数:`32`、`40` 或 `48`。未传 `assetFolderId` 时进入默认“项目”素材文件夹;后续抽帧或抠图失败不能抹掉这份已经生成成功的可恢复视频。 - 抽帧采样必须按目标帧数预留视频尾部安全步长,例如 `32帧·4秒` 最后一帧采 `3.875s`,避免 FFmpeg 在尾点附近返回成功但输出 `0` 帧。 -- 图片画布角色动作的 FFmpeg 原始帧在上传 OSS 前必须转为 RGB8,并按最终帧宽高的 contain 比例使用 `Triangle` 只缩放到内容尺寸;不得提前创建最终目标尺寸 RGBA 画布,不得引入 Alpha 通道或透明 padding。以 `560×752` 原始帧、`323×480` 最终目标为例,上传给抠图链路的源帧必须是 `323×434 RGB8 PNG`,没有上下补边。 +- 图片画布角色动作的 FFmpeg 原始帧在上传 OSS 前必须转为 RGB8,并按最终帧宽高的 contain 比例使用 `Triangle` 只缩放到内容尺寸;不得提前创建最终目标尺寸 RGBA 画布,不得引入 Alpha 通道或透明 padding。以 `560×752` 原始帧、`323×480` 最终目标为例,上传给抠图链路的源帧必须是 `323×434 RGB8 PNG`,没有上下补边。抽帧解码后若携带 Alpha 通道,必须先把像素按白底合成为不透明再转 RGB8,禁止直接丢弃 Alpha——全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入杂色边缘;共享 FFmpeg 抽帧命令保持不固定 `-pix_fmt`,白底合成只属于该链路的 BgFilter 输入准备阶段。 - 后端先计算整批精确采样时刻,再用单个 FFmpeg filter graph 统一解码预览视频并输出 `32 / 40 / 48` 张源帧;不得为每帧重新启动 FFmpeg、重复解码同一视频,也不得用会改变现有尾帧安全时刻的粗粒度 `fps` 抽帧替代。批量命令成功后必须逐一确认全部目标帧文件存在,缺少任一帧都按整批失败处理并保留缺帧编号、目标时刻和输出路径诊断。 - 每帧绿幕源图字节由上传 owned 消费(`frame.bytes` 移入 put,上传完成后释放原帧缓冲,不克隆保留);后续只持 object key。每次 BgFilter attempt 重新签发 600 秒 GET URL,multipart 仅传 `image_url`(加 `background_mode=flat`、`seg_model=birefnet`、`cross_check=on` 与同一次生成已选定的 `screenColor`),不传 `file`。BgFilter 主路径不重新下载原帧;失败后走 `阿里云通用抠图(按签名 URL 单独下载)→ 本地 editor_green_screen(再按 object key 独立下载一次并在产出后释放)`。BgFilter 每一次 HTTP attempt 的 timeout 使用“`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 基准值 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧分别为 `244000 / 260000 / 276000ms`;首次失败后重试 `1` 次。 - BgFilter、阿里云或本地键色返回透明结果后,后端继续通过现有最终帧 finalizer 转为 RGBA8,按宽高比居中放入最终目标尺寸,并使用 `RGBA(0,0,0,0)` 补边。上述样例最终输出必须为 `323×480 RGBA8 PNG`,顶部和底部各 `23px` 透明 padding,内容区域完整保留抠图结果。 -- 2.52.0 From e312f1632bce65d28b6e82645c1987d388b09383 Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 21 Jul 2026 05:59:09 +0000 Subject: [PATCH 05/27] =?UTF-8?q?=E6=9B=B4=E6=AD=A3=E5=86=B3=E7=AD=96?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E4=B8=AD=20contain=5Frgba=5Fimage=20?= =?UTF-8?q?=E7=9A=84=E8=BE=B9=E7=95=8C=E8=A1=A8=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 2026-07-21 补充行注明该函数后续重构(共享 contain 计算、同尺寸跳过重采样)但输出语义不变, 「不修改 contain_rgba_image」应理解为「不改变最终帧的 RGBA/padding 语义」 Co-Authored-By: Claude Fable 5 --- docs/project-memory/shared-memory/decision-log.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a1b883946..2071bf68a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -20,7 +20,7 @@ - 背景:图片画布角色动作此前在 BgFilter 前复用最终帧 finalizer,把 FFmpeg 抽帧先转成目标尺寸 RGBA 画布并用透明黑像素补边;透明区域进入 BgFilter、阿里云和本地键色共同读取的 OSS 源帧后,会干扰主体边缘判断并降低抠图质量。 - 决策:仅图片画布角色动作链路在抠图前把 FFmpeg 帧转为 RGB8,按最终帧宽高的 contain 比例使用 `Triangle` 缩放到内容尺寸,不创建最终目标画布、不引入 Alpha、不插入 padding;该 RGB8 PNG owned 上传 OSS 后由三段抠图链共享。抠图返回后继续复用原最终帧 finalizer,转为 RGBA8、居中放入最终目标尺寸,并以 `RGBA(0,0,0,0)` 补边。`560×752 → 323×480` 的固定验收结果为 `323×434 RGB8` 抠图输入和上下各 `23px` 透明补边的 `323×480 RGBA8` 最终帧。 -- 补充(2026-07-21 实现收口):转 RGB8 时若解码帧携带 Alpha 通道(共享 FFmpeg 抽帧命令不固定 `-pix_fmt`,源视频为 alpha 格式时 PNG 可能是 RGBA),必须先把像素按白底合成为不透明再转 RGB8(`flatten_alpha_onto_white_rgb`),禁止直接丢弃 Alpha——全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入本决策要消除的杂色边缘。该白底合成职责只属于图片画布角色动作的 BgFilter 输入准备阶段,不得为此在共享抽帧命令里固定像素格式。 +- 补充(2026-07-21 实现收口):转 RGB8 时若解码帧携带 Alpha 通道(共享 FFmpeg 抽帧命令不固定 `-pix_fmt`,源视频为 alpha 格式时 PNG 可能是 RGBA),必须先把像素按白底合成为不透明再转 RGB8(`flatten_alpha_onto_white_rgb`),禁止直接丢弃 Alpha——全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入本决策要消除的杂色边缘。该白底合成职责只属于图片画布角色动作的 BgFilter 输入准备阶段,不得为此在共享抽帧命令里固定像素格式。另:`contain_rgba_image` 后续已被重构(contain 缩放计算抽出为 `compute_contain_dimensions` 共享,同尺寸时跳过重采样直接 overlay),输出语义不变;边界行中的「不修改 `contain_rgba_image`」应理解为「不改变最终帧的 RGBA/padding 语义」,不承诺函数实现不动。 - 边界:不修改 `contain_rgba_image`、最终透明帧格式、BgFilter 请求、OSS 上传与签名、抽帧数量和采样时间,也不改变旧 `/api/assets/character-animation/*` 动作发布链路;因为降级链复用同一个 object key,阿里云和本地键色同样读取新的无补边 RGB8 源帧。 - 影响范围:`server-rs/crates/api-server/src/character_animation_assets.rs`、后端融合架构、角色动作专题和图片画布当前接入方案;不涉及 DTO、前端接口、SpacetimeDB schema 或运维配置。 - 验证方式:像素测试断言 `560×752 RGB8 → 323×434 RGB8` 且无 Alpha/补边,并断言抠图结果最终成为上下各 `23px` 透明补边的 `323×480 RGBA8`;运行 `cargo test -p api-server character_animation --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 -- 2.52.0 From 81437260954c5750f58bc639af3ff42aaf6176fc Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 21 Jul 2026 06:33:14 +0000 Subject: [PATCH 06/27] =?UTF-8?q?=E5=86=B3=E7=AD=96=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E8=BE=B9=E7=95=8C=E8=A1=8C=E7=9B=B4=E6=8E=A5=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E8=A1=8C=E4=B8=BA=E7=BA=A7=E8=A1=A8=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本条决策与其补充均为本分支新增,无需保留旧文再追加更正: 边界行「不修改 contain_rgba_image」改写为「不改变最终帧的 RGBA/padding 语义与透明帧格式」, 并删除补充行中为此加的更正说明 Co-Authored-By: Claude Fable 5 --- docs/project-memory/shared-memory/decision-log.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 2071bf68a..bf492c40e 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -20,8 +20,8 @@ - 背景:图片画布角色动作此前在 BgFilter 前复用最终帧 finalizer,把 FFmpeg 抽帧先转成目标尺寸 RGBA 画布并用透明黑像素补边;透明区域进入 BgFilter、阿里云和本地键色共同读取的 OSS 源帧后,会干扰主体边缘判断并降低抠图质量。 - 决策:仅图片画布角色动作链路在抠图前把 FFmpeg 帧转为 RGB8,按最终帧宽高的 contain 比例使用 `Triangle` 缩放到内容尺寸,不创建最终目标画布、不引入 Alpha、不插入 padding;该 RGB8 PNG owned 上传 OSS 后由三段抠图链共享。抠图返回后继续复用原最终帧 finalizer,转为 RGBA8、居中放入最终目标尺寸,并以 `RGBA(0,0,0,0)` 补边。`560×752 → 323×480` 的固定验收结果为 `323×434 RGB8` 抠图输入和上下各 `23px` 透明补边的 `323×480 RGBA8` 最终帧。 -- 补充(2026-07-21 实现收口):转 RGB8 时若解码帧携带 Alpha 通道(共享 FFmpeg 抽帧命令不固定 `-pix_fmt`,源视频为 alpha 格式时 PNG 可能是 RGBA),必须先把像素按白底合成为不透明再转 RGB8(`flatten_alpha_onto_white_rgb`),禁止直接丢弃 Alpha——全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入本决策要消除的杂色边缘。该白底合成职责只属于图片画布角色动作的 BgFilter 输入准备阶段,不得为此在共享抽帧命令里固定像素格式。另:`contain_rgba_image` 后续已被重构(contain 缩放计算抽出为 `compute_contain_dimensions` 共享,同尺寸时跳过重采样直接 overlay),输出语义不变;边界行中的「不修改 `contain_rgba_image`」应理解为「不改变最终帧的 RGBA/padding 语义」,不承诺函数实现不动。 -- 边界:不修改 `contain_rgba_image`、最终透明帧格式、BgFilter 请求、OSS 上传与签名、抽帧数量和采样时间,也不改变旧 `/api/assets/character-animation/*` 动作发布链路;因为降级链复用同一个 object key,阿里云和本地键色同样读取新的无补边 RGB8 源帧。 +- 补充(2026-07-21 实现收口):转 RGB8 时若解码帧携带 Alpha 通道(共享 FFmpeg 抽帧命令不固定 `-pix_fmt`,源视频为 alpha 格式时 PNG 可能是 RGBA),必须先把像素按白底合成为不透明再转 RGB8(`flatten_alpha_onto_white_rgb`),禁止直接丢弃 Alpha——全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入本决策要消除的杂色边缘。该白底合成职责只属于图片画布角色动作的 BgFilter 输入准备阶段,不得为此在共享抽帧命令里固定像素格式。 +- 边界:不改变最终帧的 RGBA/padding 语义与透明帧格式、BgFilter 请求、OSS 上传与签名、抽帧数量和采样时间,也不改变旧 `/api/assets/character-animation/*` 动作发布链路;因为降级链复用同一个 object key,阿里云和本地键色同样读取新的无补边 RGB8 源帧。 - 影响范围:`server-rs/crates/api-server/src/character_animation_assets.rs`、后端融合架构、角色动作专题和图片画布当前接入方案;不涉及 DTO、前端接口、SpacetimeDB schema 或运维配置。 - 验证方式:像素测试断言 `560×752 RGB8 → 323×434 RGB8` 且无 Alpha/补边,并断言抠图结果最终成为上下各 `23px` 透明补边的 `323×480 RGBA8`;运行 `cargo test -p api-server character_animation --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/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 -- 2.52.0 From a51b625101bb66a121dfaf32926e69e68bb66cd2 Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 21 Jul 2026 08:09:48 +0000 Subject: [PATCH 07/27] =?UTF-8?q?=E8=A1=A5=E5=85=85=20BgFilter=20=E5=8D=95?= =?UTF-8?q?=E5=AE=9E=E4=BE=8B=E5=90=8C=E6=AD=A5=E8=B0=83=E5=BA=A6=E6=96=B9?= =?UTF-8?q?=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增同步内部 HTTP 原地等待版架构方案,明确并发、超时、熔断与降级边界 补充外部生成 Worker 化方案和共享决策日志中的受限资源调度约定 更新文档目录并关联 BgFilter 专题方案 --- docs/README.md | 1 + .../shared-memory/decision-log.md | 12 + ...架构】BgFilter受限资源调度方案-2026-07-21.md | 437 ++++++++++++++++++ ...端架构】外部生成Worker化方案-2026-06-03.md | 4 +- 4 files changed, 453 insertions(+), 1 deletion(-) create mode 100644 docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md diff --git a/docs/README.md b/docs/README.md index d8911947e..5b8222abc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,6 +27,7 @@ ### 后端与公开数据 - [外部生成 Worker 化方案](./technical/【后端架构】外部生成Worker化方案-2026-06-03.md) +- [BgFilter 受限资源调度方案(同步内部 HTTP 原地等待版)](./technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md) - [统一公开作品 Read Model 设计](./technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md) - [外部 OpenAPI 与 API Key 接入方案](./【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md) - [SpacetimeDB 连接池取消安全](./【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index bf492c40e..c5939a2ab 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,18 @@ --- +## 2026-07-21 BgFilter 首版采用单实例同步内部 HTTP 与父流程原地等待 + +- 背景:角色动画在单个 `external_generation_job` 内通过 `buffer_unordered(frame_count)` 可并发发射最多 `48` 次 BgFilter 请求;限制父 worker 并发不能限制单个父 job 内的实际 BgFilter 并发。父 job checkpoint / continuation 和 SpacetimeDB 持久子任务都会扩大父状态机、attempt、计费、恢复和清理改动,而当前 BgFilter 成功结果本来就是 HTTP 图片二进制。 +- 决策:父 future 保持原调用栈、lease 和 attempt,等待期间继续占用通用 worker 槽并由现有 heartbeat 续租;所有调用统一同步请求唯一 `bgfilter-worker` 的内部 loopback HTTP。输入只传 OSS object key、参数和剩余预算,成功直接返回经过校验的图片二进制。子 worker 使用有界 admission `Q` 和进程内 `Semaphore(N)`,负责最多两次顺序 provider attempt、flat 进程级熔断和失败审计;父流程继续负责 flat 降级、complex 失败、Alpha / 尺寸恢复、动画 finalizer、最终 OSS、业务写回、计费和父终态。 +- 超时边界:父 job 总预算仍为普通 `900s` / 长任务 `1800s`,并保留现有 `60s` 终态写回窗口;内部 RPC 总预算包含等待 semaphore、两次 attempt、结果校验和二进制返回,子 worker 的单次 provider timeout 默认 `180s`。动画删除旧的 `2000ms × frame_count` 单次 timeout 增量。父侧不得在内部 timeout / 断连后重试整次 RPC。 +- 故障边界:首版不新增 `bgfilter_task_group`、`bgfilter_request_task`、raw OSS、checkpoint、continuation、数据库 capacity slot、共享熔断或 QPS token bucket。父或子进程崩溃、RPC 丢失时不查询、不恢复结果;父 job 沿用现有 lease / `max_attempts=1` 失败退款语义。动画首版保持所有已提交帧 collect / drain,不增加跨帧取消组。 +- 影响范围:后续实现涉及 `api-server` 内部 HTTP client、专用 `bgfilter-worker` listener / process role、并发与超时配置、部署和运维观测;不修改 SpacetimeDB schema、父 job schema、用户任务 DTO、任务列表或收费归属。 +- 验证方式:`48` 帧并发进入父 future 时,健康唯一子 worker 进程持有的 BgFilter HTTP future 峰值不得超过生产显式配置的 `N`,且 `queued + running` 不超过 `Q`;覆盖 flat / complex 降级矩阵、内部 deadline、断连后已启动请求排空、动画全帧 drain、External v1 / inline 旁路扫描、二进制大小 / MIME / 尺寸门禁、单实例部署、DDD、编码和 diff 门禁。 +- 关联文档:`docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + +--- + ## 2026-07-20 角色动作抠图前禁止透明 padding - 背景:图片画布角色动作此前在 BgFilter 前复用最终帧 finalizer,把 FFmpeg 抽帧先转成目标尺寸 RGBA 画布并用透明黑像素补边;透明区域进入 BgFilter、阿里云和本地键色共同读取的 OSS 源帧后,会干扰主体边缘判断并降低抠图质量。 diff --git a/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md b/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md new file mode 100644 index 000000000..610f329df --- /dev/null +++ b/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md @@ -0,0 +1,437 @@ +# BgFilter 受限资源调度方案(同步内部 HTTP 原地等待版) + +更新时间:`2026-07-21` + +状态:`待实施` + +> 本文替代此前讨论的“SpacetimeDB 持久子任务 + raw 结果 OSS”以及更早的“父 job checkpoint / continuation”方案。首版改为父流程在原调用栈内同步等待唯一 `bgfilter-worker` 的内部 HTTP 响应。当前代码仍由各调用方直接请求 BgFilter,本文描述目标实现。 + +## 1. 决策摘要 + +| 决策项 | 首版结论 | +| --- | --- | +| 调度单位 | 一次逻辑 BgFilter 调用;角色动画为单帧 | +| 父流程 | 保持原 future、调用栈、lease 和 `attempt`,同步等待内部 HTTP | +| 通用 worker 槽 | 等待期间继续占用;父 heartbeat 继续运行 | +| 输入 | 只传私有 OSS `objectKey`、BgFilter 参数、剩余预算和有界审计关联;不传源图字节或签名 URL | +| 成功输出 | 内部 HTTP body 直接返回图片二进制;不使用 Base64,不先写 raw OSS | +| BgFilter worker | 首版只运行一个内部 HTTP worker 实例 | +| 并发 | 进程内 `Semaphore(N)`,并增加有界 admission 上限 `Q` | +| 重试 | 子 worker 对一次逻辑调用最多做两次顺序 provider attempt;父侧不重试整次内部 RPC | +| 超时 | 父侧管理 job / request 总预算和内部 RPC 总预算;子 worker 管理排队及单次 provider `180s` 上限 | +| flat 熔断 | 迁到唯一子 worker 的进程内状态;连续失败达到阈值后暂时跳过 BgFilter,complex 完全不参与 | +| 业务语义 | 父流程继续负责 Alpha / 尺寸恢复、flat fallback、最终 OSS、画布写回、计费和父终态 | +| 动画失败 | 首版保持当前“所有已提交帧都等待并排空”语义,不新增跨帧取消组 | +| 崩溃恢复 | 不查询、不恢复 BgFilter 结果;父 job 沿用现有 lease、失败和退款语义 | +| 数据模型 | 不新增 SpacetimeDB 表,不修改 `external_generation_job` schema | + +首版明确不实现: + +- `bgfilter_task_group`、`bgfilter_request_task` 或 `bgfilter_capacity_slot`; +- 持久队列、claim、lease、reaper、subscription 或 waiter registry; +- raw 中间结果 OSS、raw TTL 或 raw 持久化预留; +- 父流程 checkpoint、`waiting_bgfilter`、continuation、requeue 或恢复不计 attempt 状态机; +- 多实例 BgFilter worker、共享持久熔断或 QPS token bucket; +- 内部 RPC 失败后偷偷回退为父进程直连 BgFilter。 + +## 2. 背景与目标 + +### 2.1 当前问题 + +角色动画当前使用 `buffer_unordered(frame_count.max(1))`。单个父 job 会让 `32 / 40 / 48` 个帧 future 同时进入 BgFilter 调用,因此限制 `external-generation-worker` 的父 job 并发不能限制实际 BgFilter 请求并发。 + +编辑器 queue job 固定 `max_attempts = 1`。普通 requeue 会改变 attempt、失败和退款语义;checkpoint / continuation 又会扩大父状态机和计费恢复改动。父流程既然可以接受继续占用 worker 槽,首版无需为 BgFilter 建第二套持久任务系统。 + +当前 BgFilter 成功结果本来就是 HTTP 图片二进制,调用方读取后再由父流程做最终处理和 OSS 持久化。因此让专用 worker 通过内部 HTTP 直接返回二进制,最接近现有数据流。 + +### 2.2 目标 + +- 所有现役 flat / complex 调用统一经过唯一内部 `bgfilter-worker`。 +- `48` 帧可同时提交内部请求,但健康唯一 worker 持有的 BgFilter HTTP future 不超过 `N`。 +- 父流程不重建,不改变父 job schema、attempt、计费和用户可见任务。 +- 保留现有 flat 两次尝试后“阿里云通用抠图 → 本地键色”、complex 两次失败直接失败的语义。 +- External v1、inline 和 queue 路径复用同一个低层 client,不能绕过限流。 +- 不把源图片、签名 URL、Token 或图片 Base64 写入数据库、JSON、审计 payload 或日志。 + +### 2.3 非目标 + +- 不释放等待中的通用 worker 槽。 +- 不保证父进程、子 worker 或内部连接崩溃后的结果恢复。 +- 不绝对限制客户端 timeout 后仍可能在 BgFilter 服务端继续的远端计算。 +- 不做跨 job 公平调度;首版有界等待采用 FIFO。 +- 不限制阿里云抠图、本地键色、OSS 或其它 provider 的并发。 +- 不改变前端 DTO、External OpenAPI、任务列表或收费归属。 + +## 3. 总体架构 + +```mermaid +flowchart LR + P["父流程:queue job / inline / External v1"] + O["已持久化的私有 OSS 源对象"] + C["内部 HTTP client"] + W["唯一 bgfilter-worker\n有界 admission(Q) + Semaphore(N)"] + B["BgFilter provider\n最多两次顺序 attempt"] + F["父流程原有后处理\nfallback / finalizer / 最终 OSS / 业务写回"] + + P --> O + P --> C + C -->|"objectKey + 参数 + requestBudgetMs"| W + W --> B + B --> W + W -->|"图片二进制或类型化 JSON 错误"| C + C --> P + P --> F +``` + +职责边界: + +- 父流程负责源对象已持久化、owner 校验、请求预算、flat fallback、Alpha / 尺寸恢复、动画 finalizer、最终 OSS / `asset_object` / 画布写回、计费和父终态。 +- `bgfilter-worker` 负责内部协议校验、OSS 签名、并发与排队上限、BgFilter 协议、两次顺序尝试、flat 熔断、provider 失败审计和结果图片校验。 +- SpacetimeDB 不参与本次内部调度;不新增表、reducer、procedure、facade 或生成 bindings。 + +`bgfilter-worker` 从实现形态看是只监听内部地址的同步 worker service,不是队列 consumer。父 worker 调另一个 worker 在这里是允许的:父进程明确选择保留调用栈和槽位,因此同步内部 HTTP 正是首版的最小交接方式。 + +## 4. 内部 HTTP 契约 + +### 4.1 请求 + +首版新增内部接口: + +```http +POST /internal/bgfilter/v1/remove-background +Content-Type: application/json +Authorization: Bearer +``` + +示例: + +```json +{ + "requestId": "bgfilter-call-uuid", + "sourceObjectKey": "generated-character-drafts/editor/source.png", + "backgroundMode": "flat", + "screenColor": "#00ff00", + "segModel": "birefnet", + "crossCheck": true, + "requestBudgetMs": 300000, + "auditContext": { + "userId": "bounded-internal-id", + "profileId": null, + "requestId": "parent-request-id" + } +} +``` + +约束: + +- 当前部署只有一个配置内私有 OSS bucket,因此请求只传 `sourceObjectKey`,子 worker 从自身 OSS 配置取 bucket 并生成短期签名 URL。 +- 如果未来确实支持多个 bucket,新增字段也必须由服务端 allowlist 校验;不能接受调用方提供任意下载 URL。 +- `backgroundMode` 只允许 `flat / complex`;`segModel` 继续沿用当前 `birefnet / anime-seg` allowlist;complex 固定使用当前参数组合。 +- `screenColor` 只对 flat 必填;complex 不得误接 flat 参数或熔断。 +- `requestBudgetMs` 是从子 worker 收到请求开始计算的相对预算,不是跨机器绝对时间。 +- JSON body 设置很小的固定上限;源图字节不进入该 JSON。 + +签名 URL 必须在取得 provider permit 后、每次 attempt 前生成,避免排队期间过期。签名 URL 只存在于子 worker 内存和发往 BgFilter 的请求中。 + +### 4.2 成功响应 + +成功直接返回经过大小、MIME 和像素尺寸校验的图片字节: + +```http +HTTP/1.1 200 OK +Content-Type: image/png + + +``` + +`image/png` 是常见响应示例。为保持当前行为,首版可以返回实际受支持的 `image/png`、`image/webp` 或 `image/jpeg`,但必须保证响应头与实际解码类型一致;不使用 JSON、Data URL 或 Base64 包装,也不为传输先落 raw OSS。 + +子 worker 必须完整读取并校验 provider body 后才向父侧返回成功,这样 provider body 中途断开时仍可在预算内执行第二次 attempt。父侧收到二进制后继续执行一次独立校验,不能只信任内部响应头。 + +### 4.3 错误响应 + +失败返回有界 JSON,稳定错误码只保留: + +- `provider_exhausted`:两次真实 provider attempt 都失败; +- `circuit_open`:flat 熔断已打开,未发送 provider 请求; +- `deadline_exceeded`:排队、provider 或响应阶段预算耗尽,使用 `phase = queue | provider | response`; +- `overloaded`:`queued + running` 已达到 `Q`; +- `cancelled`:调用方已取消且尚未开始新的 provider attempt; +- `invalid_request`:内部契约不合法; +- `unauthorized`:内部 Token 缺失或不匹配; +- `invalid_result`:provider 回图超限、MIME / 魔数不匹配或无法安全解码; +- `internal_error`:内部配置、OSS 签名或其它非 provider 故障。 + +示例: + +```json +{ + "error": { + "code": "deadline_exceeded", + "phase": "queue", + "attemptsStarted": 0, + "message": "BgFilter 内部请求预算已耗尽" + } +} +``` + +HTTP status 只作粗粒度传输分类,父侧以稳定 `error.code` 映射业务语义。错误中不得包含签名 URL、Token、图片字节、完整 provider body 或无界错误文本。 + +## 5. 超时所有权 + +### 5.1 三层预算 + +新版不是“父 worker 完全不再管理 BgFilter 超时”,而是三层分工: + +1. 父 worker 继续管理父 job 总预算:普通 `900s`、长任务 `1800s`。 +2. 父侧为每次内部 RPC 计算 worker `requestBudgetMs` 和略长的 parent client timeout;前者覆盖等待 semaphore、最多两次 provider attempt、结果校验和响应构造,后者再覆盖 loopback 传输与父侧读取。 +3. 子 worker 管理单次真实 BgFilter attempt,默认上限仍为 `180s`。 + +queue job 的总预算从父 job 开始执行时起算,不从开始申请 BgFilter 时重新计时。现有父 worker 还会把 provider deadline 设在 job deadline 前 `60s`,为最终写回和终态保留时间。同步 RPC 实现必须显式读取父侧剩余 provider budget 并传入 `requestBudgetMs`,不能像当前 BgFilter helper 一样忽略 `RequestContext` deadline。 + +父总 deadline 到达时,现有 worker 会停止续租、释放 JoinSet 槽并把 work 交给 lease fencing 仲裁,不是立刻杀死所有内部工作。正常情况下内部 RPC 自身应在更早的 provider deadline 内结束,避免进入这条脱管路径。 + +### 5.2 子 worker attempt timeout + +父侧与子 worker 的预算必须满足: + +```text +requestBudgetMs + 2s parent transport window + <= parent client timeout + <= 父侧剩余绝对预算 +``` + +parent client timeout 从父侧开始发请求时起算,`requestBudgetMs` 从子 worker 收到请求时起算,因此两者不能设成同一个值。额外 `2s` 用于 loopback 传输、调度抖动和父侧读取类型化错误,不增加 provider 可执行时间。 + +子 worker 收到请求后使用本机单调时钟计算 RPC deadline。每次 attempt 的 timeout 为: + +```text +min(180s, RPC 剩余时间 - 1s worker 响应构造窗口) +``` + +worker 内部 `1s` 和 parent transport `2s` 都只是固定的小型进程 / 传输窗口,不是 raw 结果持久化预留。剩余时间不足时不得开始新的 attempt;第一次失败后只有预算仍足够才开始第二次。 + +flat 调用还要由父侧从可分配给 BgFilter 的预算中保留当前阿里云 request timeout(默认 `30s`)和少量本地处理余量,避免 BgFilter 排队吃完全部 provider budget 后名义上有 fallback、实际上已无时间执行。complex 没有 flat fallback,不使用该预留。 + +inline / External v1 没有 queue job deadline 时,内部 RPC 仍必须有界;默认总上限按“两次现有 BgFilter attempt 上限 + 内部响应窗口”计算,排队时间同样包含在内。 + +### 5.3 动画旧增量 + +当前动画把单次 BgFilter timeout 设为: + +```text +180s + 2000ms × frame_count +``` + +该增量原本用于容纳 32 / 40 / 48 个请求直接进入 BgFilter 后的服务端排队。新版已把排队前移到内部 worker,必须删除该增量;所有真实 provider attempt 统一使用 `180s` 上限,动画尾部请求能等待多久由各自内部 RPC 剩余预算决定。 + +## 6. 并发、排队与熔断 + +### 6.1 `N` 与 `Q` + +唯一子 worker 使用两个简单上限: + +- `Q`:内部请求 admission 上限,满足 `queued + running + egress <= Q`;超出立即返回 `overloaded`。 +- `N`:BgFilter provider 并发 permit;handler 取得 permit 后才允许开始第一次 provider HTTP。 + +同一次逻辑调用的 `N` permit 从第一次 provider attempt 前一直持有到两次尝试结束、provider body 读完、完成结果校验,并随内部成功 response body 一直持有到发送完成或 body drop。第二次 attempt 不重新排队。保守延长 permit 生命周期可以防止父侧慢读时积累多份完整结果;loopback 传输很短,首版接受这点吞吐代价。 + +仅使用 provider semaphore 会形成无界 waiter,因此 `Q` 不是可选优化。内部 listener 必须在解析 JSON body 前使用连接 / request concurrency limit 与 load shedding 完成 admission,设置固定 listen backlog 和小型 body limit;`Q` permit 同样跟随 response body 到发送完成或 drop。`Q` 不替代内核 listen backlog,也不宣称消除所有已 accept socket。首版接受 FIFO,不增加持久优先级队列或公平调度状态机。 + +当前父 worker 可能提交的最大帧请求数并不只有 `96`: + +| 场景 | 潜在同时提交的动画帧调用 | +| --- | ---: | +| 一个动画父 job | `48` | +| 一个默认 external-generation-worker,父并发 `2` | `96` | +| controller 最多 `8` 个父 worker、每个并发 `2` | `768` | + +`Q` 是保护子 worker 的 overload 取舍,不要求覆盖理论最大 `768`。例如选择 `Q = 128` 可以容纳两个满帧动画并留少量余量,但更多父 worker 会收到 `overloaded`;flat 将进入 fallback,complex 将失败。生产必须明确是否接受该行为。 + +理论最坏时间必须纳入容量评估: + +```text +48 帧、每帧两次 180s:约 ceil(48 / N) × 360s +``` + +例如 `N = 4` 时理论最坏为 `4320s`,超过长 job 的 `1800s`。上线值不能只按理论最大超时推断,必须用真实 BgFilter P95、动画帧数和主机内存压测冻结;容量不足时尾部 flat 请求会在内部 deadline 后进入父侧 fallback。 + +### 6.2 并发承诺边界 + +首版只能承诺: + +```text +部署中只有一个健康 bgfilter-worker 时, +该进程当前持有的 BgFilter HTTP future <= N。 +``` + +它不能绝对保证 BgFilter 服务端实际计算始终 `<= N`:客户端 timeout、进程退出或网络断开后,远端可能继续计算,而本地 permit 已被释放。若必须严格限制服务端计算,只能把全局 semaphore 放到 BgFilter 服务本身,或让 BgFilter 支持 request id、取消和状态查询。 + +两个子 worker 实例会得到 `2N`,因此首版使用固定内部监听地址和非模板化 systemd unit;同机第二实例应因固定端口 bind 失败。发布必须 `stop old -> 等待排空/退出 -> start new`,不能让新旧进程重叠。生产 `N` 或 `Q` 缺失、为 `0` 时 fail-closed。 + +### 6.3 熔断 + +熔断是故障保护:flat 连续多次请求失败后,在 cooldown 内暂时不再请求 BgFilter,而是快速返回 `circuit_open`,由父流程进入“阿里云 → 本地”fallback,避免故障 provider 持续占满并发和超时。 + +保持当前语义: + +- 只有 flat 读取和更新熔断;complex 完全不读写。 +- flat 在取得 permit、即将发送第一次 provider HTTP 前重新检查熔断,避免 48 个排队请求在熔断打开前全部通过旧检查。 +- 已经获准执行的逻辑调用,即使第一次失败使熔断打开,也仍允许在预算内完成自己的第二次顺序 attempt;后续请求快速返回 `circuit_open`。 +- 每个真实失败 attempt 计一次失败,保持当前计数口径;成功重置。 +- 只有拿到完整配置 attempt 上限(默认 `180s`)后发生的 provider timeout,以及真实传输失败、非 2xx 和无效 / 超限图片计入。因 `requestBudgetMs` 剩余不足而被截短的 timeout 返回 `deadline_exceeded`,不更新熔断;排队满、排队超时、客户端取消、鉴权和本地配置错误同样不计入。 +- 进程重启后熔断状态清零是首版接受行为。 + +首版不增加 QPS 限制。若 provider 以后要求 QPS,需要另加 token bucket;不能把并发 semaphore 当作 QPS。 + +## 7. 断连、崩溃与动画语义 + +### 7.1 内部 RPC 断连 + +- 父侧不得自动重试整次内部 HTTP。断连时结果未知,重试可能让一次逻辑调用从最多两次 provider attempt 扩大为四次,并可能突破瞬时并发预期。 +- 尚在等待 permit 的请求到达自身 deadline 后必须取消,不再发送 provider 请求;运行时若能可靠观察客户端断连,也可提前取消,但正确性不能只依赖断连事件。 +- 已经开始的 provider attempt 必须继续读取到完成或本次 attempt timeout,并持有 permit;可观察到的 handler / client drop 只丢弃最终结果,不能让已启动请求变成无人管理的本地 future。 +- deadline 或显式 cancellation signal 已被子 worker 观察到后,不再开始第二次 attempt。单纯 TCP 断连只能 best-effort 触发 cancellation;Axum / Hyper 不保证立刻通知 handler,因此不能承诺所有断连都阻止二试。 + +实现时,已启动 provider attempt 应由持有 permit 的独立 task 管理;request handler 可观察到的 Drop / cancellation signal 只影响“是否继续重试和是否返回结果”,不直接丢弃已经开始的 provider future。`requestBudgetMs` deadline 是最终可靠的停止条件。 + +### 7.2 进程崩溃 + +- 父进程崩溃:内部连接最终断开,父 job 按现有 heartbeat、lease、`max_attempts = 1`、失败和退款语义收口。 +- 子 worker 崩溃或重启:当前内部 RPC 失败;父侧不查询、不恢复、不重发同一次 RPC。 +- 子 worker 成功但响应在网络中丢失:结果视为未知;flat 进入原 fallback,complex 失败。 +- 子 worker 不得反向 complete / fail 父 job,也不得写画布、业务资源或账单。 + +### 7.3 动画首版范围 + +动画继续让最多 `48` 个 frame future 提交内部 HTTP,并保持当前 collect / drain 行为:所有已提交的帧调用都等待自己的成功、失败或 deadline 后,父流程再按稳定帧序号选择根因并失败。首版不增加 `groupId`、跨请求 registry 或 cancel endpoint,也不宣称“某一帧失败后立即取消仍在 worker 排队的兄弟帧”。 + +这是为了把改动限制在低层 BgFilter 调用和调度服务。若真实观测证明排队兄弟帧浪费严重,可在第二阶段增加纯内存 `groupId + CancellationToken`:取消尚未取得 permit 的请求,已经开始的 provider attempt 仍排空。该升级仍不需要数据库表,但需要同时修改父侧动画收集逻辑,因此不并入首版。 + +## 8. 业务语义保持 + +| 场景 | 内部响应 | 父流程行为 | +| --- | --- | --- | +| flat 单图 / 动画帧 | 图片二进制 | 继续现有 Alpha / 尺寸恢复、finalizer 和最终持久化 | +| flat,父业务预算仍有效 | `provider_exhausted / circuit_open / deadline_exceeded / overloaded / invalid_result / internal_error` 或内部断连 | 记录对应故障后进入现有“阿里云通用抠图 → 本地键色”;这些内部错误本身不都计入熔断 | +| flat | `cancelled`,或父 job cancellation / 绝对 deadline 已生效 | 立即向上退出,不再启动阿里云或本地 fallback | +| flat | `invalid_request / unauthorized` | 作为内部契约或部署配置错误失败,不 fallback、不计入 BgFilter 熔断 | +| complex 手动去背景 | 图片二进制 | 父流程继续最终 OSS、资源和画布写回 | +| complex 手动去背景 | 任意非成功或断连 | 父流程直接失败;不得接 flat fallback,不得修改 flat 熔断 | +| 角色 / 图标 / UI 后处理最终失败 | BgFilter 与 fallback 都未得到可用结果 | 保留已持久化 provider 原图,以现有 `completed + warning` 收口 | +| 动画任一帧最终失败 | 该帧完整 fallback / finalizer / PUT 仍失败 | 排空其它已提交帧后,整项动画按现有语义失败退款 | + +父侧移除现有 flat / complex BgFilter retry loop 和本地 flat 熔断,避免父侧两次 × 子 worker 两次变成四次。所有入口只替换共同的低层 BgFilter helper;这样 External v1 直接调用 `_for_owner` 的路径也会自然经过内部 worker。 + +BgFilter 成功二进制不是一份新的业务资产: + +- 不创建 raw object key,也没有 raw 结果 TTL。 +- 父侧按现有路径消费字节并写最终 OSS;手动 complex 只写最终结果一次。 +- 角色、图标、UI 的 provider 原图和动画源帧继续沿用现有对象生命周期,不归子 worker 清理。 + +## 9. 安全、内存与可观测性 + +### 9.1 内部安全 + +- 首版限定父 worker 与子 worker 同机部署,内部 listener 只绑定 loopback 固定端口,不挂公共 Axum router、Nginx、BFF 或 OpenAPI。 +- 使用独立内部 Token;缺失时生产 fail-closed。Token 不复用 BgFilter provider Token。 +- 只接受配置 bucket 下的规范化 object key;禁止 `http://`、`https://`、`data:`、`blob:` 和路径逃逸。 +- 不在日志、trace、metrics、错误 JSON 或 SpacetimeDB 审计中写签名 URL、Token、图片字节或 Base64。 + +### 9.2 图片与内存边界 + +父、子两侧都复用当前保护: + +- 响应体最大 `32 MiB`,有 `Content-Length` 和 chunked 两种路径都累计限长; +- 解码宽高最大 `8192 × 8192`,限制解码分配; +- MIME、魔数和实际解码结果必须一致; +- 空 body、截断 body 和超限图片按 `invalid_result` 处理。 + +二进制跨进程传输期间,子 worker 和父 worker 可能同时持有同一张图片。前述 `N / Q` permit 必须由 response-body guard 持有到发送完成或 body drop,才能把子 worker 中的完整成功 body 控制在 `N` 级;在此前提下,极端内存至少按 `2 × N × 32 MiB` 再加解码缓冲评估。若实现没有该 guard,完整 body 可能积累到 `Q` 级,本文的内存模型即不成立,不能上线。生产 `N / Q` 必须结合主机内存压测,而不是只看 BgFilter 吞吐。 + +### 9.3 指标与日志 + +最小指标: + +- `bgfilter_internal_waiting_requests` +- `bgfilter_internal_in_flight` +- `bgfilter_internal_request_seconds{mode,outcome}` +- `bgfilter_provider_http_seconds{mode,attempt,outcome}` +- `bgfilter_internal_request_total{mode,outcome}` +- `bgfilter_internal_response_bytes` +- `bgfilter_circuit_state` + +日志只写 `requestId`、父 job / request correlation、mode、attempt、排队耗时、provider 耗时、结果码和安全 object key;不得记录请求/响应图片 body。 + +当前 flat 的每次 provider 失败审计必须迁到子 worker,保留“第一次失败、第二次成功”也可观察的事实;内部 admission、鉴权和本地配置错误不伪装成 BgFilter provider 失败。 + +## 10. 实施与部署计划 + +### 10.1 实施顺序 + +1. 在现有 Rust 后端增加 `bgfilter-worker` 进程角色和独立 loopback Axum listener;它不启动用户 HTTP router,也不 claim `external_generation_job`。 +2. 增加内部 request / binary response / typed error 契约、Token 校验、JSON body 上限、object key allowlist 和健康检查;listener 在 body 解析前接入连接 / request concurrency limit、固定 backlog 和 load shedding。 +3. 增加 admission `Q`、`Semaphore(N)`、两次顺序 attempt、预算检查、结果限长 / 解码校验、response-body permit guard 和 flat 进程级熔断。 +4. 增加父侧共享内部 HTTP client。该 client 不自动重试,把父剩余预算显式转换为较短的 `requestBudgetMs` 和略长的 client timeout,并保证两者都早于父绝对 deadline。 +5. 用内部 client 替换两个集中调用边界: + - flat:`remove_editor_generated_screen_background_with_bgfilter_with_request_timeout`; + - complex:`request_editor_background_removal_image_with_retry`。 +6. 从父侧移除 BgFilter provider retry 和 flat 熔断实现;保留 flat fallback、complex 失败、Alpha / 尺寸恢复和所有最终持久化。 +7. 删除动画 `2000ms × frame_count` 单 attempt timeout 增量;保留现有所有帧 collect / drain。 +8. 扫描 queue、inline、External v1 的角色、图标、UI、动画和手动 complex 路径,确认没有 direct BgFilter HTTP 旁路。 +9. 增加单实例 systemd unit、内部地址 / Token、`N / Q` 配置、readiness 和指标;真实压测 `32 / 40 / 48` 帧后启用。 + +本计划不产生 SpacetimeDB schema、migration、bindings 或表目录改动。 + +### 10.2 部署与回滚 + +1. 先部署并启动唯一 `bgfilter-worker`,确认 loopback health、鉴权和 provider smoke。 +2. 再发布使用内部 client 的 API / external-generation-worker。 +3. 确认所有父进程只访问内部 endpoint,BgFilter provider 日志中不再出现父进程直连。 +4. 发布子 worker 时执行 `stop old -> 等待排空/退出 -> start new`,不得滚动重叠。 + +内部 worker 不可用时禁止自动 direct fallback。flat 仍可走业务已有阿里云 / 本地 fallback;complex 明确失败。需要整体回滚时回滚父、子进程版本和配置,不在运行中混用两种 BgFilter 调度方式。 + +## 11. 验收门禁 + +必须覆盖: + +- `48` 帧同时进入时,唯一子 worker 观测到的客户端 BgFilter HTTP future 峰值不超过 `N`。 +- listener 在 body 解析前执行 concurrency limit / load shedding;`queued + running + egress` 达到 `Q` 后新请求立即返回 `overloaded`,handler 数不超过 `Q`,内核 socket backlog 按独立固定值验证。 +- 排队时间计入 `requestBudgetMs`;deadline 到达后不开始新的 provider attempt。 +- `requestBudgetMs + parent transport window <= parent client timeout <= 父剩余绝对预算`,worker 有时间把类型化错误交回父侧。 +- 第一次失败后预算不足时不开始第二次;父侧从不重试整次内部 RPC。 +- 父业务预算仍有效时,flat 两次失败、熔断、overload、内部 RPC deadline 或断连仍走“阿里云 → 本地”;complex 任意失败直接失败且不读写熔断。 +- flat 熔断按真实失败 attempt 计数;由剩余业务预算截短的 timeout 不计入。已获准调用可完成第二次,后续排队请求快速 `circuit_open`。 +- `cancelled`、父 cancellation / 绝对 deadline、`invalid_request` 和 `unauthorized` 不启动 flat fallback;其它 flat 错误只在父业务预算仍有效时进入 fallback。 +- 客户端断连时,等待 permit 的请求最终由 deadline 收口;已开始 provider attempt 持有 permit 并排空。明确 cancellation 已被观察到后不再开始第二次,单纯 TCP 断连只作 best-effort 测试,不作为硬保证。 +- 动画全部已提交帧继续 collect / drain,根因按现有稳定帧序号收口;首版不存在未实现的 group cancellation 承诺。 +- 成功 body 为原始二进制而非 Base64;父、子两侧都拒绝空 body、MIME / 魔数不一致、chunked 超 `32 MiB` 和超过 `8192 × 8192` 的图片。 +- `N / Q` response-body guard 在发送完成或 body drop 前不释放,慢读 / 断连时完整成功 body 不积累到 `Q` 级。 +- worker 重启 / RPC 丢失不查询、不恢复结果;父 job 的 heartbeat、lease、失败退款和 fencing 保持现状。 +- External v1 / inline 不再直连 BgFilter;公共 router、BFF、账单和任务列表中没有内部 endpoint 或内部调用记录。 +- 生产不存在两个同时运行的 `bgfilter-worker`,配置缺失或 `N / Q = 0` 时 fail-closed。 + +实现后按范围运行: + +```bash +cargo test -p api-server bgfilter --manifest-path server-rs/Cargo.toml +cargo test -p api-server character_animation --manifest-path server-rs/Cargo.toml +cargo check -p api-server --manifest-path server-rs/Cargo.toml +npm run check:server-rs-ddd +npm run check:encoding +git diff --check +``` + +不需要运行 `npm run spacetime:generate` 或 `npm run check:spacetime-schema`,因为本计划明确不修改 SpacetimeDB。 + +## 12. 实施前需冻结的参数 + +架构边界已固定:首版单实例、无 QPS、无数据库任务表、无 checkpoint / continuation、输出直接走内部二进制响应。 + +编码前只需冻结两个容量值: + +- 生产 `GENARRATIVE_BGFILTER_WORKER_CONCURRENCY = N`。 +- 生产 `GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS = Q`。若要求一个满帧动画不因自身 admission 被拒绝,`Q` 至少为 `48`;候选 `128` 可容纳两个满帧动画并留少量余量,但明确不能覆盖 controller 理论最大 `768` 次同时提交,超出部分会按 mode fallback 或失败。最终值以可接受的 overload 行为和内存压测为准。 + +其余首版固定边界:provider 单 attempt 默认 `180s`,内部响应最大 `32 MiB / 8192 × 8192`,内部 listener 只绑定 loopback 且必须鉴权。若要多实例、严格服务端全局并发、QPS 或动画 peer cancellation,应先升级本文,不在编码中临时扩 scope。 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index 4eb3b79dc..a7fb40fcb 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -2,7 +2,9 @@ > 2026-07-18 退役覆盖:旧创作模板 job 类型、玩法写回和玩法恢复链路均已退出现役 worker。当前 worker 只领取 `source_module = editor-canvas` 的任务;本文涉及拼图、跳一跳、拼消消、敲木鱼等玩法的内容仅作为历史设计记录,历史队列行不得被领取或改写。 -更新时间:`2026-07-15` +> 2026-07-21 待实施专题:BgFilter 作为受限内部资源,将成为“单用户动作一个外部生成 job”的受控内部例外。用户可见层仍只有父 `external_generation_job`;父 future 保持原 lease 和 attempt,在当前调用栈内同步请求唯一 `bgfilter-worker` 的内部 HTTP,成功图片字节直接返回父流程。首版不新增 SpacetimeDB 子任务表、父 checkpoint / continuation 或 raw 中间结果 OSS。完整边界见 [`BgFilter 受限资源调度方案(同步内部 HTTP 原地等待版)`](./【后端架构】BgFilter受限资源调度方案-2026-07-21.md)。 + +更新时间:`2026-07-21` ## 背景 -- 2.52.0 From 566f2f219447f46057a7e2ea1454037a51f7f91d Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 21 Jul 2026 12:05:27 +0000 Subject: [PATCH 08/27] =?UTF-8?q?=E8=90=BD=E5=9C=B0=20BgFilter=20=E5=8D=95?= =?UTF-8?q?=E5=AE=9E=E4=BE=8B=E5=8F=97=E9=99=90=E8=B5=84=E6=BA=90=20Worker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增私有 BgFilter worker,提供内部鉴权、并发限流、超时、顺序重试、flat 熔断和图片校验。 父生成流程改为通过内部二进制 HTTP 原地等待,并保留 flat 降级与 complex 失败语义。 接入本地开发、systemd、生产部署、Provision、健康巡检和配置漂移门禁。 补充动画、部署与运维测试、Linux fixture 隔离以及对应架构文档。 --- .env.example | 11 + .../SKILL.md | 39 +- README.md | 6 +- deploy/env/api-server.env.example | 9 +- deploy/env/bgfilter-worker.env.example | 18 + deploy/env/health-patrol.env.example | 1 + .../genarrative-bgfilter-worker.service | 30 + .../shared-memory/decision-log.md | 5 +- .../shared-memory/development-workflow.md | 19 +- docs/project-memory/shared-memory/pitfalls.md | 8 + ...架构】BgFilter受限资源调度方案-2026-07-21.md | 98 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 39 +- package.json | 1 + scripts/check-production-api-deploy.mjs | 525 +++- scripts/check-production-api-release.mjs | 36 + scripts/check-production-health-patrol.mjs | 15 + scripts/check-production-ops-guardrails.mjs | 44 + scripts/deploy/production-api-deploy.sh | 344 +- scripts/dev-stack-port-utils.mjs | 6 +- scripts/dev-stack-port-utils.test.ts | 9 +- scripts/dev.mjs | 358 ++- scripts/dev.test.ts | 173 +- scripts/jenkins-server-provision.sh | 352 ++- scripts/ops/production-health-patrol.mjs | 16 + .../crates/api-server/src/bgfilter_worker.rs | 2775 +++++++++++++++++ .../src/character_animation_assets.rs | 68 +- server-rs/crates/api-server/src/config.rs | 137 + .../crates/api-server/src/editor_project.rs | 1236 +++----- .../src/editor_screen_background_decision.rs | 1 + .../api-server/src/external_api_audit.rs | 5 + server-rs/crates/api-server/src/main.rs | 134 +- server-rs/crates/api-server/src/state.rs | 82 +- 32 files changed, 5570 insertions(+), 1030 deletions(-) create mode 100644 deploy/env/bgfilter-worker.env.example create mode 100644 deploy/systemd/genarrative-bgfilter-worker.service create mode 100644 server-rs/crates/api-server/src/bgfilter_worker.rs diff --git a/.env.example b/.env.example index 4b5ed9164..ccd41510e 100644 --- a/.env.example +++ b/.env.example @@ -143,6 +143,17 @@ ALIYUN_OSS_POST_EXPIRE_SECONDS="600" ALIYUN_OSS_POST_MAX_SIZE_BYTES="20971520" ALIYUN_OSS_SUCCESS_ACTION_STATUS="200" +# BgFilter 受限资源 worker。父 api-server / external-generation-worker 与唯一的 +# `GENARRATIVE_PROCESS_ROLE=bgfilter-worker` 进程必须使用同一个内部 Token。 +# 本地需要真实联调 BgFilter 时,在第二个终端启动专用进程;不要让 `all` 角色兼任它。 +GENARRATIVE_BGFILTER_WORKER_HOST="127.0.0.1" +GENARRATIVE_BGFILTER_WORKER_PORT="8083" +GENARRATIVE_BGFILTER_WORKER_BASE_URL="http://127.0.0.1:8083" +GENARRATIVE_BGFILTER_INTERNAL_TOKEN="CHANGE_ME_FOR_LOCAL" +GENARRATIVE_BGFILTER_WORKER_CONCURRENCY="4" +GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS="128" +GENARRATIVE_BGFILTER_WORKER_CONNECT_TIMEOUT_MS="2000" + # SpacetimeDB 数据目录备份到 OSS。备份 bucket 可与资源 bucket 分离;未设置时脚本回退使用 ALIYUN_OSS_BUCKET。 GENARRATIVE_DATABASE_BACKUP_DATA_DIR="" GENARRATIVE_DATABASE_BACKUP_WORK_DIR="" diff --git a/.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md b/.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md index 18c2ebe05..18b097648 100644 --- a/.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md +++ b/.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md @@ -1,8 +1,8 @@ --- name: genarrative-dev-stack-port-routing short_description: 修改 Genarrative 本地 dev 启动端口、代理目标、端口冲突处理时使用。 -description: 在 Genarrative 中修改 npm run dev / dev:spacetime / dev:api-server / dev:web / dev:admin-web 的本地启动端口、端口可用性探测、端口漂移、SpacetimeDB publish server、api-server 环境变量、Vite 代理目标和后台 admin-web 启动串联时使用。 -version: 1.0.0 +description: 在 Genarrative 中修改 npm run dev / dev:spacetime / dev:api-server / dev:bgfilter-worker / dev:web / dev:admin-web 的本地启动端口、端口可用性探测、端口漂移、SpacetimeDB publish server、Rust 进程环境变量、Vite 代理目标和后台 admin-web 启动串联时使用。 +version: 1.1.0 author: Hermes Agent license: MIT metadata: @@ -13,7 +13,7 @@ metadata: # Genarrative 本地 dev 启动端口与代理目标串联流程 -用于维护 Genarrative 本地开发栈启动脚本,重点覆盖 `npm run dev` 与四个 `dev:*` 单模块命令的端口检查、端口漂移和后续流程目标传递。 +用于维护 Genarrative 本地开发栈启动脚本,重点覆盖 `npm run dev` 与五个 `dev:*` 单模块命令的端口检查、端口漂移和后续流程目标传递。 ## 适用场景 @@ -31,40 +31,44 @@ metadata: 2. Rust `api-server`:`8082`,健康检查为 `http://127.0.0.1:/healthz`。 3. SpacetimeDB standalone:`3101`,健康检查为 `http://127.0.0.1:/v1/ping`。 4. 后台 Vite:`3102`,后台地址为 `http://127.0.0.1:/admin/`。 +5. 独立 BgFilter worker:`8083`,就绪检查为 `http://127.0.0.1:/readyz`。 端口不可用时,脚本会从优先端口开始向后寻找可用端口。后续流程必须以解析后的实际端口为准,不能继续使用默认端口。 -Linux 多用户并发开发时,`GENARRATIVE_DEV_PORT_RANGE` 或 `--port-range` 会先向系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json` 申请一个端口段,再把该段映射为 `web = start`、`api = start + 1`、`spacetime = start + 2`、`adminWeb = start + 3`。注册表锁文件是 `/var/tmp/genarrative-dev-port-ranges/registry.lock`,可通过 `GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR` 覆盖目录。自动分配从 `10000-10099` 起,每次占用 100 个端口块,后续块按 `10100-10199`、`10200-10299` 递增;当前口径是“一个用户固定占用一个段,后续启动继续复用这段并在段内漂移”;该注册表只在 Linux 上生效;Windows 继续沿用原有端口探测、漂移和复用逻辑,不读系统级注册表。 +Linux 多用户并发开发时,`GENARRATIVE_DEV_PORT_RANGE` 或 `--port-range` 会先向系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json` 申请一个端口段,再把该段映射为 `web = start`、`api = start + 1`、`spacetime = start + 2`、`adminWeb = start + 3`、`bgfilterWorker = start + 4`。注册表锁文件是 `/var/tmp/genarrative-dev-port-ranges/registry.lock`,可通过 `GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR` 覆盖目录。自动分配从 `10000-10099` 起,每次占用 100 个端口块,后续块按 `10100-10199`、`10200-10299` 递增;当前口径是“一个用户固定占用一个段,后续启动继续复用这段并在段内漂移”;该注册表只在 Linux 上生效;Windows 继续沿用原有统一端口探测和漂移逻辑,不读系统级注册表。 ## 实现入口 - `package.json` - - `dev`:执行 `node scripts/dev.mjs`,启动完整四模块。 - - `dev:spacetime` / `dev:api-server` / `dev:web` / `dev:admin-web`:执行 `node scripts/dev.mjs `。 + - `dev`:执行 `node scripts/dev.mjs`,启动完整五服务。 + - `dev:spacetime` / `dev:api-server` / `dev:bgfilter-worker` / `dev:web` / `dev:admin-web`:执行 `node scripts/dev.mjs `;`dev:api-server` 会安全带起其依赖的 BgFilter worker。 - `scripts/dev-stack-port-utils.mjs` - `isPortAvailable(...)`:探测端口是否可监听。 - `findAvailablePort(...)`:从优先端口向后寻找可用端口,`0` 表示申请临时端口。 - - `resolveDevStackPorts(...)`:一次性解析 SpacetimeDB、api-server、主站 Vite、后台 Vite 端口,并避免本次解析结果互相冲突。 + - `resolveDevStackPorts(...)`:一次性解析 SpacetimeDB、api-server、主站 Vite、后台 Vite、BgFilter worker 端口,并避免本次解析结果互相冲突。 - Linux 注册表分配:`reserveLinuxDevPortRange(...)` / `releaseLinuxDevPortRange(...)`,仅在 Linux 上启用系统级端口段登记与用户段复用,自动分配从 `10000-10099` 起。 - - CLI 模式:`node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:3101 api:127.0.0.1:8082 web:0.0.0.0:3000 adminWeb:127.0.0.1:3102`。 + - CLI 模式:`node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:3101 api:127.0.0.1:8082 web:0.0.0.0:3000 adminWeb:127.0.0.1:3102 bgfilterWorker:127.0.0.1:8083`。 - `scripts/dev.mjs` - 解析 CLI 参数后统一计算 client host、端口、`SPACETIME_SERVER`、`RUST_SERVER_TARGET`。 - - 完整栈按 SpacetimeDB、publish、api-server、主站 Vite、后台 Vite 顺序启动。 - - Linux 下会先申请系统级端口段并把它映射成四个 dev 端口;自动分配从 `10000-10099` 起,Windows 则直接沿用原有参数解析与端口漂移逻辑。 + - 完整栈按 SpacetimeDB、publish、BgFilter worker readiness、api-server readiness、主站 Vite、后台 Vite 顺序启动。 + - Linux 下会先申请系统级端口段并把它映射成五个 dev 端口;自动分配从 `10000-10099` 起,Windows 则把第五个服务纳入原有统一参数解析与端口漂移逻辑。 + - 完整栈和 `dev:api-server` 把两个 Rust 进程作为同一重启单元,先全部停止,再先启动 BgFilter worker、后启动 api-server;不要为同一份 Rust 源码创建两个并发 `cargo` watcher。 - 单模块命令复用同一套参数和 env 解析。 ## 必须保持的传递链路 -`npm run dev` 和四个 `dev:*` 单模块命令中端口解析后,必须同步到以下位置: +`npm run dev` 和五个 `dev:*` 单模块命令中端口解析后,必须同步到以下位置: 1. SpacetimeDB 启动:`spacetime start --listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}"`。 2. SpacetimeDB 发布:`spacetime publish ... --server "${SPACETIME_SERVER}"`。 3. Rust api-server:`GENARRATIVE_API_HOST`、`GENARRATIVE_API_PORT`、`GENARRATIVE_SPACETIME_SERVER_URL`、`GENARRATIVE_SPACETIME_DATABASE`。 4. api-server 健康检查:`wait_for_api_server "${RUST_SERVER_TARGET}/healthz" ...`。 -5. 主站 Vite:`RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET`、`ADMIN_WEB_TARGET`、`ADMIN_WEB_PORT`、`--port=${WEB_PORT}`、`--host=${WEB_HOST}`。 -6. 后台 Vite:`ADMIN_API_TARGET`、`GENARRATIVE_API_TARGET`、`GENARRATIVE_API_PORT`、`--port=${ADMIN_WEB_PORT}`。 -7. 控制台日志:`[dev:ports]` 和 `[dev] web/admin web/api-server/spacetime` 必须显示最终实际地址。 -8. Linux 端口段注册:`[dev] port-range:` 与 `[dev] port-range-registry:` 只在 Linux 输出,Windows 不应依赖系统级注册表。 +5. BgFilter worker:`GENARRATIVE_PROCESS_ROLE=bgfilter-worker`、解析后的 `HOST / PORT`、与父 API 相同的 `GENARRATIVE_BGFILTER_WORKER_BASE_URL` / `GENARRATIVE_BGFILTER_INTERNAL_TOKEN`,以及显式有效的 `N / Q`。 +6. BgFilter worker readiness:父 API 启动前检查解析后地址的 `/readyz`。 +7. 主站 Vite:`RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET`、`ADMIN_WEB_TARGET`、`ADMIN_WEB_PORT`、`--port=${WEB_PORT}`、`--host=${WEB_HOST}`。 +8. 后台 Vite:`ADMIN_API_TARGET`、`GENARRATIVE_API_TARGET`、`GENARRATIVE_API_PORT`、`--port=${ADMIN_WEB_PORT}`。 +9. 控制台日志:`[dev:ports]` 和 `[dev] web/admin web/api-server/bgfilter-worker/spacetime` 必须显示最终实际地址。 +10. Linux 端口段注册:`[dev] port-range:` 与 `[dev] port-range-registry:` 只在 Linux 输出,Windows 不应依赖系统级注册表。 如果只改了其中一段,通常会出现:浏览器打开的前端可用,但 `/api/*` 代理到旧端口;后台页面可用但后台 API 失败;SpacetimeDB 启动在新端口但 publish 仍发往旧端口。 @@ -91,14 +95,14 @@ Linux 多用户并发开发时,`GENARRATIVE_DEV_PORT_RANGE` 或 `--port-range` node --check scripts/dev.mjs npm run test -- scripts/dev-stack-port-utils.test.ts npm run check:encoding -node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:0 api:127.0.0.1:0 web:0.0.0.0:0 adminWeb:127.0.0.1:0 +node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:0 api:127.0.0.1:0 web:0.0.0.0:0 adminWeb:127.0.0.1:0 bgfilterWorker:127.0.0.1:0 ``` 端口冲突回归测试建议: 1. 用测试或临时 Node server 占用某个优先端口。 2. 调用 `findAvailablePort`,断言结果大于被占用端口。 -3. 调用 `resolveDevStackPorts`,断言四个结果互不相同。 +3. 调用 `resolveDevStackPorts`,断言五个结果互不相同。 4. 如果实际启动完整栈,观察控制台: - `[dev:ports] ... 不可用,改用 ...` - `[dev] api-server: http://...:` @@ -122,6 +126,7 @@ node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:0 ap - [ ] Linux 注册表分配、同用户复用固定段并继续漂移、自动分配从 `10000-10099` 起、Windows bypass 都有测试覆盖。 - [ ] `scripts/dev.mjs` 通过 `node --check`。 - [ ] `npm run dev` 的 SpacetimeDB、publish、api-server、主站 Vite、后台 Vite 都使用实际端口。 +- [ ] BgFilter worker 在 api-server 前 ready,父子共享实际 base URL / Token,Rust watch 只触发一次组合重启。 - [ ] `npm run dev:web` 在主站端口不可用时能切换到可用端口。 - [ ] 文档同步更新 `docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md`。 - [ ] 长期踩坑同步更新 `docs/project-memory/shared-memory/pitfalls.md`。 diff --git a/README.md b/README.md index 71aa2ac89..d5ee527a1 100644 --- a/README.md +++ b/README.md @@ -44,10 +44,10 @@ npm run dev 补充说明: -- `npm run dev` 会启动 SpacetimeDB standalone、Rust `api-server`、主站 Vite 与后台 Vite,适合完整联调。 +- `npm run dev` 会启动 SpacetimeDB standalone、独立 `bgfilter-worker`、Rust `api-server`、主站 Vite 与后台 Vite,适合完整联调;内部 worker ready 后才启动 API。 - 主站默认地址是 `http://127.0.0.1:3000`,后台可从 `http://127.0.0.1:3000/admin/` 进入,也可直连 `http://127.0.0.1:3102`。 -- 四个模块可独立启动:`npm run dev:spacetime`、`npm run dev:api-server`、`npm run dev:web`、`npm run dev:admin-web`。 -- 如需自动刷新后端模块,使用 `npm run dev -- --watch`;其中 `spacetime-module` 改动后只会重新发布模块,不会重启 standalone,`api-server` 改动后会重启 Rust 进程。主站和后台前端源码变化交给 Vite 自身 HMR,不由外层 watcher 重启。非 watch 模式下可在 `npm run dev` 终端输入 `rs api-server`、`rs web`、`rs admin-web`、`rs spacetime` 或 `rs all`,其中 `rs spacetime` 也是只重新发布模块。 +- 五个模块可独立启动:`npm run dev:spacetime`、`npm run dev:api-server`、`npm run dev:bgfilter-worker`、`npm run dev:web`、`npm run dev:admin-web`;其中 `dev:api-server` 会安全带起同 runner 的 BgFilter worker 依赖。 +- 如需自动刷新后端模块,使用 `npm run dev -- --watch`;其中 `spacetime-module` 改动后只会重新发布模块,不会重启 standalone,Rust 源码改动会把 `api-server` 与 `bgfilter-worker` 作为一个组合单元重启。主站和后台前端源码变化交给 Vite 自身 HMR,不由外层 watcher 重启。非 watch 模式下可在 `npm run dev` 终端输入 `rs api-server`、`rs bgfilter-worker`、`rs web`、`rs admin-web`、`rs spacetime` 或 `rs all`,其中 `rs spacetime` 也是只重新发布模块。 构建生产包: diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example index c254b1868..830f0e0db 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -17,6 +17,10 @@ GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS=2000 GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=600 GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS=900 GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS=1800 +# 父流程只访问同机 BgFilter worker;内部 Token 只通过受保护文件共享,不写明文 env。 +GENARRATIVE_BGFILTER_WORKER_BASE_URL=http://127.0.0.1:8083 +GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE=/etc/genarrative/secrets/bgfilter-worker.token +GENARRATIVE_BGFILTER_WORKER_CONNECT_TIMEOUT_MS=2000 GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512 GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS=16 GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS=5000 @@ -30,9 +34,10 @@ 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 +# 共享的单次 provider attempt 超时上限,父侧据此计算两次逻辑预算;专属 worker env 不重复定义。 GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=180000 -GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3 -GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300 +GENARRATIVE_EDITOR_BGFILTER_BASE_URL=http://58.87.105.82/bgfilter +GENARRATIVE_EDITOR_BGFILTER_TOKEN= # BgFilter 失败后的中间兜底:阿里云通用抠图(SegmentCommonImage)。AccessKey 留空则跳过该层, # BgFilter 失败直接本地 editor_green_screen 去背;填入后恢复 BgFilter→阿里云→本地三级兜底。 # AccessKey 也可复用标准 SDK 命名 ALIBABA_CLOUD_ACCESS_KEY_ID / ALIBABA_CLOUD_ACCESS_KEY_SECRET。 diff --git a/deploy/env/bgfilter-worker.env.example b/deploy/env/bgfilter-worker.env.example new file mode 100644 index 000000000..748544c40 --- /dev/null +++ b/deploy/env/bgfilter-worker.env.example @@ -0,0 +1,18 @@ +# 复制到 /etc/genarrative/bgfilter-worker.env;只放专用进程独占参数和可选日志覆盖。 +# provider、OSS、内部 Token 文件和请求 timeout 统一来自先加载的 api-server.env,禁止在此重复定义。 +# systemd unit 会强制设置 GENARRATIVE_PROCESS_ROLE=bgfilter-worker。 + +GENARRATIVE_ENV=production +GENARRATIVE_BGFILTER_WORKER_HOST=127.0.0.1 +GENARRATIVE_BGFILTER_WORKER_PORT=8083 +GENARRATIVE_BGFILTER_WORKER_CONCURRENCY=4 +GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS=128 +# flat 熔断只由本进程维护;complex 不读写熔断状态。 +GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3 +GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300 + +GENARRATIVE_API_LOG=info,tower_http=info +GENARRATIVE_OTEL_ENABLED=true +OTEL_SERVICE_NAME=genarrative-bgfilter-worker +OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 +OTEL_RESOURCE_ATTRIBUTES=deployment.environment=production,service.namespace=genarrative diff --git a/deploy/env/health-patrol.env.example b/deploy/env/health-patrol.env.example index b1b9799e5..524006f28 100644 --- a/deploy/env/health-patrol.env.example +++ b/deploy/env/health-patrol.env.example @@ -2,6 +2,7 @@ # 默认不启用 Pingora shadow 巡检;只有同时配置 base URL 与 probe token 才会检查。 GENARRATIVE_HEALTH_PATROL_API_BASE_URL=http://127.0.0.1:8082 +GENARRATIVE_HEALTH_PATROL_BGFILTER_BASE_URL=http://127.0.0.1:8083 GENARRATIVE_HEALTH_PATROL_SPACETIME_BASE_URL=http://127.0.0.1:3101 GENARRATIVE_HEALTH_PATROL_PUBLIC_BASE_URL=http://127.0.0.1 # 默认公网入口仍按 Nginx 巡检;Pingora 直连切换后改为 pingora-direct。 diff --git a/deploy/systemd/genarrative-bgfilter-worker.service b/deploy/systemd/genarrative-bgfilter-worker.service new file mode 100644 index 000000000..63617fe1d --- /dev/null +++ b/deploy/systemd/genarrative-bgfilter-worker.service @@ -0,0 +1,30 @@ +[Unit] +Description=Genarrative BgFilter Worker +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=genarrative +Group=genarrative +WorkingDirectory=/opt/genarrative/current +EnvironmentFile=/etc/genarrative/api-server.env +EnvironmentFile=/etc/genarrative/bgfilter-worker.env +Environment="LD_LIBRARY_PATH=/opt/genarrative/openssl-3.2.0/lib64:/opt/genarrative/openssl-3.2.0/lib" +ExecStart=/usr/bin/env GENARRATIVE_PROCESS_ROLE=bgfilter-worker OTEL_SERVICE_NAME=genarrative-bgfilter-worker /opt/genarrative/current/api-server +Restart=always +RestartSec=5 +KillSignal=SIGINT +# 内部 requestBudgetMs 协议上限为 600s;额外窗口用于响应发送和进程收口。 +TimeoutStopSec=900 +LimitNOFILE=65535 +TasksMax=2048 + +# 固定 loopback 地址与非模板 unit 共同保证首版同机只运行一个 BgFilter worker。 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ReadWritePaths=/opt/genarrative /var/lib/genarrative + +[Install] +WantedBy=multi-user.target diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c5939a2ab..f9a022349 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -20,8 +20,9 @@ - 背景:角色动画在单个 `external_generation_job` 内通过 `buffer_unordered(frame_count)` 可并发发射最多 `48` 次 BgFilter 请求;限制父 worker 并发不能限制单个父 job 内的实际 BgFilter 并发。父 job checkpoint / continuation 和 SpacetimeDB 持久子任务都会扩大父状态机、attempt、计费、恢复和清理改动,而当前 BgFilter 成功结果本来就是 HTTP 图片二进制。 - 决策:父 future 保持原调用栈、lease 和 attempt,等待期间继续占用通用 worker 槽并由现有 heartbeat 续租;所有调用统一同步请求唯一 `bgfilter-worker` 的内部 loopback HTTP。输入只传 OSS object key、参数和剩余预算,成功直接返回经过校验的图片二进制。子 worker 使用有界 admission `Q` 和进程内 `Semaphore(N)`,负责最多两次顺序 provider attempt、flat 进程级熔断和失败审计;父流程继续负责 flat 降级、complex 失败、Alpha / 尺寸恢复、动画 finalizer、最终 OSS、业务写回、计费和父终态。 -- 超时边界:父 job 总预算仍为普通 `900s` / 长任务 `1800s`,并保留现有 `60s` 终态写回窗口;内部 RPC 总预算包含等待 semaphore、两次 attempt、结果校验和二进制返回,子 worker 的单次 provider timeout 默认 `180s`。动画删除旧的 `2000ms × frame_count` 单次 timeout 增量。父侧不得在内部 timeout / 断连后重试整次 RPC。 +- 超时边界:父 job 总预算仍为普通 `900s` / 长任务 `1800s`,并保留现有 `60s` 终态写回窗口;`180s` 仅是子 worker 单次真实 provider attempt 的默认上限,父侧据此派生 `2 × attempt timeout + 1s response window` 的逻辑调用上限,再按父绝对 deadline 和 flat fallback reserve 截短。内部 RPC 总预算包含 admission 后的 semaphore 等待、最多两次 attempt、结果校验和二进制返回。动画删除旧的 `2000ms × frame_count` 单次 timeout 增量,父侧不得在内部 timeout / 断连后重试整次 RPC。 - 故障边界:首版不新增 `bgfilter_task_group`、`bgfilter_request_task`、raw OSS、checkpoint、continuation、数据库 capacity slot、共享熔断或 QPS token bucket。父或子进程崩溃、RPC 丢失时不查询、不恢复结果;父 job 沿用现有 lease / `max_attempts=1` 失败退款语义。动画首版保持所有已提交帧 collect / drain,不增加跨帧取消组。 +- 部署边界:专用进程首版仍复用完整 `AppState`,因此 systemd unit 先加载共享 `/etc/genarrative/api-server.env`,再加载 `/etc/genarrative/bgfilter-worker.env` 覆盖 worker 独占参数;共享 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 必须在父子进程保持同一有效值,flat 熔断 threshold / cooldown 只归子 worker。发布切换前校验父子使用同一个非空、非符号链接、`root:genarrative 0440` 的内部 Token 文件;拒绝 `external-generation-worker.env` 覆盖出不同的内部 URL、Token 文件、connect timeout、provider timeout、OSS bucket 或 endpoint(同 bucket 的独立 AK 允许),并要求父 base URL、子 `HOST / PORT` 与 readiness URL 指向同一 loopback endpoint。worker unit 使用 `TimeoutStopSec=900` 覆盖最大 `600s` 请求预算的优雅排空,运行期巡检同时检查唯一 worker unit active 与 loopback readiness。本地 `npm run dev` 同样启动独立子进程并解析第五个 dev 端口,`ProcessRole::All` 不内嵌 listener。 - 影响范围:后续实现涉及 `api-server` 内部 HTTP client、专用 `bgfilter-worker` listener / process role、并发与超时配置、部署和运维观测;不修改 SpacetimeDB schema、父 job schema、用户任务 DTO、任务列表或收费归属。 - 验证方式:`48` 帧并发进入父 future 时,健康唯一子 worker 进程持有的 BgFilter HTTP future 峰值不得超过生产显式配置的 `N`,且 `queued + running` 不超过 `Q`;覆盖 flat / complex 降级矩阵、内部 deadline、断连后已启动请求排空、动画全帧 drain、External v1 / inline 旁路扫描、二进制大小 / MIME / 尺寸门禁、单实例部署、DDD、编码和 diff 门禁。 - 关联文档:`docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 @@ -1730,7 +1731,7 @@ ## 2026-05-30 Linux 本地 dev 端口段按系统级注册表分配 - 背景:同一台 Linux 开发机上有多个用户同时跑 `npm run dev` 时,单纯靠各自 `GENARRATIVE_DEV_PORT_RANGE` 容易撞段,且同一用户并发起两个 dev 会话时也会把相同端口段重复拿走。 -- 决策:Linux 上的本地 dev 端口段分配统一收口到系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json`,锁文件为 `/var/tmp/genarrative-dev-port-ranges/registry.lock`,可通过 `GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR` 覆盖目录。未手动指定时自动从 `10000-10099` 开始按 100 端口块分配,后续块按 `10100-10199`、`10200-10299` 递增;端口段映射固定为 `web = start`、`api = start + 1`、`spacetime = start + 2`、`admin-web = start + 3`;注册表会拒绝不同用户的相同或重叠段,并让同一用户后续启动继续复用自己已占用的固定段。`GENARRATIVE_DEV_PORT_RANGE` 与 `--port-range` 仍可手动指定端口段,但只在 Linux 生效,Windows 继续沿用原有端口探测与漂移逻辑,不读注册表。 +- 决策:Linux 上的本地 dev 端口段分配统一收口到系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json`,锁文件为 `/var/tmp/genarrative-dev-port-ranges/registry.lock`,可通过 `GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR` 覆盖目录。未手动指定时自动从 `10000-10099` 开始按 100 端口块分配,后续块按 `10100-10199`、`10200-10299` 递增;端口段最初映射为 `web = start`、`api = start + 1`、`spacetime = start + 2`、`admin-web = start + 3`,2026-07-21 按顶部 BgFilter 决策扩展 `bgfilter-worker = start + 4`;注册表会拒绝不同用户的相同或重叠段,并让同一用户后续启动继续复用自己已占用的固定段。`GENARRATIVE_DEV_PORT_RANGE` 与 `--port-range` 仍可手动指定端口段,但只在 Linux 生效,Windows 继续沿用统一端口探测与漂移逻辑,不读注册表。 - 影响范围:`scripts/dev-stack-port-utils.mjs`、`scripts/dev.mjs`、`scripts/dev-stack-port-utils.test.ts`、`scripts/dev.test.ts`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、本条决策记录、`development-workflow.md`。 - 验证方式:`node --check scripts/dev-stack-port-utils.mjs`、`node --check scripts/dev.mjs`、`node node_modules/vitest/vitest.mjs run scripts/dev-stack-port-utils.test.ts scripts/dev.test.ts` 通过;Linux 下能看到 `[dev] port-range:` 与 `registry.json` 路径日志,自动分配从 `10000-10099` 起步,Windows 不出现注册表分配日志。 - 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 5630daee9..b84a63b4a 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -51,18 +51,19 @@ npm install npm run dev ``` -Linux 多用户共享同一台机器开发时,本地 dev 脚本会为当前 Linux 用户分配一个固定端口段并写入系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json`,自动分配从 `10000-10099` 开始,每段 100 个端口,四个 dev 服务依次使用 `start` 到 `start + 3`。可用 `GENARRATIVE_DEV_PORT_RANGE` 或 `npm run dev -- --port-range` 手动指定端口段用于特殊场景;注册表会阻止不同用户使用相同或重叠段,并让同一用户后续启动继续复用自己已占用的固定段。该机制只在 Linux 生效,Windows 仍沿用原有端口探测与漂移逻辑。 +Linux 多用户共享同一台机器开发时,本地 dev 脚本会为当前 Linux 用户分配一个固定端口段并写入系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json`,自动分配从 `10000-10099` 开始,每段 100 个端口,五个 dev 服务依次使用 `start` 到 `start + 4`,其中 BgFilter worker 固定为 `start + 4`。可用 `GENARRATIVE_DEV_PORT_RANGE` 或 `npm run dev -- --port-range` 手动指定端口段用于特殊场景;注册表会阻止不同用户使用相同或重叠段,并让同一用户后续启动继续复用自己已占用的固定段。该机制只在 Linux 生效,Windows 把第五个服务纳入原有统一端口探测与漂移逻辑。 -本地 `npm run dev`、`npm run dev:spacetime` 和 `npm run dev:api-server` 会在 Rust 子进程环境中绕过项目默认 `sccache` wrapper,避免损坏的本机 cache daemon 阻断 `spacetime publish` 或 `api-server` 启动;显式设置的非 sccache 自定义 wrapper 会被保留。生产 / Jenkins 构建仍按流水线自身的 sccache 策略执行。 +本地 `npm run dev`、`npm run dev:spacetime`、`npm run dev:api-server` 和 `npm run dev:bgfilter-worker` 会在 Rust 子进程环境中绕过项目默认 `sccache` wrapper,避免损坏的本机 cache daemon 阻断 `spacetime publish` 或 Rust 服务启动;显式设置的非 sccache 自定义 wrapper 会被保留。生产 / Jenkins 构建仍按流水线自身的 sccache 策略执行。 该命令会启动: - SpacetimeDB standalone +- 独立 `bgfilter-worker` - Rust `api-server` - 主站 Vite - 后台 Vite -`npm run dev` 和单模块 `dev:*` 命令会更新根目录 `.app/dev-stack.json`,记录四个本地服务的 pid、端口、URL、启动状态和当前命令。该目录只作本机运行态观测,不提交 Git。 +`npm run dev` 和单模块 `dev:*` 命令会更新根目录 `.app/dev-stack.json`,记录五个本地服务的 pid、端口、URL、启动状态和当前命令。该目录只作本机运行态观测,不提交 Git。 开启自动刷新: @@ -70,9 +71,9 @@ Linux 多用户共享同一台机器开发时,本地 dev 脚本会为当前 Li npm run dev -- --watch ``` -watch 模式只由外层调度器自动处理后端侧刷新:`spacetime-module` 改动后重新发布模块但不重启 standalone 宿主,`api-server` 改动后重启 Rust 进程。主站 Vite 与后台 Vite 的源码变化交给 Vite 自身 HMR,避免外层 watcher 监听到依赖缓存或临时文件后循环重启。 +watch 模式只由外层调度器自动处理后端侧刷新:`spacetime-module` 改动后重新发布模块但不重启 standalone 宿主;完整栈和 `dev:api-server` 只创建一套 Rust watcher,改动后先停止 API 与 BgFilter worker,再先启动并验活 worker、最后启动并验活 API。主站 Vite 与后台 Vite 的源码变化交给 Vite 自身 HMR,避免外层 watcher 监听到依赖缓存或临时文件后循环重启。 -非 watch 模式下,`npm run dev` 终端支持输入 `rs spacetime`、`rs api-server`、`rs web`、`rs admin-web` 或 `rs all`。其中 `rs spacetime` 只会重新发布 `spacetime-module`,不会重启 standalone 宿主;其他模块仍按进程重启。 +非 watch 模式下,`npm run dev` 终端支持输入 `rs spacetime`、`rs api-server`、`rs bgfilter-worker`、`rs web`、`rs admin-web` 或 `rs all`。其中 `rs spacetime` 只会重新发布 `spacetime-module`,不会重启 standalone 宿主;重启任一 Rust 角色都会走 API / BgFilter worker 组合重启。 单独启动 SpacetimeDB: @@ -86,6 +87,12 @@ npm run dev:spacetime npm run dev:api-server ``` +该命令会由同一 runner 自动带起独立 BgFilter worker,确保共享实际内部 base URL 和 Token。只单独启动内部 worker 时使用: + +```bash +npm run dev:bgfilter-worker +``` + 单独启动前端: ```bash @@ -107,7 +114,7 @@ npm run server-manager:panel 该命令启动 `server-rs/crates/server-manager-panel` 的 egui 桌面工具,从本机 `~/.ssh/config` 读取可用 `Host` alias,支持多服务器健康巡检、可折叠侧边栏和受控 systemd 服务启停。服务操作通过远端 `sudo -n systemctl start|stop|restart ` 执行,目标服务器需要提前配置对应 unit 的免交互 sudo 权限。 面板启动时会自动注入本机中文字体;如开发机中文仍显示为方块,可设置 `GENARRATIVE_SERVER_PANEL_CJK_FONT=/path/to/font.ttc|index` 指向本机 CJK 字体。 -`npm run dev:api-server` 会保留终端实时输出,并把同一份输出持久化到 `logs/api-server/api-server-.log`。完整联调入口 `npm run dev` 启动的 Rust `api-server` 使用同一套日志规则。如需改写路径,可设置 `GENARRATIVE_API_SERVER_LOG_FILE`;如只改目录,可设置 `GENARRATIVE_API_SERVER_LOG_DIR`。 +`npm run dev:api-server` 会保留终端实时输出,并把 API 输出持久化到 `logs/api-server/api-server-.log`、BgFilter worker 输出持久化到 `logs/bgfilter-worker/bgfilter-worker-.log`。完整联调入口 `npm run dev` 使用同一套日志规则。API 日志可通过 `GENARRATIVE_API_SERVER_LOG_FILE` / `GENARRATIVE_API_SERVER_LOG_DIR` 改写,worker 日志可通过 `GENARRATIVE_BGFILTER_WORKER_LOG_FILE` / `GENARRATIVE_BGFILTER_WORKER_LOG_DIR` 改写。 开发态 `npm run dev` / `npm run dev:api-server` 默认打开 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,密码入口可以直接注册未知手机号账号;生产默认仍关闭该开关。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index dcdcd0fe2..7cdda102e 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3254,3 +3254,11 @@ - 现象:VectorEngine 单次请求超时大于 worker job 执行预算时,worker 已停止续租,provider 才超时或开始重试;最终 lease 过期、任务失败并退款,上游却可能继续消耗资源或迟到成功。 - 原因:单 attempt timeout、重试退避、图片下载与 worker / lease 分别使用独立的相对计时,没有共享同一绝对 deadline;只抬高 worker timeout 或单独压低 provider timeout 都无法保证留出终态写回窗口。 - 处理:实际调用 VectorEngine 的四类图片 job 使用 `1800s` long 预算;从 job 开始的同一起点派生 provider deadline,常规提前 `60s`、短预算提前一半。每次 attempt、退避、下一次 attempt 和图片下载都必须在该 deadline 内;普通 HTTP / `inline` 不伪造 worker deadline。修复时不改动 lease fencing、迟到写回仲裁和原子退款语义。 + +## 同一 Rust 二进制的本地双进程不能各自并发 watch 重启(2026-07-21) + +- 现象:本地把 `api-server` 与独立 `bgfilter-worker` 都用 `cargo run -p api-server` 启动后,一次 Rust 源码变更触发两套 watcher 并发停止、编译和链接;Windows 常因另一个实例仍占用 `api-server.exe` 而链接失败,或出现 API 已恢复但内部 worker 尚未 ready 的半更新状态。 +- 原因:两个进程角色共享同一 crate、target 和可执行文件,却被错误地当成两个互不相关的 dev service。更危险的是先启动 `GENARRATIVE_PROCESS_ROLE=all` 的 API:它会立即消费外部生成队列,可能在内部 BgFilter worker 尚未 ready 时领取任务。 +- 处理:`npm run dev` 与 `npm run dev:api-server` 只创建一套 Rust watcher,并把两个进程作为组合重启单元:先停止 API 与 BgFilter worker,再只让 worker 的 `cargo run` 完成必要构建,等待 worker `/readyz`,最后启动并验活 API。交互 `rs api-server`、`rs bgfilter-worker` 在完整栈内也必须走同一组合重启。`ProcessRole::All` 永远不内嵌 BgFilter listener;父子进程共享解析后的内部 base URL / Token,Linux 第五端口固定为端口段 `start + 4`,Windows 把第五端口纳入统一探测和漂移。 +- 验证:定向测试断言组合重启顺序为“stop API → stop worker → start/ready worker → start/ready API”,`dev:api-server` 自动带起同 runner worker,端口解析得到五个互不冲突的端口;再运行 `node --check scripts/dev.mjs`、dev-stack 定向测试和编码检查。 +- 关联:`scripts/dev.mjs`、`scripts/dev-stack-port-utils.mjs`、`.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 diff --git a/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md b/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md index 610f329df..ec536408f 100644 --- a/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md +++ b/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md @@ -2,9 +2,9 @@ 更新时间:`2026-07-21` -状态:`待实施` +状态:`已实施,待生产压测` -> 本文替代此前讨论的“SpacetimeDB 持久子任务 + raw 结果 OSS”以及更早的“父 job checkpoint / continuation”方案。首版改为父流程在原调用栈内同步等待唯一 `bgfilter-worker` 的内部 HTTP 响应。当前代码仍由各调用方直接请求 BgFilter,本文描述目标实现。 +> 本文替代此前讨论的“SpacetimeDB 持久子任务 + raw 结果 OSS”以及更早的“父 job checkpoint / continuation”方案。首版改为父流程在原调用栈内同步等待唯一 `bgfilter-worker` 的内部 HTTP 响应。代码与部署接线已经实施;完成本文生产压测和验收门禁前,不视为可上线实现。 ## 1. 决策摘要 @@ -13,8 +13,8 @@ | 调度单位 | 一次逻辑 BgFilter 调用;角色动画为单帧 | | 父流程 | 保持原 future、调用栈、lease 和 `attempt`,同步等待内部 HTTP | | 通用 worker 槽 | 等待期间继续占用;父 heartbeat 继续运行 | -| 输入 | 只传私有 OSS `objectKey`、BgFilter 参数、剩余预算和有界审计关联;不传源图字节或签名 URL | -| 成功输出 | 内部 HTTP body 直接返回图片二进制;不使用 Base64,不先写 raw OSS | +| 请求输入(父 → 子) | 只传私有 OSS `objectKey`、BgFilter 参数、剩余预算和有界审计关联;不重复传源图字节,也不传签名 URL | +| 成功输出(子 → 父) | 内部 HTTP body 直接传回 BgFilter 结果图片的原始字节;不使用 Base64、不返回结果 object key、不先写 raw OSS | | BgFilter worker | 首版只运行一个内部 HTTP worker 实例 | | 并发 | 进程内 `Semaphore(N)`,并增加有界 admission 上限 `Q` | | 重试 | 子 worker 对一次逻辑调用最多做两次顺序 provider attempt;父侧不重试整次内部 RPC | @@ -24,6 +24,7 @@ | 动画失败 | 首版保持当前“所有已提交帧都等待并排空”语义,不新增跨帧取消组 | | 崩溃恢复 | 不查询、不恢复 BgFilter 结果;父 job 沿用现有 lease、失败和退款语义 | | 数据模型 | 不新增 SpacetimeDB 表,不修改 `external_generation_job` schema | +| 配置加载 | 子 worker 先加载 API 基础环境,再加载 worker 专属环境覆盖;共享超时保持单一来源 | 首版明确不实现: @@ -42,6 +43,8 @@ 编辑器 queue job 固定 `max_attempts = 1`。普通 requeue 会改变 attempt、失败和退款语义;checkpoint / continuation 又会扩大父状态机和计费恢复改动。父流程既然可以接受继续占用 worker 槽,首版无需为 BgFilter 建第二套持久任务系统。 +checkpoint / continuation 不是当前已有能力,而是旧版方案需要新增的恢复状态机;同步原地等待版不新增它们。旧版 `bgfilter_task_group` 用于聚合动画帧,`bgfilter_request_task` 用于持久调度一次逻辑调用(动画时为单帧);本版继续由父 future 聚合帧结果,由内部 HTTP handler、`Q` admission 和 `Semaphore(N)` 调度单次调用,因此两张表都不再需要。 + 当前 BgFilter 成功结果本来就是 HTTP 图片二进制,调用方读取后再由父流程做最终处理和 OSS 持久化。因此让专用 worker 通过内部 HTTP 直接返回二进制,最接近现有数据流。 ### 2.2 目标 @@ -91,6 +94,26 @@ flowchart LR `bgfilter-worker` 从实现形态看是只监听内部地址的同步 worker service,不是队列 consumer。父 worker 调另一个 worker 在这里是允许的:父进程明确选择保留调用栈和槽位,因此同步内部 HTTP 正是首版的最小交接方式。 +这里的“同步等待”是控制流上的 request / response `await`:不会阻塞 OS 执行线程或整个父进程,但父 job future 仍留在通用 worker 的并发集合中,占用一个父 worker 槽,并由现有 heartbeat 继续续租。 + +首版进程角色仍复用现有完整 `AppState` 构造路径,以获得 OSS、BgFilter provider、SpacetimeDB 审计、HTTP client 和可观测性依赖;进程角色只阻止它挂载公共路由、claim 外部生成 job 或启动其它后台循环,并不等于它只需要 `N / Q` 几个环境变量。因此生产 unit 必须先加载 `/etc/genarrative/api-server.env`,再加载 `/etc/genarrative/bgfilter-worker.env` 覆盖监听地址、`N / Q` 和 worker 独占参数。后续若拆出轻量专用 state,可再缩小共享配置依赖,首版不能假设该拆分已经存在。 + +### 3.1 图片数据流口径 + +请求和响应采用不同口径,不能把“请求不传源图字节”理解成“响应也不能传图片字节”: + +| 阶段 | 传递内容 | 是否新增持久化 | +| --- | --- | --- | +| 父流程 → `bgfilter-worker` | JSON:源图 `objectKey`、参数和预算 | 否 | +| `bgfilter-worker` → BgFilter provider | 子 worker 现场签发的源图短期 URL | 否 | +| BgFilter provider → `bgfilter-worker` | 结果图片字节 | 否,只在子 worker 有界内存中读取和校验 | +| `bgfilter-worker` → 父流程 | `2xx` HTTP body 中的原始结果图片字节 | 否,父侧直接读入有界字节缓冲 | +| 父流程 → OSS / 业务写回 | 现有后处理后的最终图片 | 是,仍只走父流程现有最终持久化路径 | + +因此,本方案所说的“直接返回二进制”就是直接传图片字节:父侧内部 client 的成功结果是 `Bytes` / `Vec` 一类有界内存缓冲及可信的图片类型,而不是 Base64 字符串、临时 object key 或子任务结果记录。这里不是把 provider 响应边读边透明转发;子 worker 要先完整读取并校验结果,确认本次 attempt 成功后,再把同一份图片内容作为内部 HTTP body 返回,以保留第二次顺序尝试和无效图片拦截能力。 + +输入与输出采用非对称传输是有意设计:源图在调用前已经持久化到私有 OSS,传 `objectKey` 可避免重复上传和跨进程复制大块输入;输出则是父流程马上消费的短生命周期结果,直接用内部 HTTP 二进制 body 返回最小,不需要先制造一份 raw OSS 资产。 + ## 4. 内部 HTTP 契约 ### 4.1 请求 @@ -128,7 +151,7 @@ Authorization: Bearer - 如果未来确实支持多个 bucket,新增字段也必须由服务端 allowlist 校验;不能接受调用方提供任意下载 URL。 - `backgroundMode` 只允许 `flat / complex`;`segModel` 继续沿用当前 `birefnet / anime-seg` allowlist;complex 固定使用当前参数组合。 - `screenColor` 只对 flat 必填;complex 不得误接 flat 参数或熔断。 -- `requestBudgetMs` 是从子 worker 收到请求开始计算的相对预算,不是跨机器绝对时间。 +- `requestBudgetMs` 是相对预算,不是跨机器绝对时间。当前实现从内部鉴权通过并取得 `Q` admission permit 的时刻起算;`Q` 满时立即返回 `overloaded`,成功 admission 后的 JSON 解析、等待 `N` permit、provider attempt、结果校验和响应构造都消耗该预算。 - JSON body 设置很小的固定上限;源图字节不进入该 JSON。 签名 URL 必须在取得 provider permit 后、每次 attempt 前生成,避免排队期间过期。签名 URL 只存在于子 worker 内存和发往 BgFilter 的请求中。 @@ -146,7 +169,7 @@ Content-Type: image/png `image/png` 是常见响应示例。为保持当前行为,首版可以返回实际受支持的 `image/png`、`image/webp` 或 `image/jpeg`,但必须保证响应头与实际解码类型一致;不使用 JSON、Data URL 或 Base64 包装,也不为传输先落 raw OSS。 -子 worker 必须完整读取并校验 provider body 后才向父侧返回成功,这样 provider body 中途断开时仍可在预算内执行第二次 attempt。父侧收到二进制后继续执行一次独立校验,不能只信任内部响应头。 +子 worker 必须完整读取并校验 provider body 后才向父侧返回成功,这样 provider body 中途断开时仍可在预算内执行第二次 attempt。父侧内部 client 对 `2xx` 响应读取有界二进制 body,并把字节直接交回现有 Alpha / 尺寸恢复与 finalizer;父侧仍执行一次独立校验,不能只信任内部响应头。任何一侧都不得把成功 body 转成 Base64、JSON 数组或临时 OSS 引用。 ### 4.3 错误响应 @@ -189,19 +212,21 @@ HTTP status 只作粗粒度传输分类,父侧以稳定 `error.code` 映射业 queue job 的总预算从父 job 开始执行时起算,不从开始申请 BgFilter 时重新计时。现有父 worker 还会把 provider deadline 设在 job deadline 前 `60s`,为最终写回和终态保留时间。同步 RPC 实现必须显式读取父侧剩余 provider budget 并传入 `requestBudgetMs`,不能像当前 BgFilter helper 一样忽略 `RequestContext` deadline。 +本版没有 BgFilter 子任务等待 claim 的阶段。几个起算点必须区分:父 job 在数据库中尚未被 claim 的等待不消耗 job 执行预算;父 job 开始实际执行后,生图及 BgFilter 之前的耗时都会消耗父总预算;父内部 HTTP client timeout 从开始发送请求起覆盖 loopback 传输、worker admission、等待 `N`、provider 和回包;单次 `180s` attempt timer 只在子 worker 真正开始一次 BgFilter provider HTTP 时启动。父侧不是放弃超时,而是不再直接执行 provider 单次 attempt 的计时器。 + 父总 deadline 到达时,现有 worker 会停止续租、释放 JoinSet 槽并把 work 交给 lease fencing 仲裁,不是立刻杀死所有内部工作。正常情况下内部 RPC 自身应在更早的 provider deadline 内结束,避免进入这条脱管路径。 ### 5.2 子 worker attempt timeout -父侧与子 worker 的预算必须满足: +父侧先按 `requestBudgetMs + 2s` 计算期望的内部 client timeout,再受父绝对 deadline 截断: ```text -requestBudgetMs + 2s parent transport window - <= parent client timeout - <= 父侧剩余绝对预算 +parent client timeout + = min(requestBudgetMs + 2s parent transport window, + 父侧当前剩余绝对预算) ``` -parent client timeout 从父侧开始发请求时起算,`requestBudgetMs` 从子 worker 收到请求时起算,因此两者不能设成同一个值。额外 `2s` 用于 loopback 传输、调度抖动和父侧读取类型化错误,不增加 provider 可执行时间。 +`requestBudgetMs` 派生时会预扣这段 transport reserve,因此正常路径仍为子 worker 保留约 `2s` 的 loopback 传输、调度抖动和父侧读取类型化错误时间;父绝对 deadline 始终是硬上限。额外 `2s` 不增加 provider 可执行时间,也不能在父预算已经不足时强行延长 client timeout。 子 worker 收到请求后使用本机单调时钟计算 RPC deadline。每次 attempt 的 timeout 为: @@ -213,6 +238,8 @@ worker 内部 `1s` 和 parent transport `2s` 都只是固定的小型进程 / flat 调用还要由父侧从可分配给 BgFilter 的预算中保留当前阿里云 request timeout(默认 `30s`)和少量本地处理余量,避免 BgFilter 排队吃完全部 provider budget 后名义上有 fallback、实际上已无时间执行。complex 没有 flat fallback,不使用该预留。 +`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 是单次真实 provider attempt 的基础上限,默认 `180s`。父侧据此派生一次逻辑调用的上限:`2 × attempt timeout + 1s worker response window`,再按父绝对 deadline 和 flat fallback reserve 截短为 `requestBudgetMs`;parent client timeout 则取前述 `min(requestBudgetMs + 最多 2s transport window, 父绝对 deadline 剩余)`。该配置必须由父子进程使用同一个有效值:首版把它放在共享 API 基础环境中作为单一来源,worker 专属环境不得悄悄覆盖成另一个值。 + inline / External v1 没有 queue job deadline 时,内部 RPC 仍必须有界;默认总上限按“两次现有 BgFilter attempt 上限 + 内部响应窗口”计算,排队时间同样包含在内。 ### 5.3 动画旧增量 @@ -271,17 +298,19 @@ inline / External v1 没有 queue job deadline 时,内部 RPC 仍必须有界 ### 6.3 熔断 -熔断是故障保护:flat 连续多次请求失败后,在 cooldown 内暂时不再请求 BgFilter,而是快速返回 `circuit_open`,由父流程进入“阿里云 → 本地”fallback,避免故障 provider 持续占满并发和超时。 +熔断是故障保护:flat 的真实 provider attempt 连续失败达到阈值后,在 cooldown 内暂时不再请求 BgFilter,而是快速返回 `circuit_open`,由父流程进入“阿里云 → 本地”fallback,避免故障 provider 持续占满并发和超时。 保持当前语义: - 只有 flat 读取和更新熔断;complex 完全不读写。 - flat 在取得 permit、即将发送第一次 provider HTTP 前重新检查熔断,避免 48 个排队请求在熔断打开前全部通过旧检查。 - 已经获准执行的逻辑调用,即使第一次失败使熔断打开,也仍允许在预算内完成自己的第二次顺序 attempt;后续请求快速返回 `circuit_open`。 -- 每个真实失败 attempt 计一次失败,保持当前计数口径;成功重置。 +- 每个真实失败 attempt 计一次失败,保持当前计数口径;flat 任一真实 attempt 成功后重置。 - 只有拿到完整配置 attempt 上限(默认 `180s`)后发生的 provider timeout,以及真实传输失败、非 2xx 和无效 / 超限图片计入。因 `requestBudgetMs` 剩余不足而被截短的 timeout 返回 `deadline_exceeded`,不更新熔断;排队满、排队超时、客户端取消、鉴权和本地配置错误同样不计入。 - 进程重启后熔断状态清零是首版接受行为。 +flat 熔断的 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD` 与 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS` 属于 `bgfilter-worker` 运行参数;父 API / external-generation worker 不再读取或更新熔断。生产示例必须把这两个值放进 worker 专属环境,避免运维人员在父侧修改了一个实际不生效的配置。 + 首版不增加 QPS 限制。若 provider 以后要求 QPS,需要另加 token bucket;不能把并发 semaphore 当作 QPS。 ## 7. 断连、崩溃与动画语义 @@ -335,6 +364,7 @@ BgFilter 成功二进制不是一份新的业务资产: - 首版限定父 worker 与子 worker 同机部署,内部 listener 只绑定 loopback 固定端口,不挂公共 Axum router、Nginx、BFF 或 OpenAPI。 - 使用独立内部 Token;缺失时生产 fail-closed。Token 不复用 BgFilter provider Token。 +- 父、子进程只从同一个 `GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE` 读取内部 Token。发布脚本必须在切换 `current` 链接前确认该路径是非符号链接的普通非空文件,owner / group / mode 符合 `root:genarrative 0440`;不能先切换版本、再等 readiness 暴露首次未 provision 或权限错误。 - 只接受配置 bucket 下的规范化 object key;禁止 `http://`、`https://`、`data:`、`blob:` 和路径逃逸。 - 不在日志、trace、metrics、错误 JSON 或 SpacetimeDB 审计中写签名 URL、Token、图片字节或 Base64。 @@ -347,7 +377,9 @@ BgFilter 成功二进制不是一份新的业务资产: - MIME、魔数和实际解码结果必须一致; - 空 body、截断 body 和超限图片按 `invalid_result` 处理。 -二进制跨进程传输期间,子 worker 和父 worker 可能同时持有同一张图片。前述 `N / Q` permit 必须由 response-body guard 持有到发送完成或 body drop,才能把子 worker 中的完整成功 body 控制在 `N` 级;在此前提下,极端内存至少按 `2 × N × 32 MiB` 再加解码缓冲评估。若实现没有该 guard,完整 body 可能积累到 `Q` 级,本文的内存模型即不成立,不能上线。生产 `N / Q` 必须结合主机内存压测,而不是只看 BgFilter 吞吐。 +二进制跨进程传输期间,子 worker 和父 worker 可能同时持有同一张图片。子侧 provider permit、成功 body guard 和图片校验槽均与 `N` 对齐;guard 持有到内部响应发送完成或 body drop,完整成功 body 不会积累到 `Q` 级。父侧另有固定 `P = 4` 个成功图片读取 / 解码槽,必须在开始读取 `2xx` body 前取得,并覆盖有界 body 读取与 `spawn_blocking` 校验。极端完整 body 内存按 `(N + P) × 32 MiB` 再加父子解码缓冲、provider 读取缓冲和运行时开销评估;生产 `N / Q` 必须结合主机内存压测,而不是只看 BgFilter 吞吐。 + +图片解码运行在不可强制取消的 blocking task 中。父子两侧等待校验结果都必须受各自 deadline 约束;deadline 到达后请求可按类型化超时收口。子 worker 已启动但尚未结束的校验 task 继续持有图片字节和校验槽,并由 shutdown tracker 等待真实结束;provider `N` 只覆盖真实 provider 调用及内部响应发送,不因后台 CPU 校验延长而虚假占用。父侧超时后的 blocking task 继续持有父侧校验槽直到真实结束,防止后续大图无界叠加,但它没有外部副作用,不纳入子 worker 的 shutdown tracker。`TimeoutStopSec=900` 覆盖的是子 worker 的排空边界。 ### 9.3 指标与日志 @@ -372,26 +404,36 @@ BgFilter 成功二进制不是一份新的业务资产: 1. 在现有 Rust 后端增加 `bgfilter-worker` 进程角色和独立 loopback Axum listener;它不启动用户 HTTP router,也不 claim `external_generation_job`。 2. 增加内部 request / binary response / typed error 契约、Token 校验、JSON body 上限、object key allowlist 和健康检查;listener 在 body 解析前接入连接 / request concurrency limit、固定 backlog 和 load shedding。 3. 增加 admission `Q`、`Semaphore(N)`、两次顺序 attempt、预算检查、结果限长 / 解码校验、response-body permit guard 和 flat 进程级熔断。 -4. 增加父侧共享内部 HTTP client。该 client 不自动重试,把父剩余预算显式转换为较短的 `requestBudgetMs` 和略长的 client timeout,并保证两者都早于父绝对 deadline。 +4. 增加父侧共享内部 HTTP client。该 client 不自动重试;对 `2xx` 读取并返回受限图片字节,对非 `2xx` 只解析有界类型化 JSON 错误;把父剩余预算显式转换为较短的 `requestBudgetMs` 和略长的 client timeout,并保证两者都早于父绝对 deadline。 5. 用内部 client 替换两个集中调用边界: - flat:`remove_editor_generated_screen_background_with_bgfilter_with_request_timeout`; - complex:`request_editor_background_removal_image_with_retry`。 6. 从父侧移除 BgFilter provider retry 和 flat 熔断实现;保留 flat fallback、complex 失败、Alpha / 尺寸恢复和所有最终持久化。 7. 删除动画 `2000ms × frame_count` 单 attempt timeout 增量;保留现有所有帧 collect / drain。 8. 扫描 queue、inline、External v1 的角色、图标、UI、动画和手动 complex 路径,确认没有 direct BgFilter HTTP 旁路。 -9. 增加单实例 systemd unit、内部地址 / Token、`N / Q` 配置、readiness 和指标;真实压测 `32 / 40 / 48` 帧后启用。 +9. 增加单实例 systemd unit、内部地址 / Token、`N / Q` 配置、readiness 和指标;unit 按“API 基础环境 → worker 专属环境”加载,部署 preflight 校验共享超时和 Token;真实压测 `32 / 40 / 48` 帧后启用。 +10. 把独立子进程纳入本地 dev 调度器;`ProcessRole::All` 保持不内嵌 BgFilter listener,避免本地与生产形成两套调用实现。 本计划不产生 SpacetimeDB schema、migration、bindings 或表目录改动。 ### 10.2 部署与回滚 -1. 先部署并启动唯一 `bgfilter-worker`,确认 loopback health、鉴权和 provider smoke。 -2. 再发布使用内部 client 的 API / external-generation-worker。 -3. 确认所有父进程只访问内部 endpoint,BgFilter provider 日志中不再出现父进程直连。 -4. 发布子 worker 时执行 `stop old -> 等待排空/退出 -> start new`,不得滚动重叠。 +1. 在切换发布目录前完成 preflight:共享 API env 与两类 worker env 均存在;`external-generation-worker.env` 和 `bgfilter-worker.env` 不得把共享 BgFilter 配置覆盖为不同有效值;父 base URL、子 `HOST / PORT` 与 readiness URL 指向同一 loopback endpoint;内部 Token 文件存在、非空、非符号链接且权限正确;`N / Q` 为正整数且 `Q >= N`。 +2. 安装非模板单实例 unit;它先加载 `/etc/genarrative/api-server.env`,再加载 `/etc/genarrative/bgfilter-worker.env`。 +3. 执行 `stop old -> 等待排空/退出 -> start new`,确认唯一 `bgfilter-worker` 的 loopback readiness、鉴权和 provider smoke,不得滚动重叠。 +4. 再重启使用内部 client 的 API / external-generation-worker / controller。 +5. 确认所有父进程只访问内部 endpoint,BgFilter provider 日志中不再出现父进程直连。 内部 worker 不可用时禁止自动 direct fallback。flat 仍可走业务已有阿里云 / 本地 fallback;complex 明确失败。需要整体回滚时回滚父、子进程版本和配置,不在运行中混用两种 BgFilter 调度方式。 +`bgfilter-worker` 收到停止信号后先停止接收新请求,再等待已 admission 的 handler 和已启动 provider attempt 排空。内部协议允许的 `requestBudgetMs` 最大为 `600s`,因此 systemd unit 固定使用 `TimeoutStopSec=900`;部署脚本的同步 `systemctl stop` 必须允许该窗口完成,不能沿用 systemd 常见的约 `90s` 默认值强杀在途调用。 + +### 10.3 本地开发 + +`npm run dev` 必须自动启动独立 `bgfilter-worker` 子进程,并在启动父 API / external-generation 路径前完成 loopback readiness。开发端口解析新增第五个 `bgfilter` 职责:Linux 多用户端口段使用 `start + 4`,Windows 沿用现有端口探测与漂移;解析后的实际 host / port 注入子进程,实际 base URL 注入父进程,不能继续硬编码 `8083`。父、子使用同一个仅存在于本地进程环境的内部 Token。 + +`GENARRATIVE_PROCESS_ROLE=all` 仍不直接运行 BgFilter listener。单模块联调需要提供明确的独立 worker 启动入口,并在 `dev:api-server` 没有可用 worker 时自动带起或 fail-fast 给出该入口,不能让开发者等到一次图片生成才看到连接拒绝。dev 状态文件、watch 重启、端口日志和退出清理都要把第五个子进程纳入,防止遗留进程占端口。 + ## 11. 验收门禁 必须覆盖: @@ -399,18 +441,28 @@ BgFilter 成功二进制不是一份新的业务资产: - `48` 帧同时进入时,唯一子 worker 观测到的客户端 BgFilter HTTP future 峰值不超过 `N`。 - listener 在 body 解析前执行 concurrency limit / load shedding;`queued + running + egress` 达到 `Q` 后新请求立即返回 `overloaded`,handler 数不超过 `Q`,内核 socket backlog 按独立固定值验证。 - 排队时间计入 `requestBudgetMs`;deadline 到达后不开始新的 provider attempt。 -- `requestBudgetMs + parent transport window <= parent client timeout <= 父剩余绝对预算`,worker 有时间把类型化错误交回父侧。 +- 不存在 BgFilter 子任务 claim 状态;成功 admission 后等待 `N` 的时间同时计入子 `requestBudgetMs` 和父 client timeout。 +- parent client timeout 取 `min(requestBudgetMs + parent transport window, 父剩余绝对预算)`;预算派生正常预留响应窗口,结果校验等待也必须 deadline-aware,不能只在校验完成后事后判超时。 - 第一次失败后预算不足时不开始第二次;父侧从不重试整次内部 RPC。 - 父业务预算仍有效时,flat 两次失败、熔断、overload、内部 RPC deadline 或断连仍走“阿里云 → 本地”;complex 任意失败直接失败且不读写熔断。 - flat 熔断按真实失败 attempt 计数;由剩余业务预算截短的 timeout 不计入。已获准调用可完成第二次,后续排队请求快速 `circuit_open`。 - `cancelled`、父 cancellation / 绝对 deadline、`invalid_request` 和 `unauthorized` 不启动 flat fallback;其它 flat 错误只在父业务预算仍有效时进入 fallback。 - 客户端断连时,等待 permit 的请求最终由 deadline 收口;已开始 provider attempt 持有 permit 并排空。明确 cancellation 已被观察到后不再开始第二次,单纯 TCP 断连只作 best-effort 测试,不作为硬保证。 - 动画全部已提交帧继续 collect / drain,根因按现有稳定帧序号收口;首版不存在未实现的 group cancellation 承诺。 -- 成功 body 为原始二进制而非 Base64;父、子两侧都拒绝空 body、MIME / 魔数不一致、chunked 超 `32 MiB` 和超过 `8192 × 8192` 的图片。 +- 成功 body 为原始图片字节而非 Base64、JSON 或结果 object key;父侧 client 把该有界字节缓冲直接交给现有后处理,子 worker 不执行 raw OSS PUT。父、子两侧都拒绝空 body、MIME / 魔数不一致、chunked 超 `32 MiB` 和超过 `8192 × 8192` 的图片。 - `N / Q` response-body guard 在发送完成或 body drop 前不释放,慢读 / 断连时完整成功 body 不积累到 `Q` 级。 +- 子侧图片校验槽与 `N` 对齐;校验等待受 child deadline 约束,超时后的 blocking 校验 task 继续持有图片字节与校验槽,并被 shutdown tracker 排空;真实 provider 调用已经结束后不继续占用 `N`。 +- 父侧固定最多 `4` 个成功图片读取 / 解码槽,permit 在读取 `2xx` body 前取得并覆盖 `spawn_blocking` 校验;内部响应并发再高也不能无界累积父侧完整 body 或解码任务。 - worker 重启 / RPC 丢失不查询、不恢复结果;父 job 的 heartbeat、lease、失败退款和 fencing 保持现状。 - External v1 / inline 不再直连 BgFilter;公共 router、BFF、账单和任务列表中没有内部 endpoint 或内部调用记录。 - 生产不存在两个同时运行的 `bgfilter-worker`,配置缺失或 `N / Q = 0` 时 fail-closed。 +- worker unit 先加载共享 API env、再加载 worker 专属 env;父子有效 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 完全一致,flat 熔断参数只由子 worker 配置和执行。 +- `external-generation-worker.env` 后加载时不得把内部 base URL、Token / Token 文件、connect timeout、provider attempt timeout、OSS bucket 或 endpoint 覆盖为与共享 API env 不同的有效值;父侧必须把源对象写到子 worker 将要签名读取的同一 OSS 位置。外部生成 worker 可使用同 bucket 下权限等价或更小的独立 AK,不要求凭据文本相同。 +- 父进程 `GENARRATIVE_BGFILTER_WORKER_BASE_URL`、子 worker `HOST / PORT` 和部署 readiness URL 必须指向同一个 `127.0.0.1:` endpoint;旧非空配置不能因为“无需补默认值”而绕过一致性检查。 +- `genarrative-bgfilter-worker.service` 必须保持 `TimeoutStopSec=900`,覆盖最大 `600s` 内部请求预算和停止收口余量。 +- 发布目录切换前拒绝缺失、空、符号链接或权限错误的内部 Token 文件;父、子有效 Token 文件路径必须相同。 +- 生产运行期巡检同时检查 `genarrative-bgfilter-worker.service` 为 active 且 `127.0.0.1:8083/readyz` 成功,不能只依赖 systemd 自动重启。 +- `npm run dev` 启动独立 BgFilter 子进程并使用解析后的第五个端口;`all` 角色不内嵌 listener,单模块入口、watch、状态文件和退出清理没有遗留进程或硬编码端口。 实现后按范围运行: @@ -435,3 +487,5 @@ git diff --check - 生产 `GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS = Q`。若要求一个满帧动画不因自身 admission 被拒绝,`Q` 至少为 `48`;候选 `128` 可容纳两个满帧动画并留少量余量,但明确不能覆盖 controller 理论最大 `768` 次同时提交,超出部分会按 mode fallback 或失败。最终值以可接受的 overload 行为和内存压测为准。 其余首版固定边界:provider 单 attempt 默认 `180s`,内部响应最大 `32 MiB / 8192 × 8192`,内部 listener 只绑定 loopback 且必须鉴权。若要多实例、严格服务端全局并发、QPS 或动画 peer cancellation,应先升级本文,不在编码中临时扩 scope。 + +配置归属同时冻结:`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=180000` 来自父子共同加载的 API 基础环境;`GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`、`GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`、`N` 与 `Q` 由 worker 专属环境管理。若部署脚本发现共享值被 worker 环境重复定义且不同,必须在启动前失败。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 635934ce3..336d82b01 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -1,6 +1,6 @@ # 本地开发验证与生产运维 -更新时间:`2026-07-17` +更新时间:`2026-07-21` ## 标准开发流程 @@ -27,13 +27,14 @@ npm run dev 该命令启动: - SpacetimeDB standalone。 +- 独立 `bgfilter-worker`。 - Rust `api-server`。 - 主站 Vite。 - 后台 Vite。 -`npm run dev` 和单模块 `npm run dev:web`、`npm run dev:api-server`、`npm run dev:spacetime`、`npm run dev:admin-web` 启动后都会更新根目录 `.app/dev-stack.json`。该文件记录本次命令、数据库、更新时间,以及 `spacetime`、`api-server`、`web`、`admin-web` 的 `pid`、监听 host / port、可访问 URL、启动状态和当前命令。`.app/` 是本地运行态目录,不提交 Git;端口漂移、服务重启或子进程退出后以该文件里的实际状态为准。 +`npm run dev` 和单模块 `npm run dev:web`、`npm run dev:api-server`、`npm run dev:bgfilter-worker`、`npm run dev:spacetime`、`npm run dev:admin-web` 启动后都会更新根目录 `.app/dev-stack.json`。该文件记录本次命令、数据库、更新时间,以及 `spacetime`、`api-server`、`bgfilter-worker`、`web`、`admin-web` 的 `pid`、监听 host / port、可访问 URL、启动状态和当前命令。`.app/` 是本地运行态目录,不提交 Git;端口漂移、服务重启或子进程退出后以该文件里的实际状态为准。 -通过 `nohup` 在仓库根目录启动 dev 栈且未显式重定向 stdout / stderr 时,默认 `nohup.out` 会持续收集 SpacetimeDB、api-server、主站 Vite 和后台 Vite 的整套 dev 栈输出;该文件已被主站 Vite watcher 和 Git 忽略,避免日志追加触发页面刷新循环,重启主站 Vite 后生效。若把输出显式重定向到其它仓库内文件(例如 `> dev.out`),该自定义文件不会自动获得同样的 watcher 保护,应改为写到 Vite root 之外,或同步配置精确的忽略规则。 +通过 `nohup` 在仓库根目录启动 dev 栈且未显式重定向 stdout / stderr 时,默认 `nohup.out` 会持续收集 SpacetimeDB、api-server、bgfilter-worker、主站 Vite 和后台 Vite 的整套 dev 栈输出;该文件已被主站 Vite watcher 和 Git 忽略,避免日志追加触发页面刷新循环,重启主站 Vite 后生效。若把输出显式重定向到其它仓库内文件(例如 `> dev.out`),该自定义文件不会自动获得同样的 watcher 保护,应改为写到 Vite root 之外,或同步配置精确的忽略规则。 单独启动主站前端: @@ -47,15 +48,21 @@ npm run dev:web npm run dev:api-server ``` -Linux 本机多用户并发开发时,`npm run dev` 和 `npm run dev:*` 单模块命令会先在系统级端口段注册表里给当前用户分配一个端口段,再把该段映射为 `web = start`、`api = start + 1`、`spacetime = start + 2`、`admin-web = start + 3`。默认注册表目录是 `/var/tmp/genarrative-dev-port-ranges/`,其中 `registry.json` 记录各用户的活跃段,`registry.lock` 负责串行化分配;可以用 `GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR` 覆盖目录。系统自动分配时从 `10000-10099` 开始,每次占用 100 个端口块,后续块按 `10100-10199`、`10200-10299` 递增;`GENARRATIVE_DEV_PORT_RANGE` 或 `--port-range` 只在 Linux 上生效,Windows 仍按原来的 3000 / 8082 / 3101 / 3102 端口探测与漂移逻辑运行,不读这个系统级注册表。 +`npm run dev:api-server` 会由同一个启动器安全带起它依赖的独立 BgFilter worker,两者共享本次运行生成或显式配置的内部 Token;不要另外启动第二份 worker。只需单独运行内部 worker 时使用: -后端日志默认写入 `logs/api-server/`。后端 API smoke 使用 `npm run dev:api-server` 并检查 `/healthz`;需要确认实例可接生产流量时检查 `/readyz`。不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。 +```bash +npm run dev:bgfilter-worker +``` -Windows 本地 `npm run dev` / `npm run dev:api-server` 会用空的 `RUSTC_WRAPPER` / `CARGO_BUILD_RUSTC_WRAPPER` 覆盖 `server-rs/.cargo/config.toml` 里的 `sccache`,从而直连真实 `rustc`。不要把 wrapper 绕过值写成 `rustc`;Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,最终报 `multiple input filenames provided` 并导致 api-server 无法启动。排查本地启动失败时,先看 dev 日志是否出现该错误,再确认脚本注入的 wrapper 为空。 +Linux 本机多用户并发开发时,`npm run dev` 和 `npm run dev:*` 单模块命令会先在系统级端口段注册表里给当前用户分配一个端口段,再把该段映射为 `web = start`、`api = start + 1`、`spacetime = start + 2`、`admin-web = start + 3`、`bgfilter-worker = start + 4`。默认注册表目录是 `/var/tmp/genarrative-dev-port-ranges/`,其中 `registry.json` 记录各用户的活跃段,`registry.lock` 负责串行化分配;可以用 `GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR` 覆盖目录。系统自动分配时从 `10000-10099` 开始,每次占用 100 个端口块,后续块按 `10100-10199`、`10200-10299` 递增;`GENARRATIVE_DEV_PORT_RANGE` 或 `--port-range` 只在 Linux 上生效,Windows 仍按原来的 3000 / 8082 / 3101 / 3102 / 8083 优先端口统一探测并漂移,不读这个系统级注册表。父 API 与 worker 始终使用解析后的实际 `GENARRATIVE_BGFILTER_WORKER_BASE_URL`,不能写死 `8083`。 + +后端日志默认写入 `logs/api-server/`,独立 BgFilter worker 日志默认写入 `logs/bgfilter-worker/`。后端 API smoke 使用 `npm run dev:api-server`,先检查 BgFilter worker `/readyz`,再检查 API `/healthz`;需要确认 API 实例可接生产流量时检查 API `/readyz`。不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。 + +Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter-worker` 会用空的 `RUSTC_WRAPPER` / `CARGO_BUILD_RUSTC_WRAPPER` 覆盖 `server-rs/.cargo/config.toml` 里的 `sccache`,从而直连真实 `rustc`。完整栈和 `dev:api-server` 把 API 与 BgFilter worker 作为一个 Rust 重启单元:源码变化时先停两个进程,再先启动并验活 worker、最后启动并验活 API,避免两个 `cargo run` 并发链接同一个 Windows 可执行文件。不要把 wrapper 绕过值写成 `rustc`;Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,最终报 `multiple input filenames provided` 并导致 api-server 无法启动。排查本地启动失败时,先看 dev 日志是否出现该错误,再确认脚本注入的 wrapper 为空。 Windows 本地如果已在 `%LOCALAPPDATA%\Genarrative\ffmpeg\bin` 安装 FFmpeg,`npm run dev` / `npm run dev:api-server` 会自动把该目录加入本次 `api-server` 子进程 `Path`,并注入 `CHARACTER_ANIMATION_FFMPEG_PATH` / `CHARACTER_ANIMATION_FFPROBE_PATH` 的绝对路径。这样即使外层终端或长期运行的 dev 进程是在安装 FFmpeg 之前启动,角色动画抽帧也不会继续因为 `ffmpeg: program not found` 失败;若手动配置了上述环境变量或 `GENARRATIVE_CHARACTER_ANIMATION_*` 前缀变量,显式配置优先。 -开发态 `npm run dev` 与 `npm run dev:api-server` 会默认注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true` 和 `GENARRATIVE_PROCESS_ROLE=all`,因此密码登录在本地开发环境可直接注册未知手机号账号,且本地 `api-server` 会同时监听 HTTP 并消费外部生成队列;显式设置 `GENARRATIVE_PROCESS_ROLE` 时保留显式值。Linux 本地默认 `all` 角色启动前,dev 脚本会停止当前仓库、同一个 SpacetimeDB server / database 下遗留的 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程,避免旧 worker 二进制继续抢同一条队列并在业务写回时制造 procedure 超时;显式拆分 `api` / `external-generation-worker` 做生产式验证时不会触发这项清理。生产环境仍按 `api-server` 配置默认关闭密码自动注册,并由独立 worker 进程消费队列。 +开发态 `npm run dev` 与 `npm run dev:api-server` 会默认注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true` 和 `GENARRATIVE_PROCESS_ROLE=all`,因此密码登录在本地开发环境可直接注册未知手机号账号,且本地 `api-server` 会同时监听 HTTP 并消费外部生成队列;显式设置 `GENARRATIVE_PROCESS_ROLE` 时保留显式值。`all` 不内嵌 BgFilter worker;启动器总是先启动并验活独立 `GENARRATIVE_PROCESS_ROLE=bgfilter-worker` 进程,再启动父 API,并向两者注入同一个内部 base URL / Token。Linux 本地默认 `all` 角色启动前,dev 脚本会停止当前仓库、同一个 SpacetimeDB server / database 下遗留的 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程,避免旧 worker 二进制继续抢同一条队列并在业务写回时制造 procedure 超时;显式拆分 `api` / `external-generation-worker` 做生产式验证时不会触发这项清理。生产环境仍按 `api-server` 配置默认关闭密码自动注册,并由独立 worker 进程消费队列。 本地排查外部内容生成 worker 队列时,默认同一 Rust 进程同时监听 HTTP 并消费 `external_generation_job` 队列;更接近生产的验证应分别启动 `api`、`external-generation-worker` 和 `external-generation-controller`。生产默认 `GENARRATIVE_PROCESS_ROLE=api`,外部生成任务由独立 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程消费;生产与容器扩缩容验证保持 `queue`。当前 worker 只领取 `source_module = editor-canvas` 的图片画布任务,包括 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation`。旧玩法历史任务即使仍为 pending / running 也不领取、不改状态;显式把本地进程角色设为 `api` 且没有 worker 时,现役编辑器生成请求只返回 queued/running,不会兜底执行外部 provider。 @@ -67,12 +74,15 @@ HTTP 角色的 `GENARRATIVE_SPACETIME_POOL_SIZE` 只表示 procedure / reducer lease 过期后不代表任务一定再次执行:claim transaction 只有在 `attempt < max_attempts` 时才会递增 attempt 并返回 worker;如果过期的是最终 attempt,则直接把 job 收口为 `failed`、清理 lease,并按入队冻结价格为当前 attempt 原子退款或写 cancellation intent。该终态任务不会再次进入 provider executor,迟到 consume 会被 settlement intent 拒绝。 -图片画布角色图、图标素材、UI 素材提取和角色动作逐帧去背景时优先调用 BgFilter;当前全部调用都显式传 `background_mode=flat`,保持单一纯色背景抠图语义。默认 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=180000`,该值是所有路径的基准请求超时;角色动作逐帧请求的每一次 HTTP attempt 额外增加 `2000ms × 本次实际帧数`,默认 `32 / 40 / 48` 帧对应 `244000 / 260000 / 276000ms`,角色形象单图、图标、UI 和手动去背景继续使用基准值。该 request timeout 不是整批帧或整项任务超时,首次失败后的重试会重新计时;角色动作整项任务仍受默认 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS=1800` 预算约束,排查时以 `editor_bgfilter_request_start.timeout_ms` 确认实际值。连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后熔断 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 秒。BgFilter 调用失败和熔断期均先走阿里云通用抠图,只有阿里云失败才走本地幕布色去背景兜底。阿里云这层默认 `GENARRATIVE_ALIYUN_MATTING_ENABLED=true`,但必须在 `api-server.env` 填入 `GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID` / `GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET`(或标准 SDK 命名 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`)才会真正启用;AccessKey 缺失时启动日志会打印「阿里云抠图 AccessKey 未配置,跳过抠图客户端初始化」,抠图直接塌成 BgFilter→本地两级,`npm run check:api-server-env` 也会给出对应告警。修改这些变量后需要重启对应 `api-server` / worker 进程;排查时先从 worker 启动日志确认 lease 和 job timeout,再看带 `background_mode=flat` 的 `editor_bgfilter_request_start`、`editor_bgfilter_fallback_to_aliyun_matting`、`editor_bgfilter_circuit_open_fallback_to_aliyun_matting`,以及阿里云失败后的 `editor_aliyun_matting_fallback_to_local_screen_background_removal` 日志。 -手动 `POST /api/editor/images/background-removals` 同样调用 BgFilter,但 worker 只解析并校验已有 OSS object key,直接签发 600 秒 URL,不下载原图;multipart 固定传 `image_url`、`background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传 `file` 或背景色。首次失败后立即重试 `1` 次,两次都失败则返回最终错误。它不进入只适用于已知纯色背景的阿里云 / 本地键色兜底链,也不改变 flat 路径的熔断状态。标准纯色背景四条链路仍固定传 `background_mode=flat`。两种模式统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN`、`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 和共享 HTTP client;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只保留为 token 兼容别名。 +图片画布角色图、图标素材、UI 素材提取和角色动作逐帧去背景使用 `background_mode=flat`;手动 `POST /api/editor/images/background-removals` 使用 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`。父流程不再直连 BgFilter,而是把已持久化的私有 OSS object key、模式参数和剩余预算交给唯一的 loopback `bgfilter-worker`。子 worker 负责签发短期源 URL、全局 admission `Q`、provider 并发 `N`、最多两次顺序 attempt、结果校验和 flat 进程级熔断;成功时直接用内部 HTTP 二进制 body 把原始结果图片字节返回父流程,不写 raw OSS。默认 `N=4`、`Q=128`,生产缺失或为 `0` 时 fail-closed;角色动画仍可同时提交最多 `48` 个单帧逻辑调用,但健康 worker 中实际在飞的 BgFilter provider 请求不超过 `N`。 + +默认 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=180000` 是子 worker 单次真实 provider attempt 的上限。父侧仍管理父 job 总预算,并为一次内部 RPC 分配覆盖排队、最多两次 attempt 和内部响应的小预算;子 worker 把排队时间计入该预算,剩余时间不足时不开始新的 attempt。flat 还由父侧预留阿里云 request timeout 和本地处理余量。flat 两次失败、熔断、overload 或内部 RPC 故障且父业务预算仍有效时,父流程才继续“阿里云通用抠图 → 本地键色”;阿里云 fallback 不属于 BgFilter worker。complex 任意失败直接使父流程失败,不接 flat fallback,也不读写 flat 熔断。父侧不会重试整次内部 HTTP,避免子侧两次乘成四次 provider attempt。 + +阿里云通用抠图默认 `GENARRATIVE_ALIYUN_MATTING_ENABLED=true`,但必须在 `api-server.env` 填入 `GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID` / `GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET`(或标准 SDK 命名 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`)才会真正启用;AccessKey 缺失时启动日志会打印「阿里云抠图 AccessKey 未配置,跳过抠图客户端初始化」,flat 失败后直接进入本地键色。两种模式的 provider 配置仍统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN` 和共享的 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只保留为 token 兼容别名。修改 provider、内部 worker或 fallback 配置后,需要按角色重启对应进程。 阿里云通用抠图的非上海地域输入使用 `AuthorizeFileUpload → Policy POST → SegmentCommonImage` 正式链路,上传 Bucket / Endpoint / ObjectKey 由阿里云动态返回;不得恢复 `GetOssStsToken`、固定 `viapi-customer-temp`、临时 AK/SK 或 OSS V1 PUT。该切换不新增环境变量;真实链路冒烟可运行 `cargo run -p platform-matting --example segment_smoke --manifest-path server-rs/Cargo.toml -- <图片路径>`,预期日志中的输入 host 为授权响应返回的上海 OSS host,并完成结果下载。图片字节仍经过执行任务的 api-server / worker,排障时不要把 Advance 路径误判为阿里云直接抓取任意公网 URL。 -BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去背景源图直接使用 600 秒签名 URL:`api-server` 的 multipart 提交 `image_url` 及对应模式参数,不再提交 `file`,也不会在 BgFilter 调用前重新下载 OSS 对象。生成原图和动作帧上传完成后应已消费并释放字节所有权;手动路径从始至终不读取原图字节。flat 路径的 BgFilter 失败或熔断打开后,阿里云 fallback 才单独下载源对象并上传动态临时桶,临时上传完成即释放本次下载缓冲;阿里云继续失败时本地 fallback 再独立下载,并在本地处理产出后释放本次原图缓冲。排障日志只应出现 object key 与签名有效期,不得记录带 `x-oss-*` 查询参数的完整 URL。这里的释放是 Rust 缓冲析构,不以操作系统 RSS 立即下降作为判据。 +BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去背景源图继续使用短期签名 URL,但签名只在 `bgfilter-worker` 内生成并传给 provider;父进程到子进程只传 object key 和参数,不传源图字节或签名 URL。子 worker 完整读取并校验 provider 成功 body 后,把同一图片内容作为受限原始字节响应返回;父流程继续负责 Alpha / 尺寸恢复、动画 finalizer、最终 OSS、画布写回、计费和终态。flat fallback 才由父流程按原路径下载源对象供阿里云或本地键色使用。排障日志只应出现 object key 与签名有效期,不得记录带 `x-oss-*` 查询参数的完整 URL、内部 Token 或图片字节。 图片编辑器任务侧栏与生成提交工作流只读取 BFF 队列接口:`GET /api/runtime/external-generation/jobs` 列出当前用户任务,`GET /api/runtime/external-generation/jobs/{jobId}` 查看单 job 状态,概览场景可使用 `GET /api/runtime/external-generation/queue-overview`。前端不直接查询 `external_generation_job` private table,也不展示 worker 内部 payload;完成态以编辑器项目和资源接口返回的正式数据为准。 @@ -425,13 +435,14 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分 `Genarrative-Server-Provision` 会安装并启用 `genarrative-health-patrol.timer`,默认每 5 分钟运行一次 `genarrative-health-patrol.service`。巡检脚本随 API release 归档到 `/opt/genarrative/current/scripts/ops/production-health-patrol.mjs`,只读检查: -- 默认 `GENARRATIVE_HEALTH_PATROL_GATEWAY_MODE=nginx`,检查 `genarrative-api.service`、`genarrative-external-generation-controller.service`、`spacetimedb.service`、`nginx.service` 是否 active;Pingora 直连切换后改为 `pingora-direct`,检查 `genarrative-api.service`、`genarrative-external-generation-controller.service`、`spacetimedb.service`、`genarrative-pingora-gateway.service`,不再要求 `nginx.service` active。 +- 默认 `GENARRATIVE_HEALTH_PATROL_GATEWAY_MODE=nginx`,检查 `genarrative-api.service`、唯一的 `genarrative-bgfilter-worker.service`、`genarrative-external-generation-controller.service`、`spacetimedb.service`、`nginx.service` 是否 active;Pingora 直连切换后改为 `pingora-direct`,仍要求 BgFilter worker active,只把网关检查从 `nginx.service` 切到 `genarrative-pingora-gateway.service`。 - 至少一个 `genarrative-external-generation-worker@*.service` 实例是否 active;如果 controller 存活但 worker 全部退出,巡检直接返回 `CRITICAL`,避免外部生成队列长期无人消费。 - API 直连 `/healthz`、`/readyz`。 +- BgFilter worker 直连 `http://127.0.0.1:8083/readyz`;可通过 `GENARRATIVE_HEALTH_PATROL_BGFILTER_BASE_URL` 覆盖探测地址。 - SpacetimeDB 直连 `/v1/ping`。 - 默认通过本机公网网关入口检查 `/`;需要增加现役公开 API 时,用可重复的 `--public-path` 或对应环境配置显式追加 `/api/editor/showcase/resources`。如需走正式域名,在 `/etc/genarrative/health-patrol.env` 配置 `GENARRATIVE_HEALTH_PATROL_PUBLIC_BASE_URL=https://<域名>`。若在目标机本机打 `https://127.0.0.1` 或 `http://127.0.0.1`,同时配置 `GENARRATIVE_HEALTH_PATROL_PUBLIC_HOST=<域名>`,确保 public probe 命中正确 vhost / Host 语义;不得把已退役模板 API 重新加入健康探针。 - Pingora 影子网关只在同时配置 `GENARRATIVE_HEALTH_PATROL_PINGORA_BASE_URL` 与 `GENARRATIVE_HEALTH_PATROL_PINGORA_PROBE_TOKEN` 时纳入巡检;脚本会访问 `GET /__genarrative_pingora/healthz` 并校验返回 `gateway=pingora-shadow`,未配置时不影响现有生产巡检。 -- 最近 15 分钟对应 gateway mode 下 `genarrative-api.service`、`genarrative-external-generation-controller.service`、`genarrative-external-generation-worker@*.service`、`spacetimedb.service` 和 `nginx.service` 或 `genarrative-pingora-gateway.service` 的 `err..alert` 日志。 +- 最近 15 分钟对应 gateway mode 下 `genarrative-api.service`、`genarrative-bgfilter-worker.service`、`genarrative-external-generation-controller.service`、`genarrative-external-generation-worker@*.service`、`spacetimedb.service` 和 `nginx.service` 或 `genarrative-pingora-gateway.service` 的 `err..alert` 日志。 巡检脚本的显式 `--timeout-ms`、`--slow-ms`、`GENARRATIVE_HEALTH_PATROL_TIMEOUT_MS` 和 `GENARRATIVE_HEALTH_PATROL_SLOW_MS` 必须是正整数,非法值会直接失败,不静默回退默认 `5000ms` / `3000ms`;生产巡检、health patrol env 复核和 env 切换脚本读取的布尔 env 也必须是明确布尔值,非法值会直接失败。health patrol env 复核脚本的 `--env-file`,以及 env 切换脚本的 `--env-file` / `--check-script` 都必须是绝对路径且不能是文件系统根目录,也不能包含换行或 NUL;env 切换脚本写入的 public base URL / Host 同样不能包含换行或 NUL。env 切换 `--apply` 还必须直接指向真实普通 env 文件,不能传符号链接。切换窗口调整超时、慢请求阈值、巡检模式开关或 env 路径时,先确认 env 与命令行参数格式正确,再把失败当作配置错误处理。 @@ -558,6 +569,10 @@ Nginx 与 Pingora 在维护 marker 存在时对内网来源绕过整站维护闸 生产环境变量模板:`deploy/env/api-server.env.example`。真实密钥只放服务器,不提交 Git,不写入文档示例。 +BgFilter 受限资源调度使用非模板单实例 `genarrative-bgfilter-worker.service`,固定以 `GENARRATIVE_PROCESS_ROLE=bgfilter-worker` 监听 `127.0.0.1:8083`,不挂 Nginx 或公共路由。`api-server.env` 是父侧与子 worker 的共享基础,集中保存 provider、OSS、`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=180000`、内部 worker 地址、Token 文件和连接超时;BgFilter unit 先加载它,再加载只含 `HOST / PORT / CONCURRENCY / MAX_REQUESTS`、flat 熔断阈值 / cooldown 和可选日志覆盖的 `/etc/genarrative/bgfilter-worker.env`。同一配置只保留一份,专属 env 不重复 provider、OSS、Token 或请求 timeout。外部生成 worker unit 也会在共享 API env 后加载 `/etc/genarrative/external-generation-worker.env`;该文件如重复定义内部 base URL、Token / Token 文件、connect timeout、provider attempt timeout、OSS bucket 或 endpoint,最终有效值必须与共享 API env 完全一致,否则发布失败,避免父进程把源图写到子 worker 不会读取的位置。外部生成 worker 可以使用同一 bucket 下权限等价或更小的独立 AK,不要求凭据文本一致。Token 文件由 Provision 以 `root:genarrative 0440` 创建或保留,env 只引用路径,不保存内部 Token 明文;env 示例和仓库不得出现真实 provider / OSS secret。 + +生产发布必须按 `共享配置 / endpoint / Token / N/Q 预检 → stop 旧 BgFilter worker → 等待 systemd 排空 → start 唯一实例 → 检查 http://127.0.0.1:8083/readyz → 重启 API → 重启 external-generation worker / controller` 的顺序执行。Token 预检要求 API env 指向非空、非符号链接的普通文件,权限固定为 `root:genarrative 0440`;endpoint 预检要求父进程 `GENARRATIVE_BGFILTER_WORKER_BASE_URL`、子 worker `HOST / PORT` 和 readiness URL 指向同一个 `127.0.0.1:`。任何预检失败都发生在 `current` 切换和停止现役 worker 之前。非模板 unit、固定 loopback 端口和显式 stop/start 共同避免新旧 BgFilter worker 重叠;第二实例会因固定端口绑定失败。内部协议的 `requestBudgetMs` 硬上限为 `600s`,unit 使用 `TimeoutStopSec=900` 给已 admission 请求和已启动 provider attempt 留足排空余量,禁止沿用约 `90s` 的默认停止窗口。默认 API deploy 会安装、enable、启动并验活该 unit;只有明确回滚或应急排障时才使用 `--no-bgfilter-worker` 跳过,且不得让父进程偷偷恢复为直连 BgFilter。 + `api-server` 进程角色由 `GENARRATIVE_PROCESS_ROLE` 控制:`api` 只监听 HTTP,`external-generation-worker` 只消费外部生成队列,`external-generation-controller` 只管理 worker systemd 实例,`all` 仅用于本地或临时 smoke,不隐式启动 controller。外部生成策略由 `GENARRATIVE_EXTERNAL_GENERATION_MODE` 控制;生产和容器压测默认保持 `queue`,本地 `npm run dev` / `npm run dev:api-server` 默认由 dev 脚本注入 `GENARRATIVE_PROCESS_ROLE=all`,如果外部生成策略为 `queue` 会由同一进程消费队列。`inline` 只用于本地或低并发同步排查,HTTP handler 会直接复用 worker executor,完成后返回 `completed`,但不会落 `external_generation_job`,也不能通过增加 worker 进程扩吞吐。外部生成 worker 使用同一发布包和同一套 SpacetimeDB 配置,按实例数和 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY` 动态扩缩;生产默认由 `genarrative-external-generation-controller.service` 读取 `get_external_generation_queue_stats_and_return`,按 `claimable_pending + running_active + expired_running` 计算目标 worker 数,并对 `genarrative-external-generation-worker@N.service` 精确执行 `systemctl start/stop`。controller 参数模板是 `deploy/env/external-generation-controller.env.example`:默认保底 `MIN_WORKERS=1`、上限 `MAX_WORKERS=8`、每 worker 目标 `TARGET_JOBS_PER_WORKER=2`、`POLL_INTERVAL_MS=10000`、连续 `SCALE_DOWN_IDLE_ROUNDS=6` 轮完全空闲才缩容;缩容每轮只停止最高编号的一个实例,且不主动停止 `@1`。worker 收到 SIGINT/SIGTERM 后会停止 claim 新任务并等待当前任务完成;若进程被硬杀、机器断电或超过 systemd `TimeoutStopSec`,未完成任务才会在 lease 过期后由其它 worker 重领。每个 worker 实例应设置唯一 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID`,默认会用主机名和 pid 兜底;systemd 生产模板 `deploy/systemd/genarrative-external-generation-worker@.service` 会用 `%H-%i` 生成实例 ID,并把 tracking outbox 隔离到 `/var/lib/genarrative/tracking-outbox/%H-%i`。`Genarrative-Server-Provision` 会安装 worker 模板、controller unit 和两份专属 env 模板,默认 enable 首个 `genarrative-external-generation-worker@1.service` 与 `genarrative-external-generation-controller.service`;首次 API deploy 会在默认 worker pattern 下自动 `enable --now genarrative-external-generation-worker@1.service` 并等待 worker active,同时重启并验活 controller。手动兜底扩容仍可用 `systemctl start genarrative-external-generation-worker@2.service` / `@3.service`,缩容用 `systemctl stop genarrative-external-generation-worker@N.service`;controller 下轮会按队列压力修正到目标实例数。worker 专属参数模板是 `deploy/env/external-generation-worker.env.example`,密钥与 SpacetimeDB 连接仍复用 `/etc/genarrative/api-server.env`。API 发布脚本默认会重启并验活 `genarrative-external-generation-worker@*.service` 和 `genarrative-external-generation-controller.service`;若本次只发 HTTP 且不希望滚动 worker,可传 `--no-worker-services`,若不希望重启 controller 可传 `--no-worker-controller`。`GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS` 控制空队列轮询间隔,`GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS` 控制单次 lease,worker 会约每三分之一 lease、最长 30 秒续租;该值应覆盖一次心跳网络抖动窗口,不需要大于完整外部生成链路耗时。SpacetimeDB 使用自身事务时间计算 claim/renew/complete/fail,完成和失败回写还会校验 `lease_token` 与未过期 lease,避免同一 job 被过期 worker 覆盖。首版 worker 粒度是单动作单 job,不拆阶段 job;当前外部生成动作覆盖拼图、跳一跳、拼消消、敲木鱼和图片画布编辑器生成 / 抠图入口,纯元信息保存、发布、试玩启动、运行态动作和公开读取继续 inline。图片画布 worker 成功后由后端保存 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`,前端只轮询 job 并重新读取项目快照。当前生成业务失败只做用户重新触发,不做自动业务重试,避免 worker 退款和重试成功之间产生钱包账本漂移。 worker 被硬杀或断电后,lease 过期任务只有尚未耗尽 `max_attempts` 才由其它 worker 重领;最终 attempt 的过期任务由 claim transaction 直接失败并结算,不会继续执行外部 provider。当前业务入队点默认 `max_attempts=1`,因此一次已领取任务若以 lease 过期结束,后续 poll 负责终态与退款收口,而不是发起第二次 provider 请求。 diff --git a/package.json b/package.json index 0f02e12b9..2e1c9a6ef 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "node scripts/dev.mjs", "dev:spacetime": "node scripts/dev.mjs spacetime", "dev:api-server": "node scripts/dev.mjs api-server", + "dev:bgfilter-worker": "node scripts/dev.mjs bgfilter-worker", "dev:web": "node scripts/dev.mjs web", "dev:admin-web": "node scripts/dev.mjs admin-web", "server-manager:panel": "cargo run -p server-manager-panel --manifest-path server-rs/Cargo.toml", diff --git a/scripts/check-production-api-deploy.mjs b/scripts/check-production-api-deploy.mjs index 84c9f4b5e..7fc27a5db 100644 --- a/scripts/check-production-api-deploy.mjs +++ b/scripts/check-production-api-deploy.mjs @@ -45,6 +45,18 @@ function main() { assertDeployRejectsPingoraArtifactMissingManifestEntry(); assertDeployRejectsPingoraManifestEntryMissingArtifact(); assertDeployRequiresPingoraWhenRequested(); + assertDeployRejectsInvalidSharedBgFilterRequestTimeout(); + assertDeployRejectsBgFilterWorkerSharedEnvDrift(); + assertDeployRejectsEmptyBgFilterWorkerSharedEnvOverride(); + assertDeployRejectsExternalGenerationWorkerBgFilterEnvDrift(); + assertDeployRejectsBgFilterParentChildEndpointDrift(); + assertDeployRejectsBgFilterHealthEndpointDrift(); + assertDeployRejectsNonLoopbackBgFilterListener(); + assertDeployRejectsMissingBgFilterWorkerCapacity(); + assertDeployRejectsInvalidBgFilterWorkerCapacity(); + assertDeployRejectsInlineBgFilterInternalToken(); + assertDeployRejectsEmptyBgFilterInternalToken(); + assertDeployRejectsWhitespaceBgFilterInternalToken(); assertReadinessFailureKeepsMaintenanceAfterCurrentSwitch(); assertMissingReleaseManifestFails(); assertReleaseManifestMissingApiArtifactFails(); @@ -117,6 +129,20 @@ function assertMaintenanceKept(fixture, reason) { } } +function assertBgFilterPreflightFailedBeforeSwitch(fixture, reason) { + if (existsSync(fixture.currentLink)) { + failures.push(`${reason} 时不得切换 current。`); + } + if ( + readOptionalCommandsLog(fixture).includes( + 'systemctl stop genarrative-bgfilter-worker.service', + ) + ) { + failures.push(`${reason} 时不得停止当前 BgFilter worker。`); + } + assertMaintenanceCleared(fixture, reason); +} + function assertDeployCopiesPingoraDirectReleaseDependencies() { const fixture = prepareFixture('with-direct-checks'); const result = runDeploy(fixture); @@ -275,6 +301,13 @@ function assertDeployCopiesPingoraDirectReleaseDependencies() { ), 'current release 必须包含外部生成 worker controller systemd 单元。', ); + assertFileExists( + path.join( + releaseDir, + 'deploy/systemd/genarrative-bgfilter-worker.service', + ), + 'current release 必须包含唯一 BgFilter worker systemd 单元。', + ); assertFileExists( path.join( fixture.systemdUnitDir, @@ -289,6 +322,35 @@ function assertDeployCopiesPingoraDirectReleaseDependencies() { ), 'API deploy 必须把随包外部生成 worker controller 单元安装到 systemd unit 目录。', ); + assertFileExists( + path.join( + fixture.systemdUnitDir, + 'genarrative-bgfilter-worker.service', + ), + 'API deploy 必须把随包 BgFilter worker 单元安装到 systemd unit 目录。', + ); + const bgfilterUnit = readFileSync( + path.join(fixture.systemdUnitDir, 'genarrative-bgfilter-worker.service'), + 'utf8', + ); + const sharedEnvIndex = bgfilterUnit.indexOf( + 'EnvironmentFile=/etc/genarrative/api-server.env', + ); + const dedicatedEnvIndex = bgfilterUnit.indexOf( + 'EnvironmentFile=/etc/genarrative/bgfilter-worker.env', + ); + if ( + sharedEnvIndex < 0 || + dedicatedEnvIndex < 0 || + sharedEnvIndex > dedicatedEnvIndex + ) { + failures.push('BgFilter unit 必须先加载共享 API env,再加载专属 worker env。'); + } + assertIncludes( + bgfilterUnit, + 'TimeoutStopSec=900', + 'BgFilter unit 必须给最多 600s 的内部请求预算留足优雅排空时间。', + ); assertFileExists( path.join(releaseDir, 'deploy/pingora/pingora-gateway.env.example'), 'current release 必须包含 Pingora env 示例。', @@ -304,6 +366,28 @@ function assertDeployCopiesPingoraDirectReleaseDependencies() { path.join(releaseDir, 'deploy/env/health-patrol.env.example'), 'current release 必须包含健康巡检 env 示例。', ); + assertFileExists( + path.join(releaseDir, 'deploy/env/bgfilter-worker.env.example'), + 'current release 必须包含 BgFilter worker env 示例。', + ); + const bgfilterEnvExample = readFileSync( + path.join(releaseDir, 'deploy/env/bgfilter-worker.env.example'), + 'utf8', + ); + assertIncludes( + bgfilterEnvExample, + 'GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3', + 'BgFilter 专属 env 必须提供 flat 熔断阈值。', + ); + for (const sharedKey of [ + 'GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=', + 'GENARRATIVE_EDITOR_BGFILTER_BASE_URL=', + 'ALIYUN_OSS_ACCESS_KEY_ID=', + ]) { + if (bgfilterEnvExample.includes(sharedKey)) { + failures.push(`BgFilter 专属 env 不得重复共享配置: ${sharedKey}`); + } + } assertFileExists( path.join(releaseDir, 'deploy/env/pingora-direct-live.env.example'), 'current release 必须包含 Pingora direct live env 示例。', @@ -338,6 +422,16 @@ function assertDeployCopiesPingoraDirectReleaseDependencies() { 'GENARRATIVE_SPACETIME_SERVER_URL=http://127.0.0.1:3101', '部署脚本必须写入 SpacetimeDB server URL。', ); + assertIncludes( + apiEnv, + 'GENARRATIVE_BGFILTER_WORKER_BASE_URL=http://127.0.0.1:18083', + '部署脚本必须为父进程补齐内部 BgFilter worker 地址。', + ); + assertIncludes( + apiEnv, + `GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE=${fixture.bgfilterTokenFile}`, + '部署脚本必须保留并校验父进程指定的内部 BgFilter Token 文件。', + ); const commandsLog = readFileSync(fixture.commandsLog, 'utf8'); assertIncludes( @@ -350,6 +444,44 @@ function assertDeployCopiesPingoraDirectReleaseDependencies() { 'systemctl restart genarrative-api.service', '部署脚本必须重启 API service。', ); + assertIncludes( + commandsLog, + 'systemctl stop genarrative-bgfilter-worker.service', + '部署脚本必须先停止旧 BgFilter worker 并等待 systemd 排空。', + ); + assertIncludes( + commandsLog, + 'systemctl start genarrative-bgfilter-worker.service', + '部署脚本必须启动唯一 BgFilter worker。', + ); + assertIncludes( + commandsLog, + 'curl -fsS --max-time 2 http://127.0.0.1:18083/readyz', + '部署脚本必须在重启父进程前验活 BgFilter worker。', + ); + const bgfilterReadyIndex = commandsLog.indexOf( + 'curl -fsS --max-time 2 http://127.0.0.1:18083/readyz', + ); + const bgfilterStopIndex = commandsLog.indexOf( + 'systemctl stop genarrative-bgfilter-worker.service', + ); + const bgfilterStartIndex = commandsLog.indexOf( + 'systemctl start genarrative-bgfilter-worker.service', + ); + const apiRestartIndex = commandsLog.indexOf( + 'systemctl restart genarrative-api.service', + ); + if ( + bgfilterStopIndex < 0 || + bgfilterStartIndex < 0 || + bgfilterReadyIndex < 0 || + apiRestartIndex < 0 || + bgfilterStopIndex > bgfilterStartIndex || + bgfilterStartIndex > bgfilterReadyIndex || + bgfilterReadyIndex > apiRestartIndex + ) { + failures.push('BgFilter worker 必须按 stop → start → readiness → API restart 排序。'); + } assertIncludes( commandsLog, 'curl -fsS --max-time 2 http://127.0.0.1:18082/readyz', @@ -669,6 +801,12 @@ function assertReadinessFailureKeepsMaintenanceAfterCurrentSwitch() { assertMaintenanceKept(fixture, 'current 切换后的 readiness 失败'); const releaseDir = path.join(fixture.releaseRoot, fixture.version); + if (!existsSync(fixture.currentLink)) { + failures.push( + `readiness 失败用例在 current 切换前提前退出。\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + return; + } const currentTarget = readlinkSync(fixture.currentLink); if (currentTarget !== releaseDir) { failures.push( @@ -677,6 +815,323 @@ function assertReadinessFailureKeepsMaintenanceAfterCurrentSwitch() { } } +function assertDeployRejectsInvalidSharedBgFilterRequestTimeout() { + const fixture = prepareFixture('invalid-shared-bgfilter-request-timeout'); + writeFileSync( + fixture.apiEnvFile, + `${readFileSync(fixture.apiEnvFile, 'utf8')}GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=invalid\n`, + 'utf8', + ); + const result = runDeploy(fixture); + + if (result.status === 0) { + failures.push('共享 BgFilter provider attempt timeout 非法时部署必须失败。'); + } + assertIncludes( + result.stderr, + 'GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS 必须在共享 API env 中配置为正整数毫秒', + '共享 BgFilter timeout 预检必须给出明确错误。', + ); + if ( + readOptionalCommandsLog(fixture).includes( + 'systemctl stop genarrative-bgfilter-worker.service', + ) + ) { + failures.push('共享 BgFilter timeout 预检失败时不得停止当前 worker。'); + } + assertMaintenanceCleared(fixture, '共享 BgFilter timeout 预检失败'); +} + +function assertDeployRejectsEmptyBgFilterInternalToken() { + const fixture = prepareFixture('empty-bgfilter-internal-token'); + writeFileSync(fixture.bgfilterTokenFile, '', 'utf8'); + const result = runDeploy(fixture); + + if (result.status === 0) { + failures.push('BgFilter 内部 Token 为空时部署必须失败。'); + } + assertIncludes( + result.stderr, + 'BgFilter 内部 Token 必须是非空普通文件且不能是符号链接', + 'Token 文件预检必须给出明确错误。', + ); + assertBgFilterPreflightFailedBeforeSwitch( + fixture, + 'BgFilter Token 文件预检失败', + ); +} + +function assertDeployRejectsWhitespaceBgFilterInternalToken() { + const fixture = prepareFixture('whitespace-bgfilter-internal-token'); + writeFileSync(fixture.bgfilterTokenFile, ' \n\t\n', 'utf8'); + const result = runDeploy(fixture); + + if (result.status === 0) { + failures.push('BgFilter 内部 Token 只包含空白字符时部署必须失败。'); + } + assertIncludes( + result.stderr, + 'BgFilter 内部 Token 文件必须至少包含一个非空白字符', + '纯空白 Token 文件预检必须给出明确错误。', + ); + assertBgFilterPreflightFailedBeforeSwitch( + fixture, + 'BgFilter 纯空白 Token 文件预检失败', + ); +} + +function assertDeployRejectsInlineBgFilterInternalToken() { + const cases = [ + ['api-env', 'apiEnvFile'], + ['external-generation-worker-env', 'externalGenerationWorkerEnvFile'], + ['worker-env', 'bgfilterWorkerEnvFile'], + ]; + for (const [name, targetField] of cases) { + const fixture = prepareFixture(`inline-bgfilter-token-${name}`); + const targetFile = fixture[targetField]; + writeFileSync( + targetFile, + `${readFileSync(targetFile, 'utf8')}GENARRATIVE_BGFILTER_INTERNAL_TOKEN=plaintext-must-be-rejected\n`, + 'utf8', + ); + const result = runDeploy(fixture); + + if (result.status === 0) { + failures.push(`${name} 保存 BgFilter 内部 Token 明文时部署必须失败。`); + } + assertIncludes( + result.stderr, + '不得保存 GENARRATIVE_BGFILTER_INTERNAL_TOKEN 明文', + `${name} 明文 Token 预检必须给出明确错误。`, + ); + assertBgFilterPreflightFailedBeforeSwitch( + fixture, + `${name} 明文 BgFilter Token 预检失败`, + ); + } +} + +function assertDeployRejectsBgFilterWorkerSharedEnvDrift() { + const fixture = prepareFixture('bgfilter-worker-shared-env-drift'); + writeFileSync( + fixture.bgfilterWorkerEnvFile, + `${readFileSync(fixture.bgfilterWorkerEnvFile, 'utf8')}GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=120000\n`, + 'utf8', + ); + const result = runDeploy(fixture); + + if (result.status === 0) { + failures.push('BgFilter 专属 env 覆盖不同的共享 timeout 时部署必须失败。'); + } + assertIncludes( + result.stderr, + 'BgFilter 专属 env 中的共享配置与 API env 不一致: GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS', + '共享配置漂移预检必须给出具体变量名。', + ); + if ( + readOptionalCommandsLog(fixture).includes( + 'systemctl stop genarrative-bgfilter-worker.service', + ) + ) { + failures.push('共享配置漂移预检失败时不得停止当前 BgFilter worker。'); + } + assertMaintenanceCleared(fixture, 'BgFilter 共享配置漂移预检失败'); +} + +function assertDeployRejectsEmptyBgFilterWorkerSharedEnvOverride() { + const fixture = prepareFixture('bgfilter-worker-empty-shared-env-override'); + writeFileSync( + fixture.bgfilterWorkerEnvFile, + `${readFileSync(fixture.bgfilterWorkerEnvFile, 'utf8')}GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE=\n`, + 'utf8', + ); + const result = runDeploy(fixture); + + if (result.status === 0) { + failures.push('BgFilter 专属 env 以空值覆盖共享 Token 文件路径时部署必须失败。'); + } + assertIncludes( + result.stderr, + 'BgFilter 专属 env 中的共享配置与 API env 不一致: GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE', + '空值覆盖共享配置时预检必须给出具体变量名。', + ); + if ( + readOptionalCommandsLog(fixture).includes( + 'systemctl stop genarrative-bgfilter-worker.service', + ) + ) { + failures.push('空值覆盖共享配置的预检失败时不得停止当前 BgFilter worker。'); + } + assertMaintenanceCleared(fixture, 'BgFilter 空值覆盖共享配置预检失败'); +} + +function assertDeployRejectsExternalGenerationWorkerBgFilterEnvDrift() { + const cases = [ + [ + 'request-timeout', + 'GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=120000', + 'GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS', + ], + [ + 'base-url', + 'GENARRATIVE_BGFILTER_WORKER_BASE_URL=http://127.0.0.1:19083', + 'GENARRATIVE_BGFILTER_WORKER_BASE_URL', + ], + [ + 'token-file', + 'GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE=', + 'GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE', + ], + [ + 'connect-timeout', + 'GENARRATIVE_BGFILTER_WORKER_CONNECT_TIMEOUT_MS=9000', + 'GENARRATIVE_BGFILTER_WORKER_CONNECT_TIMEOUT_MS', + ], + ['oss-bucket', 'ALIYUN_OSS_BUCKET=wrong-source-bucket', 'ALIYUN_OSS_BUCKET'], + [ + 'oss-endpoint', + 'ALIYUN_OSS_ENDPOINT=https://oss-wrong.example.com', + 'ALIYUN_OSS_ENDPOINT', + ], + ]; + + for (const [name, assignment, key] of cases) { + const fixture = prepareFixture(`external-worker-bgfilter-drift-${name}`); + writeFileSync( + fixture.externalGenerationWorkerEnvFile, + `${readFileSync(fixture.externalGenerationWorkerEnvFile, 'utf8')}${assignment}\n`, + 'utf8', + ); + const result = runDeploy(fixture); + + if (result.status === 0) { + failures.push(`外部生成 worker 覆盖 BgFilter 共享配置 ${key} 时部署必须失败。`); + } + assertIncludes( + result.stderr, + `外部生成 worker env 中的 BgFilter 共享配置与 API env 不一致: ${key}`, + `外部生成 worker 的 ${key} 漂移预检必须给出具体变量名。`, + ); + assertBgFilterPreflightFailedBeforeSwitch( + fixture, + `外部生成 worker 的 ${key} 漂移预检失败`, + ); + } +} + +function assertDeployRejectsBgFilterParentChildEndpointDrift() { + const fixture = prepareFixture('bgfilter-parent-child-endpoint-drift'); + writeFileSync( + fixture.apiEnvFile, + `${readFileSync(fixture.apiEnvFile, 'utf8')}GENARRATIVE_BGFILTER_WORKER_BASE_URL=http://127.0.0.1:19083\n`, + 'utf8', + ); + const result = runDeploy(fixture); + + if (result.status === 0) { + failures.push('父进程 BgFilter base URL 与子 worker listener 不一致时部署必须失败。'); + } + assertIncludes( + result.stderr, + '父进程 GENARRATIVE_BGFILTER_WORKER_BASE_URL 必须与 BgFilter worker 有效监听地址一致', + '父子 BgFilter endpoint 漂移预检必须给出明确错误。', + ); + assertBgFilterPreflightFailedBeforeSwitch(fixture, '父子 BgFilter endpoint 漂移预检失败'); +} + +function assertDeployRejectsBgFilterHealthEndpointDrift() { + const fixture = prepareFixture('bgfilter-health-endpoint-drift'); + const result = runDeploy(fixture, { + bgfilterWorkerHealthUrl: 'http://127.0.0.1:19083/readyz', + }); + + if (result.status === 0) { + failures.push('BgFilter readiness URL 与父子 endpoint 不一致时部署必须失败。'); + } + assertIncludes( + result.stderr, + '--bgfilter-worker-health-url 必须与父进程 base URL 和子 worker listener 指向同一 loopback endpoint', + 'BgFilter readiness endpoint 漂移预检必须给出明确错误。', + ); + assertBgFilterPreflightFailedBeforeSwitch(fixture, 'BgFilter readiness endpoint 漂移预检失败'); +} + +function assertDeployRejectsNonLoopbackBgFilterListener() { + const fixture = prepareFixture('bgfilter-non-loopback-listener'); + writeFileSync( + fixture.bgfilterWorkerEnvFile, + `${readFileSync(fixture.bgfilterWorkerEnvFile, 'utf8')}GENARRATIVE_BGFILTER_WORKER_HOST=0.0.0.0\n`, + 'utf8', + ); + const result = runDeploy(fixture); + + if (result.status === 0) { + failures.push('BgFilter worker listener 不是固定 loopback 时部署必须失败。'); + } + assertIncludes( + result.stderr, + 'BgFilter worker 首版必须监听 127.0.0.1', + 'BgFilter 非 loopback listener 预检必须给出明确错误。', + ); + assertBgFilterPreflightFailedBeforeSwitch(fixture, 'BgFilter 非 loopback listener 预检失败'); +} + +function assertDeployRejectsInvalidBgFilterWorkerCapacity() { + const fixture = prepareFixture('bgfilter-worker-invalid-capacity'); + writeFileSync( + fixture.bgfilterWorkerEnvFile, + `${readFileSync(fixture.bgfilterWorkerEnvFile, 'utf8')}GENARRATIVE_BGFILTER_WORKER_CONCURRENCY=8\nGENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS=4\n`, + 'utf8', + ); + const result = runDeploy(fixture); + + if (result.status === 0) { + failures.push('BgFilter worker 的 Q 小于 N 时部署必须失败。'); + } + assertIncludes( + result.stderr, + 'GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS 必须大于或等于 CONCURRENCY', + 'BgFilter N/Q 预检必须给出明确错误。', + ); + if ( + readOptionalCommandsLog(fixture).includes( + 'systemctl stop genarrative-bgfilter-worker.service', + ) + ) { + failures.push('BgFilter N/Q 预检失败时不得停止当前 worker。'); + } + assertMaintenanceCleared(fixture, 'BgFilter N/Q 预检失败'); +} + +function assertDeployRejectsMissingBgFilterWorkerCapacity() { + for (const key of [ + 'GENARRATIVE_BGFILTER_WORKER_CONCURRENCY', + 'GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS', + ]) { + const fixture = prepareFixture(`bgfilter-worker-missing-${key.toLowerCase()}`); + const current = readFileSync(fixture.bgfilterWorkerEnvFile, 'utf8'); + const next = current + .split(/\r?\n/u) + .filter((line) => !line.startsWith(`${key}=`)) + .join('\n'); + writeFileSync(fixture.bgfilterWorkerEnvFile, `${next}\n`, 'utf8'); + const result = runDeploy(fixture); + + if (result.status === 0) { + failures.push(`BgFilter worker 缺少 ${key} 时部署必须失败。`); + } + assertIncludes( + result.stderr, + `${key} 必须是正整数`, + `BgFilter worker 缺少 ${key} 时必须给出明确错误。`, + ); + assertBgFilterPreflightFailedBeforeSwitch( + fixture, + `BgFilter worker 缺少 ${key} 的预检失败`, + ); + } +} + function assertMissingReleaseManifestFails() { const fixture = prepareFixture('missing-release-manifest'); rmSync(path.join(fixture.sourceDir, 'release-manifest.json')); @@ -1325,6 +1780,17 @@ function prepareFixture(name) { const releaseRoot = path.join(root, 'releases'); const currentLink = path.join(root, 'current'); const apiEnvFile = path.join(root, 'etc', 'api-server.env'); + const externalGenerationWorkerEnvFile = path.join( + root, + 'etc', + 'external-generation-worker.env', + ); + const bgfilterWorkerEnvFile = path.join( + root, + 'etc', + 'bgfilter-worker.env', + ); + const bgfilterTokenFile = path.join(root, 'etc', 'bgfilter-worker.token'); const pingoraEnvFile = path.join(root, 'etc', 'pingora-gateway.env'); const maintenanceFile = path.join(root, 'maintenance', 'enabled'); const fakeBin = path.join(root, 'bin'); @@ -1355,10 +1821,31 @@ function prepareFixture(name) { 'GENARRATIVE_TRACKING_OUTBOX_ENABLED=false', `GENARRATIVE_WALLET_REFUND_OUTBOX_DIR=${path.join(root, 'wallet-refund-outbox')}`, 'GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS=5000', + 'GENARRATIVE_BGFILTER_WORKER_BASE_URL=http://127.0.0.1:18083', + `GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE=${bgfilterTokenFile}`, + 'GENARRATIVE_BGFILTER_WORKER_CONNECT_TIMEOUT_MS=2000', + 'GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=180000', '', ].join('\n'), 'utf8', ); + writeFileSync( + externalGenerationWorkerEnvFile, + 'GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY=2\n', + 'utf8', + ); + writeFileSync( + bgfilterWorkerEnvFile, + [ + 'GENARRATIVE_BGFILTER_WORKER_HOST=127.0.0.1', + 'GENARRATIVE_BGFILTER_WORKER_PORT=18083', + 'GENARRATIVE_BGFILTER_WORKER_CONCURRENCY=4', + 'GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS=128', + '', + ].join('\n'), + 'utf8', + ); + writeFileSync(bgfilterTokenFile, 'fixture-bgfilter-token\n', 'utf8'); writePingoraEnv({ pingoraEnvFile }); chmodExecutable(path.join(sourceDir, 'api-server')); writeSha256(sourceDir, 'api-server'); @@ -1507,6 +1994,13 @@ function prepareFixture(name) { 'deploy/systemd/genarrative-external-generation-controller.service', ), ); + copyFile( + 'deploy/systemd/genarrative-bgfilter-worker.service', + path.join( + sourceDir, + 'deploy/systemd/genarrative-bgfilter-worker.service', + ), + ); copyFile( 'deploy/pingora/pingora-gateway.env.example', path.join(sourceDir, 'deploy/pingora/pingora-gateway.env.example'), @@ -1515,6 +2009,10 @@ function prepareFixture(name) { 'deploy/env/health-patrol.env.example', path.join(sourceDir, 'deploy/env/health-patrol.env.example'), ); + copyFile( + 'deploy/env/bgfilter-worker.env.example', + path.join(sourceDir, 'deploy/env/bgfilter-worker.env.example'), + ); copyFile( 'deploy/env/pingora-direct-live.env.example', path.join(sourceDir, 'deploy/env/pingora-direct-live.env.example'), @@ -1595,7 +2093,7 @@ function prepareFixture(name) { [ '#!/usr/bin/env bash', `printf 'curl %s\\n' "$*" >> ${shellQuote(commandsLog)}`, - 'if [[ "${FAKE_CURL_FAIL:-false}" == "true" ]]; then', + 'if [[ "${FAKE_CURL_FAIL:-false}" == "true" && "$*" == *"18082/readyz"* ]]; then', ' exit 22', 'fi', 'exit 0', @@ -1613,6 +2111,16 @@ function prepareFixture(name) { ].join('\n'), 'utf8', ); + writeFileSync( + path.join(fakeBin, 'stat'), + [ + '#!/usr/bin/env bash', + 'echo "root:genarrative:440"', + 'exit 0', + '', + ].join('\n'), + 'utf8', + ); writeFileSync( path.join(fakeBin, 'cp'), [ @@ -1655,6 +2163,7 @@ function prepareFixture(name) { chmodExecutable(path.join(fakeBin, 'systemctl')); chmodExecutable(path.join(fakeBin, 'curl')); chmodExecutable(path.join(fakeBin, 'sleep')); + chmodExecutable(path.join(fakeBin, 'stat')); chmodExecutable(path.join(fakeBin, 'cp')); chmodExecutable(path.join(fakeBin, 'sudo')); @@ -1664,6 +2173,9 @@ function prepareFixture(name) { releaseRoot, currentLink, apiEnvFile, + externalGenerationWorkerEnvFile, + bgfilterWorkerEnvFile, + bgfilterTokenFile, pingoraEnvFile, maintenanceFile, fakeBin, @@ -1736,6 +2248,12 @@ function runDeploy(fixture, options = {}) { 'http://127.0.0.1:18082/readyz', '--api-env-file', options.apiEnvFile ?? fixture.apiEnvFile, + '--worker-env-file', + fixture.externalGenerationWorkerEnvFile, + '--bgfilter-worker-env-file', + fixture.bgfilterWorkerEnvFile, + '--bgfilter-worker-health-url', + options.bgfilterWorkerHealthUrl ?? 'http://127.0.0.1:18083/readyz', '--database', 'genarrative-prod', '--spacetime-server-url', @@ -1757,6 +2275,11 @@ function runDeploy(fixture, options = {}) { ...process.env, PATH: `${fixture.fakeBin}:${process.env.PATH || ''}`, GENARRATIVE_MAINTENANCE_FILE: fixture.maintenanceFile, + GENARRATIVE_MAINTENANCE_PAGE_FILE: path.join( + fixture.root, + 'maintenance', + 'page.html', + ), FAKE_PINGORA_ACTIVE: options.pingoraActive === false ? 'false' : 'true', FAKE_PINGORA_DIRECT_ENTRY: options.pingoraDirectEntry === true ? 'true' : 'false', diff --git a/scripts/check-production-api-release.mjs b/scripts/check-production-api-release.mjs index 8495269f1..9880a3a7d 100644 --- a/scripts/check-production-api-release.mjs +++ b/scripts/check-production-api-release.mjs @@ -258,6 +258,42 @@ function assertApiReleaseContainsPingoraDirectDependencies() { ), 'API release 必须包含外部生成 worker controller systemd 单元。', ); + assertFileExists( + path.join( + releaseDir, + 'deploy/systemd/genarrative-bgfilter-worker.service', + ), + 'API release 必须包含唯一 BgFilter worker systemd 单元。', + ); + assertFileExists( + path.join(releaseDir, 'deploy/env/bgfilter-worker.env.example'), + 'API release 必须包含 BgFilter worker env 示例。', + ); + const bgfilterUnit = readFileSync( + path.join( + releaseDir, + 'deploy/systemd/genarrative-bgfilter-worker.service', + ), + 'utf8', + ); + const sharedEnvIndex = bgfilterUnit.indexOf( + 'EnvironmentFile=/etc/genarrative/api-server.env', + ); + const dedicatedEnvIndex = bgfilterUnit.indexOf( + 'EnvironmentFile=/etc/genarrative/bgfilter-worker.env', + ); + if ( + sharedEnvIndex < 0 || + dedicatedEnvIndex < 0 || + sharedEnvIndex > dedicatedEnvIndex + ) { + failures.push('API release 的 BgFilter unit 必须按共享 env → 专属 env 加载。'); + } + assertIncludes( + bgfilterUnit, + 'TimeoutStopSec=900', + 'API release 的 BgFilter unit 必须给最多 600s 的内部请求预算留足优雅排空时间。', + ); assertFileExists( path.join(releaseDir, 'deploy/pingora/pingora-gateway.env.example'), 'API release 必须包含 Pingora env 示例。', diff --git a/scripts/check-production-health-patrol.mjs b/scripts/check-production-health-patrol.mjs index cb3436eff..924288136 100644 --- a/scripts/check-production-health-patrol.mjs +++ b/scripts/check-production-health-patrol.mjs @@ -54,6 +54,11 @@ function assertPublicBaseUrlDefaultsToGatewayEntry() { "process.env.GENARRATIVE_HEALTH_PATROL_PUBLIC_BASE_URL ||\n 'http://127.0.0.1'", 'publicBaseUrl 默认必须指向本机网关入口,不能回落到 API 直连端口。', ); + assertIncludes( + script, + "process.env.GENARRATIVE_HEALTH_PATROL_BGFILTER_BASE_URL ||\n 'http://127.0.0.1:8083'", + 'BgFilter worker 巡检默认必须指向唯一实例的 loopback 端口。', + ); if ( script.includes( 'process.env.GENARRATIVE_HEALTH_PATROL_PUBLIC_BASE_URL ||\n process.env.GENARRATIVE_HEALTH_PATROL_API_BASE_URL', @@ -85,6 +90,14 @@ async function assertNginxModeChecksNginxService() { 'systemctl is-active nginx.service', 'nginx gateway mode 必须检查 nginx.service。', ); + assertIncludes( + commandsLog, + 'systemctl is-active genarrative-bgfilter-worker.service', + '生产巡检必须检查唯一 BgFilter worker service。', + ); + if (!payload.checks.some((check) => check.name === 'bgfilter:/readyz')) { + failures.push('生产巡检必须探测 BgFilter worker /readyz。'); + } if (commandsLog.includes('genarrative-pingora-gateway.service')) { failures.push( 'nginx gateway mode 不应要求 Pingora gateway service active。', @@ -369,6 +382,8 @@ async function runPatrol(fixture, args) { 'scripts/ops/production-health-patrol.mjs', '--api-base-url', fixture.baseUrl, + '--bgfilter-base-url', + fixture.baseUrl, '--spacetime-base-url', fixture.baseUrl, '--public-base-url', diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index 50a05aab7..f03037d97 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -1647,6 +1647,50 @@ const checks = [ includes: 'genarrative-external-generation-worker@1.service', reason: 'Server-Provision 必须启用外部生成保底 worker 实例。', }, + { + file: 'deploy/systemd/genarrative-bgfilter-worker.service', + includes: 'TimeoutStopSec=900', + reason: + 'BgFilter worker 必须给最多 600s 的内部请求预算留足优雅排空时间,不能沿用 systemd 默认停止窗口。', + }, + { + file: 'scripts/jenkins-server-provision.sh', + includes: 'validate_no_bgfilter_internal_token_plaintext', + reason: + 'Server-Provision 必须拒绝 API 或 BgFilter worker env 保存内部 Token 明文。', + }, + { + file: 'scripts/jenkins-server-provision.sh', + includes: + 'for env_file in "${API_ENV_FILE}" "${WORKER_ENV_FILE}" "${BGFILTER_WORKER_ENV_FILE}"; do', + reason: + 'Server-Provision 必须同时拒绝 external-generation-worker.env 保存 BgFilter 内部 Token 明文。', + }, + { + file: 'scripts/jenkins-server-provision.sh', + includes: + 'validate_bgfilter_env_file_alignment "${WORKER_ENV_FILE}" "外部生成 worker env" "false"', + reason: + 'Server-Provision 启动外部生成 worker 前必须拒绝 BgFilter URL、Token 文件、timeout 与 OSS 位置漂移。', + }, + { + file: 'scripts/jenkins-server-provision.sh', + includes: 'validate_bgfilter_loopback_endpoint_alignment', + reason: + 'Server-Provision 启动 BgFilter worker 前必须确认父 base URL 与子 listener 指向同一 loopback endpoint。', + }, + { + file: 'scripts/jenkins-server-provision.sh', + includes: 'BgFilter 内部 Token 文件不得为空或只包含空白字符', + reason: + 'Server-Provision 必须拒绝仅含空白字符的 BgFilter 内部 Token 文件。', + }, + { + file: 'scripts/jenkins-server-provision.sh', + includes: "root:genarrative:440", + reason: + 'Server-Provision 必须复核 BgFilter 内部 Token 文件的 owner、group 与 0440 权限。', + }, { file: 'scripts/deploy/production-api-deploy.sh', includes: 'ensure_default_worker_service', diff --git a/scripts/deploy/production-api-deploy.sh b/scripts/deploy/production-api-deploy.sh index 9afefeef9..fda71bbf7 100644 --- a/scripts/deploy/production-api-deploy.sh +++ b/scripts/deploy/production-api-deploy.sh @@ -5,11 +5,11 @@ 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] [--worker-env-file /etc/genarrative/external-generation-worker.env] [--database genarrative-prod] [--spacetime-server-url http://127.0.0.1:3101] [--keep-maintenance-mode] + ./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] [--bgfilter-worker-service genarrative-bgfilter-worker.service] [--bgfilter-worker-health-url http://127.0.0.1:8083/readyz] [--bgfilter-worker-env-file /etc/genarrative/bgfilter-worker.env] [--no-bgfilter-worker] [--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] [--keep-maintenance-mode] 说明: 进入维护模式,校验并发布 api-server 单文件,更新 current 链接,重启 systemd 服务并执行 readiness 检查。 - 默认同时重启外部生成 worker controller 和已加载的 worker 实例;未启用 worker 单元时会自动跳过。 + 默认先停止、启动并验活唯一 BgFilter worker,再重启 API、外部生成 worker controller 和已加载的 worker 实例。 若传入 --database,会在重启前把 GENARRATIVE_SPACETIME_DATABASE 写入 api-server 环境文件,避免服务继续读取旧库。 若发布包包含 pingora-gateway,或传入 --require-pingora-gateway,部署脚本会要求 release manifest、二进制与 checksum 一致,再在 current 链接切换后先复核 systemd/env 仍是本机高端口 shadow 配置,启动或重启 Pingora 影子服务并复核 active。 默认在 readiness 通过后退出维护模式;传入 --keep-maintenance-mode 时保留维护文件,供人工验收后再恢复公网。 @@ -175,6 +175,81 @@ if matched_value is not None: fi } +env_contains_nonempty_assignment() { + local file_path="$1" + local key="$2" + + if [[ ! -f "${file_path}" ]]; then + return 1 + fi + + local python_script=' +import sys +from pathlib import Path + +path = Path(sys.argv[1]) +key = sys.argv[2] +for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + current_key, value = line.split("=", 1) + if current_key.strip() != key: + continue + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in ("\"", chr(39)): + value = value[1:-1] + if value.strip(): + raise SystemExit(0) +raise SystemExit(1) +' + + if [[ -r "${file_path}" ]]; then + python3 -c "${python_script}" "${file_path}" "${key}" + else + if ! sudo -n true >/dev/null 2>&1; then + echo "[production-api-deploy] 当前用户无权读取 ${file_path},且 sudo -n 不可用;无法检查运行态环境变量。" >&2 + exit 1 + fi + sudo -n python3 -c "${python_script}" "${file_path}" "${key}" + fi +} + +env_has_assignment() { + local file_path="$1" + local key="$2" + + if [[ ! -f "${file_path}" ]]; then + return 1 + fi + + local python_script=' +import sys +from pathlib import Path + +path = Path(sys.argv[1]) +key = sys.argv[2] +for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + current_key, _ = line.split("=", 1) + if current_key.strip() == key: + raise SystemExit(0) +raise SystemExit(1) +' + + if [[ -r "${file_path}" ]]; then + python3 -c "${python_script}" "${file_path}" "${key}" + else + if ! sudo -n true >/dev/null 2>&1; then + echo "[production-api-deploy] 当前用户无权读取 ${file_path},且 sudo -n 不可用;无法检查运行态环境变量。" >&2 + exit 1 + fi + sudo -n python3 -c "${python_script}" "${file_path}" "${key}" + fi +} + ensure_env_value() { local file_path="$1" local key="$2" @@ -303,10 +378,11 @@ ensure_runtime_env_and_dirs() { 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_BGFILTER_WORKER_BASE_URL" "http://127.0.0.1:8083" + ensure_env_value "${api_env_file}" "GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE" "/etc/genarrative/secrets/bgfilter-worker.token" + ensure_env_value "${api_env_file}" "GENARRATIVE_BGFILTER_WORKER_CONNECT_TIMEOUT_MS" "2000" ensure_runtime_bootstrap_secret_file_env "${api_env_file}" ensure_env_value_migrates_old_default "${api_env_file}" "GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS" "45000" "180000" - 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")" @@ -351,6 +427,186 @@ ensure_worker_runtime_env_defaults() { ensure_runtime_bootstrap_secret_file_env "${worker_env_file}" } +ensure_bgfilter_worker_runtime_env_defaults() { + local bgfilter_env_file="$1" + + if [[ -z "${bgfilter_env_file}" ]]; then + return + fi + if [[ ! -f "${bgfilter_env_file}" ]]; then + echo "[production-api-deploy] BgFilter worker 环境文件不存在: ${bgfilter_env_file}" >&2 + return 1 + fi + + ensure_env_value "${bgfilter_env_file}" "GENARRATIVE_BGFILTER_WORKER_HOST" "127.0.0.1" + ensure_env_value "${bgfilter_env_file}" "GENARRATIVE_BGFILTER_WORKER_PORT" "8083" + ensure_env_value "${bgfilter_env_file}" "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD" "3" + ensure_env_value "${bgfilter_env_file}" "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS" "300" +} + +validate_bgfilter_shared_runtime_env() { + local api_env_file="$1" + local request_timeout_ms connect_timeout_ms + + request_timeout_ms="$(read_env_value "${api_env_file}" "GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS")" + if [[ ! "${request_timeout_ms}" =~ ^[1-9][0-9]*$ ]]; then + echo "[production-api-deploy] GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS 必须在共享 API env 中配置为正整数毫秒: ${api_env_file}" >&2 + return 1 + fi + + connect_timeout_ms="$(read_env_value "${api_env_file}" "GENARRATIVE_BGFILTER_WORKER_CONNECT_TIMEOUT_MS")" + if [[ ! "${connect_timeout_ms}" =~ ^[1-9][0-9]*$ ]]; then + echo "[production-api-deploy] GENARRATIVE_BGFILTER_WORKER_CONNECT_TIMEOUT_MS 必须在共享 API env 中配置为正整数毫秒: ${api_env_file}" >&2 + return 1 + fi +} + +validate_bgfilter_worker_capacity() { + local bgfilter_env_file="$1" + local concurrency max_requests + + concurrency="$(read_env_value "${bgfilter_env_file}" "GENARRATIVE_BGFILTER_WORKER_CONCURRENCY")" + max_requests="$(read_env_value "${bgfilter_env_file}" "GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS")" + if [[ ! "${concurrency}" =~ ^[1-9][0-9]{0,8}$ ]]; then + echo "[production-api-deploy] GENARRATIVE_BGFILTER_WORKER_CONCURRENCY 必须是正整数: ${bgfilter_env_file}" >&2 + return 1 + fi + if [[ ! "${max_requests}" =~ ^[1-9][0-9]{0,8}$ ]]; then + echo "[production-api-deploy] GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS 必须是正整数: ${bgfilter_env_file}" >&2 + return 1 + fi + if (( 10#${max_requests} < 10#${concurrency} )); then + echo "[production-api-deploy] GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS 必须大于或等于 CONCURRENCY: ${bgfilter_env_file}" >&2 + return 1 + fi +} + +validate_bgfilter_worker_shared_env_alignment() { + local api_env_file="$1" + local bgfilter_env_file="$2" + local key shared_value dedicated_value + + for key in \ + GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS \ + GENARRATIVE_EDITOR_BGFILTER_BASE_URL \ + GENARRATIVE_EDITOR_BGFILTER_TOKEN \ + GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE \ + ALIYUN_OSS_BUCKET \ + ALIYUN_OSS_ENDPOINT \ + ALIYUN_OSS_ACCESS_KEY_ID \ + ALIYUN_OSS_ACCESS_KEY_SECRET \ + ALIYUN_OSS_READ_EXPIRE_SECONDS; do + if ! env_has_assignment "${bgfilter_env_file}" "${key}"; then + continue + fi + dedicated_value="$(read_env_value "${bgfilter_env_file}" "${key}")" + shared_value="$(read_env_value "${api_env_file}" "${key}")" + if [[ "${dedicated_value}" != "${shared_value}" ]]; then + echo "[production-api-deploy] BgFilter 专属 env 中的共享配置与 API env 不一致: ${key};请迁移到 ${api_env_file} 并从 ${bgfilter_env_file} 删除重复项。" >&2 + return 1 + fi + done +} + +validate_external_generation_worker_bgfilter_env_alignment() { + local api_env_file="$1" + local worker_env_file="$2" + local key shared_value worker_value + + if [[ -z "${worker_env_file}" || ! -f "${worker_env_file}" ]]; then + return 0 + fi + + for key in \ + GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS \ + GENARRATIVE_BGFILTER_WORKER_BASE_URL \ + GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE \ + GENARRATIVE_BGFILTER_WORKER_CONNECT_TIMEOUT_MS \ + ALIYUN_OSS_BUCKET \ + ALIYUN_OSS_ENDPOINT; do + if ! env_has_assignment "${worker_env_file}" "${key}"; then + continue + fi + worker_value="$(read_env_value "${worker_env_file}" "${key}")" + shared_value="$(read_env_value "${api_env_file}" "${key}")" + if [[ "${worker_value}" != "${shared_value}" ]]; then + echo "[production-api-deploy] 外部生成 worker env 中的 BgFilter 共享配置与 API env 不一致: ${key};${worker_env_file} 会在 systemd 中后加载并覆盖父侧有效值。" >&2 + return 1 + fi + done +} + +validate_bgfilter_loopback_endpoint_alignment() { + local api_env_file="$1" + local bgfilter_env_file="$2" + local health_url="$3" + local base_url host port expected_base_url expected_health_url + + base_url="$(read_env_value "${api_env_file}" "GENARRATIVE_BGFILTER_WORKER_BASE_URL")" + host="$(read_env_value "${bgfilter_env_file}" "GENARRATIVE_BGFILTER_WORKER_HOST")" + port="$(read_env_value "${bgfilter_env_file}" "GENARRATIVE_BGFILTER_WORKER_PORT")" + + if [[ "${host}" != "127.0.0.1" ]]; then + echo "[production-api-deploy] BgFilter worker 首版必须监听 127.0.0.1,当前 GENARRATIVE_BGFILTER_WORKER_HOST=${host:-}: ${bgfilter_env_file}" >&2 + return 1 + fi + if [[ ! "${port}" =~ ^[1-9][0-9]{0,4}$ ]] || (( 10#${port} > 65535 )); then + echo "[production-api-deploy] GENARRATIVE_BGFILTER_WORKER_PORT 必须是 1-65535 的有效端口: ${bgfilter_env_file}" >&2 + return 1 + fi + + expected_base_url="http://${host}:${port}" + if [[ "${base_url%/}" != "${expected_base_url}" ]]; then + echo "[production-api-deploy] 父进程 GENARRATIVE_BGFILTER_WORKER_BASE_URL 必须与 BgFilter worker 有效监听地址一致: expected=${expected_base_url}, actual=${base_url:-}" >&2 + return 1 + fi + + expected_health_url="${expected_base_url}/readyz" + if [[ "${health_url}" != "${expected_health_url}" ]]; then + echo "[production-api-deploy] --bgfilter-worker-health-url 必须与父进程 base URL 和子 worker listener 指向同一 loopback endpoint: expected=${expected_health_url}, actual=${health_url:-}" >&2 + return 1 + fi +} + +validate_no_bgfilter_internal_token_plaintext() { + local env_file + + for env_file in "$@"; do + if [[ -z "${env_file}" ]]; then + continue + fi + if env_contains_nonempty_assignment "${env_file}" "GENARRATIVE_BGFILTER_INTERNAL_TOKEN"; then + echo "[production-api-deploy] ${env_file} 不得保存 GENARRATIVE_BGFILTER_INTERNAL_TOKEN 明文;生产环境只允许使用 GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE。" >&2 + return 1 + fi + done +} + +validate_bgfilter_internal_token_file() { + local api_env_file="$1" + local token_file token_metadata + + token_file="$(read_env_value "${api_env_file}" "GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE")" + if [[ -z "${token_file}" || "${token_file}" != /* ]]; then + echo "[production-api-deploy] GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE 必须指向绝对路径: ${api_env_file}" >&2 + return 1 + fi + if [[ -L "${token_file}" || ! -f "${token_file}" || ! -s "${token_file}" ]]; then + echo "[production-api-deploy] BgFilter 内部 Token 必须是非空普通文件且不能是符号链接: ${token_file}" >&2 + return 1 + fi + if ! run_privileged grep -q '[^[:space:]]' -- "${token_file}"; then + echo "[production-api-deploy] BgFilter 内部 Token 文件必须至少包含一个非空白字符: ${token_file}" >&2 + return 1 + fi + + token_metadata="$(run_privileged stat -c '%U:%G:%a' -- "${token_file}")" + if [[ "${token_metadata}" != "root:genarrative:440" ]]; then + echo "[production-api-deploy] BgFilter 内部 Token 权限必须为 root:genarrative 0440,且必须是普通文件: ${token_file} (${token_metadata})" >&2 + return 1 + fi +} + extract_pingora_env_files_from_unit() { local service_name="$1" local unit_content @@ -572,8 +828,17 @@ install_worker_systemd_units() { local release_dir="$1" local pattern="$2" local controller_service="$3" + local bgfilter_service="$4" local installed_any=0 + if [[ "${bgfilter_service}" == "genarrative-bgfilter-worker.service" ]]; then + install_release_systemd_unit \ + "${release_dir}/deploy/systemd/genarrative-bgfilter-worker.service" \ + "genarrative-bgfilter-worker.service" \ + "BgFilter worker systemd 单元" + installed_any=1 + fi + if [[ "${pattern}" == "genarrative-external-generation-worker@*.service" ]]; then install_release_systemd_unit \ "${release_dir}/deploy/systemd/genarrative-external-generation-worker@.service" \ @@ -688,6 +953,38 @@ wait_for_worker_controller_service() { return 1 } +restart_and_wait_for_bgfilter_worker() { + local service="$1" + local health_url="$2" + + if [[ -z "${service}" ]]; then + echo "[production-api-deploy] 跳过 BgFilter worker 启动。" + return 0 + fi + if ! systemctl cat "${service}" >/dev/null 2>&1; then + echo "[production-api-deploy] 缺少 BgFilter worker systemd 单元: ${service}" >&2 + return 1 + fi + + echo "[production-api-deploy] 停止旧 BgFilter worker 并等待在途请求排空: ${service}" + systemctl stop "${service}" + systemctl enable "${service}" + echo "[production-api-deploy] 启动唯一 BgFilter worker: ${service}" + systemctl start "${service}" + + for _ in {1..30}; do + if systemctl is-active --quiet "${service}" && curl -fsS --max-time 2 "${health_url}" >/dev/null; then + echo "[production-api-deploy] BgFilter worker readiness 通过: ${health_url}" + return 0 + fi + sleep 2 + done + + systemctl --no-pager --full status "${service}" || true + echo "[production-api-deploy] BgFilter worker readiness 检查超时: ${health_url}" >&2 + return 1 +} + SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" SOURCE_DIR="" VERSION="" @@ -697,6 +994,9 @@ SERVICE_NAME="genarrative-api.service" PINGORA_SERVICE_NAME="genarrative-pingora-gateway.service" WORKER_SERVICE_PATTERN="genarrative-external-generation-worker@*.service" WORKER_CONTROLLER_SERVICE="genarrative-external-generation-controller.service" +BGFILTER_WORKER_SERVICE="genarrative-bgfilter-worker.service" +BGFILTER_WORKER_HEALTH_URL="http://127.0.0.1:8083/readyz" +BGFILTER_WORKER_ENV_FILE="/etc/genarrative/bgfilter-worker.env" 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" @@ -766,6 +1066,23 @@ while [[ $# -gt 0 ]]; do WORKER_CONTROLLER_SERVICE="" shift ;; + --bgfilter-worker-service) + BGFILTER_WORKER_SERVICE="${2:?缺少 --bgfilter-worker-service 的值}" + shift 2 + ;; + --bgfilter-worker-health-url) + BGFILTER_WORKER_HEALTH_URL="${2:?缺少 --bgfilter-worker-health-url 的值}" + shift 2 + ;; + --bgfilter-worker-env-file) + BGFILTER_WORKER_ENV_FILE="${2:?缺少 --bgfilter-worker-env-file 的值}" + shift 2 + ;; + --no-bgfilter-worker) + BGFILTER_WORKER_SERVICE="" + BGFILTER_WORKER_ENV_FILE="" + shift + ;; --health-url) HEALTH_URL="${2:?缺少 --health-url 的值}" shift 2 @@ -801,6 +1118,9 @@ 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 "${BGFILTER_WORKER_ENV_FILE}" ]]; then + require_absolute_path "${BGFILTER_WORKER_ENV_FILE}" "--bgfilter-worker-env-file" +fi if [[ -n "${DATABASE}" ]]; then validate_spacetime_database_name "${DATABASE}" @@ -1127,8 +1447,20 @@ if [[ -n "${SPACETIME_SERVER_URL}" ]]; then fi ensure_runtime_env_and_dirs "${API_ENV_FILE}" +validate_bgfilter_shared_runtime_env "${API_ENV_FILE}" validate_real_wechat_pay_refund_reconciliation "${API_ENV_FILE}" ensure_worker_runtime_env_defaults "${WORKER_ENV_FILE}" +ensure_bgfilter_worker_runtime_env_defaults "${BGFILTER_WORKER_ENV_FILE}" +validate_external_generation_worker_bgfilter_env_alignment "${API_ENV_FILE}" "${WORKER_ENV_FILE}" +validate_no_bgfilter_internal_token_plaintext "${API_ENV_FILE}" "${WORKER_ENV_FILE}" "${BGFILTER_WORKER_ENV_FILE}" +if [[ -n "${BGFILTER_WORKER_SERVICE}" ]]; then + validate_bgfilter_internal_token_file "${API_ENV_FILE}" + validate_bgfilter_loopback_endpoint_alignment "${API_ENV_FILE}" "${BGFILTER_WORKER_ENV_FILE}" "${BGFILTER_WORKER_HEALTH_URL}" +fi +if [[ -n "${BGFILTER_WORKER_ENV_FILE}" ]]; then + validate_bgfilter_worker_capacity "${BGFILTER_WORKER_ENV_FILE}" + validate_bgfilter_worker_shared_env_alignment "${API_ENV_FILE}" "${BGFILTER_WORKER_ENV_FILE}" +fi migrate_legacy_editor_generation_pricing_override "${CURRENT_LINK}" if [[ "${PINGORA_INCLUDED}" -eq 1 ]]; then @@ -1149,7 +1481,9 @@ if [[ "${PINGORA_INCLUDED}" -eq 1 ]]; then ensure_pingora_shadow_service "${PINGORA_SERVICE_NAME}" "${PINGORA_SHADOW_ENV_FILE}" fi -install_worker_systemd_units "${RELEASE_DIR}" "${WORKER_SERVICE_PATTERN}" "${WORKER_CONTROLLER_SERVICE}" +install_worker_systemd_units "${RELEASE_DIR}" "${WORKER_SERVICE_PATTERN}" "${WORKER_CONTROLLER_SERVICE}" "${BGFILTER_WORKER_SERVICE}" + +restart_and_wait_for_bgfilter_worker "${BGFILTER_WORKER_SERVICE}" "${BGFILTER_WORKER_HEALTH_URL}" echo "[production-api-deploy] 重启服务: ${SERVICE_NAME}" systemctl restart "${SERVICE_NAME}" diff --git a/scripts/dev-stack-port-utils.mjs b/scripts/dev-stack-port-utils.mjs index d4f5cdd0f..37a3f013d 100644 --- a/scripts/dev-stack-port-utils.mjs +++ b/scripts/dev-stack-port-utils.mjs @@ -55,8 +55,8 @@ export function parsePortRangeSpec(value) { throw new Error(`端口段无效: ${spec},端口必须在 1024-65535 且起始不大于结束`); } - if (end - start + 1 < 4) { - throw new Error(`端口段至少需要 4 个端口: ${spec}`); + if (end - start + 1 < 5) { + throw new Error(`端口段至少需要 5 个端口: ${spec}`); } return {start, end, label: `${start}-${end}`}; @@ -118,6 +118,7 @@ export function mapDevPortsToPortRange(portRange) { apiPort: normalizedRange.start + 1, spacetimePort: normalizedRange.start + 2, adminWebPort: normalizedRange.start + 3, + bgfilterWorkerPort: normalizedRange.start + 4, range: normalizedRange, }; } @@ -569,6 +570,7 @@ export async function resolveDevStackPorts(config) { ['api', config.api], ['web', config.web], ['adminWeb', config.adminWeb], + ['bgfilterWorker', config.bgfilterWorker], ].filter(([, portConfig]) => Boolean(portConfig)); const result = {}; diff --git a/scripts/dev-stack-port-utils.test.ts b/scripts/dev-stack-port-utils.test.ts index 8f56402f7..fdaa19642 100644 --- a/scripts/dev-stack-port-utils.test.ts +++ b/scripts/dev-stack-port-utils.test.ts @@ -47,7 +47,7 @@ async function reserveConsecutivePorts() { } describe('dev stack port utils', () => { - it('解析端口段并映射到四个 dev 端口', () => { + it('解析端口段并映射到五个 dev 端口', () => { expect(parsePortRangeSpec('10000-10099')).toEqual({ start: 10000, end: 10099, @@ -58,7 +58,11 @@ describe('dev stack port utils', () => { apiPort: 10001, spacetimePort: 10002, adminWebPort: 10003, + bgfilterWorkerPort: 10004, }); + expect(() => parsePortRangeSpec('10000-10003')).toThrow( + '端口段至少需要 5 个端口', + ); }); it('使用端口可用性检查为被占用端口寻找后续可用端口', async () => { @@ -112,9 +116,10 @@ describe('dev stack port utils', () => { api: {host: '127.0.0.1', preferredPort: 0}, web: {host: '127.0.0.1', preferredPort: 0}, adminWeb: {host: '127.0.0.1', preferredPort: 0}, + bgfilterWorker: {host: '127.0.0.1', preferredPort: 0}, }); - expect(new Set(Object.values(resolvedPorts)).size).toBe(4); + expect(new Set(Object.values(resolvedPorts)).size).toBe(5); }); it('端口段内会一直漂移到段尾,不会被默认 200 次尝试截断', async () => { diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 5bdf33f72..c013d39ab 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -31,6 +31,7 @@ import { } from './dev-stack-port-utils.mjs'; import { ensureParentDir, + formatApiServerLogTimestamp, mergeApiServerEnv, resolveApiServerLogFile, resolveClientHost, @@ -138,9 +139,17 @@ function resolveLocalDevRustcWrapperBypass() { return process.platform === 'win32' ? '' : '/usr/bin/env'; } -const SERVICE_NAMES = ['spacetime', 'api-server', 'web', 'admin-web']; +const SERVICE_NAMES = [ + 'spacetime', + 'api-server', + 'bgfilter-worker', + 'web', + 'admin-web', +]; const SERVICE_ALIASES = new Map([ ['api', 'api-server'], + ['bgfilter', 'bgfilter-worker'], + ['bgfilterWorker', 'bgfilter-worker'], ['admin', 'admin-web'], ['adminWeb', 'admin-web'], ['all', 'all'], @@ -151,12 +160,15 @@ function usage() { npm run dev [-- --watch] [-- --api-port 8090] npm run dev:spacetime [-- --skip-publish] npm run dev:api-server [-- --database genarrative-dev] + npm run dev:bgfilter-worker [-- --database genarrative-dev] npm run dev:web [-- --api-port 8082] npm run dev:admin-web [-- --api-port 8082] 常用参数: --api-host api-server 监听地址 --api-port api-server 端口 + --bgfilter-worker-host BgFilter worker 内部监听地址 + --bgfilter-worker-port BgFilter worker 内部端口 --web-host 主站 Vite 监听地址 --web-port 主站 Vite 端口 --admin-web-host 后台 Vite 监听地址 @@ -172,6 +184,7 @@ function usage() { 交互命令: rs spacetime 重新发布 spacetime-module,不重启 standalone rs api-server 重启 api-server + rs bgfilter-worker 重启 BgFilter worker rs web 重启主站 Vite rs admin-web 重启后台 Vite rs all 重新发布 spacetime-module,并重启其余模块 @@ -208,6 +221,11 @@ function parseArgs(argv, baseEnv) { const options = { apiHost: env.GENARRATIVE_API_HOST || '127.0.0.1', apiPort: normalizePort(env.GENARRATIVE_API_PORT, 8082), + bgfilterWorkerHost: env.GENARRATIVE_BGFILTER_WORKER_HOST || '127.0.0.1', + bgfilterWorkerPort: normalizePort( + env.GENARRATIVE_BGFILTER_WORKER_PORT, + 8083, + ), webHost: env.WEB_HOST || '0.0.0.0', webPort: normalizePort(env.WEB_PORT, 3000), adminWebHost: env.ADMIN_WEB_HOST || '127.0.0.1', @@ -261,6 +279,17 @@ function parseArgs(argv, baseEnv) { options.apiPort = normalizePort(readValue(), options.apiPort); explicitOptions.add('apiPort'); break; + case '--bgfilter-worker-host': + options.bgfilterWorkerHost = readValue(); + explicitOptions.add('bgfilterWorkerHost'); + break; + case '--bgfilter-worker-port': + options.bgfilterWorkerPort = normalizePort( + readValue(), + options.bgfilterWorkerPort, + ); + explicitOptions.add('bgfilterWorkerPort'); + break; case '--web-host': options.webHost = readValue(); explicitOptions.add('webHost'); @@ -438,6 +467,12 @@ function resolveDevStackServiceEndpoint(runner, serviceName) { port: options.apiPort, url: state.apiTarget, }; + case 'bgfilter-worker': + return { + host: options.bgfilterWorkerHost, + port: options.bgfilterWorkerPort, + url: state.bgfilterWorkerTarget, + }; case 'web': return { host: options.webHost, @@ -474,6 +509,7 @@ function resolveDevStackServiceCommand(runner, serviceName) { `${options.spacetimeHost}:${options.spacetimePort}`, '--non-interactive', ].join(' '); + case 'bgfilter-worker': case 'api-server': return 'cargo run -p api-server --manifest-path server-rs/Cargo.toml'; case 'web': @@ -649,6 +685,7 @@ function ensureRequiredFiles(command) { if ( command === 'api-server' || + command === 'bgfilter-worker' || command === 'spacetime' || command === 'all' ) { @@ -921,7 +958,7 @@ function readLinuxApiServerProcessSnapshot(pid) { if ( error?.code === 'ENOENT' || error?.code === 'EACCES' || - error?.code === 'EPERM' || + error?.code === 'EPERM' || error?.code === 'ESRCH' ) { return null; @@ -1094,6 +1131,10 @@ class DevRunner { this.baseEnv.GENARRATIVE_SPACETIME_TOKEN ?? '', ).trim(); delete this.baseEnv.GENARRATIVE_SPACETIME_TOKEN; + this.bgfilterInternalToken = + String(this.baseEnv.GENARRATIVE_BGFILTER_INTERNAL_TOKEN ?? '').trim() || + randomBytes(32).toString('hex'); + delete this.baseEnv.GENARRATIVE_BGFILTER_INTERNAL_TOKEN; this.runtimeServiceBootstrapSecret = String( this.baseEnv.GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET ?? '', ).trim(); @@ -1106,22 +1147,29 @@ class DevRunner { `http://${options.spacetimeHost}:${options.spacetimePort}`; this.state = { apiTargetHost: resolveClientHost(options.apiHost), + bgfilterWorkerTargetHost: resolveClientHost(options.bgfilterWorkerHost), adminWebTargetHost: resolveClientHost(options.adminWebHost), spacetimeServer: initialSpacetimeServer, apiTarget: `http://${resolveClientHost(options.apiHost)}:${options.apiPort}`, + bgfilterWorkerTarget: `http://${resolveClientHost(options.bgfilterWorkerHost)}:${options.bgfilterWorkerPort}`, portRange: null, portRangeReservation: null, }; this.services = new Map(); this.watchers = []; this.shuttingDown = false; + this.windowsApiServerCleanupCompleted = false; } async init(command) { this.command = command; ensureRequiredFiles(command); requireCommand('node'); - if (command === 'api-server' || command === 'all') { + if ( + command === 'api-server' || + command === 'bgfilter-worker' || + command === 'all' + ) { requireCommand('cargo'); } if ( @@ -1182,9 +1230,13 @@ class DevRunner { if (!this.explicitOptions.has('adminWebPort')) { this.options.adminWebPort = mappedPorts.adminWebPort; } + if (!this.explicitOptions.has('bgfilterWorkerPort')) { + this.options.bgfilterWorkerPort = mappedPorts.bgfilterWorkerPort; + } this.state.spacetimeServer = `http://${this.options.spacetimeHost}:${this.options.spacetimePort}`; this.state.apiTarget = `http://${this.state.apiTargetHost}:${this.options.apiPort}`; + this.state.bgfilterWorkerTarget = `http://${this.state.bgfilterWorkerTargetHost}:${this.options.bgfilterWorkerPort}`; } shouldValidateSpacetimeToolVersion(command) { @@ -1194,7 +1246,7 @@ class DevRunner { if (command === 'all') { return !this.options.skipSpacetime || !this.options.skipPublish; } - if (command === 'api-server') { + if (command === 'api-server' || command === 'bgfilter-worker') { return isLoopbackSpacetimeServer(this.state.spacetimeServer); } return false; @@ -1337,6 +1389,18 @@ class DevRunner { }; } + if ( + command === 'all' || + command === 'api-server' || + command === 'bgfilter-worker' + ) { + portConfig.bgfilterWorker = { + host: options.bgfilterWorkerHost, + preferredPort: options.bgfilterWorkerPort, + portRange: portRangeFor('bgfilterWorkerPort'), + }; + } + if (Object.keys(portConfig).length === 0) { return; } @@ -1367,13 +1431,20 @@ class DevRunner { if (resolvedPorts.adminWeb) { options.adminWebPort = resolvedPorts.adminWeb; } + if (resolvedPorts.bgfilterWorker) { + options.bgfilterWorkerPort = resolvedPorts.bgfilterWorker; + } this.state.apiTargetHost = resolveClientHost(options.apiHost); this.state.adminWebTargetHost = resolveClientHost(options.adminWebHost); + this.state.bgfilterWorkerTargetHost = resolveClientHost( + options.bgfilterWorkerHost, + ); if (command === 'all' || command === 'spacetime') { this.state.spacetimeServer = `http://${options.spacetimeHost}:${options.spacetimePort}`; } this.state.apiTarget = `http://${this.state.apiTargetHost}:${options.apiPort}`; + this.state.bgfilterWorkerTarget = `http://${this.state.bgfilterWorkerTargetHost}:${options.bgfilterWorkerPort}`; } registerServices() { @@ -1394,6 +1465,14 @@ class DevRunner { onStateChange, ), ); + this.services.set( + 'bgfilter-worker', + new DevService( + 'bgfilter-worker', + async (service) => this.startBgfilterWorker(service), + onStateChange, + ), + ); this.services.set( 'web', new DevService( @@ -1444,6 +1523,7 @@ class DevRunner { `[dev] admin web: http://${state.adminWebTargetHost}:${options.adminWebPort}/admin/`, ); console.log(`[dev] api-server: ${state.apiTarget}`); + console.log(`[dev] bgfilter-worker: ${state.bgfilterWorkerTarget}`); console.log(`[dev] spacetime: ${state.spacetimeServer}`); console.log(`[dev] database: ${options.database}`); } @@ -1465,12 +1545,23 @@ class DevRunner { async startCommand(command) { if (command === 'all') { await this.startSpacetimeForFullStack(); - await this.services.get('api-server').start(); - await this.waitForApiServer(); + await this.startRustServicePair(); await this.services.get('web').start(); await this.services.get('admin-web').start(); this.startInteractiveInput(); - this.startWatchers(['spacetime', 'api-server', 'web', 'admin-web']); + this.startWatchers([ + 'spacetime', + 'api-server', + 'bgfilter-worker', + 'web', + 'admin-web', + ]); + return; + } + + if (command === 'api-server') { + await this.startRustServicePair(); + this.startWatchers(['api-server', 'bgfilter-worker']); return; } @@ -1483,6 +1574,24 @@ class DevRunner { this.startWatchers([command]); } + async startRustServicePair() { + if (!this.windowsApiServerCleanupCompleted) { + stopExistingWindowsApiServer(); + this.windowsApiServerCleanupCompleted = true; + } + + await this.services.get('bgfilter-worker').start(); + await this.waitForBgfilterWorker(); + await this.services.get('api-server').start(); + await this.waitForApiServer(); + } + + async restartRustServicePair() { + await this.services.get('api-server').stop(); + await this.services.get('bgfilter-worker').stop(); + await this.startRustServicePair(); + } + async startSpacetimeForFullStack() { if (!this.options.skipSpacetime && !this.state.spacetimeReused) { await this.services.get('spacetime').start(); @@ -1794,6 +1903,8 @@ class DevRunner { }, options: this.options, state: this.state, + bgfilterInternalToken: this.bgfilterInternalToken, + processRole: this.command === 'all' ? 'all' : undefined, }); if (this.runtimeServiceBootstrapSecret) { mergedEnv.GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET = @@ -1809,7 +1920,6 @@ class DevRunner { service.logStream = logStream; mergedEnv.GENARRATIVE_API_SERVER_LOG_FILE = logFile; - stopExistingWindowsApiServer(logStream); await stopStaleLocalExternalGenerationWorkers({ database: this.options.database, logStream, @@ -1859,6 +1969,82 @@ class DevRunner { service.registerChild(child); } + async startBgfilterWorker(service) { + await this.ensureApiServerSpacetimeToken(); + if ( + !this.runtimeServiceBootstrapSecret && + this.options.migrationBootstrapSecretMode !== 'disabled' + ) { + this.runtimeServiceBootstrapSecret = + this.resolveRuntimeServiceBootstrapSecret(); + } + + const mergedEnv = buildBgfilterWorkerProcessEnv({ + baseEnv: { + ...buildLocalRustProcessEnv(this.baseEnv), + GENARRATIVE_SPACETIME_TOKEN: this.spacetimeApiToken, + }, + options: this.options, + state: this.state, + bgfilterInternalToken: this.bgfilterInternalToken, + }); + if (this.runtimeServiceBootstrapSecret) { + mergedEnv.GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET = + this.runtimeServiceBootstrapSecret; + } + + const logFile = resolveBgfilterWorkerLogFile(repoRoot, mergedEnv); + ensureParentDir(logFile); + const logStream = createWriteStream(logFile, { + flags: 'a', + encoding: 'utf8', + }); + service.logStream = logStream; + mergedEnv.GENARRATIVE_API_SERVER_LOG_FILE = logFile; + + console.log(`[dev:bgfilter-worker] log: ${logFile}`); + console.log( + `[dev:bgfilter-worker] SpacetimeDB ${this.options.database} @ ${this.state.spacetimeServer}`, + ); + service.updateRuntimeState({ + status: 'starting', + pid: null, + host: this.options.bgfilterWorkerHost, + port: this.options.bgfilterWorkerPort, + url: this.state.bgfilterWorkerTarget, + command: resolveDevStackServiceCommand(this, 'bgfilter-worker'), + startedAt: new Date().toISOString(), + exitCode: null, + signal: null, + }); + + const child = spawn( + 'cargo', + ['run', '-p', 'api-server', '--manifest-path', 'server-rs/Cargo.toml'], + { + cwd: repoRoot, + env: mergedEnv, + stdio: ['ignore', 'pipe', 'pipe'], + shell: process.platform === 'win32', + }, + ); + + child.stdout?.on('data', (chunk) => { + process.stdout.write(chunk); + logStream.write(chunk); + }); + child.stderr?.on('data', (chunk) => { + process.stderr.write(chunk); + logStream.write(chunk); + }); + child.on('error', (error) => { + console.error(`[dev:bgfilter-worker] 启动 cargo 失败: ${error.message}`); + service.updateRuntimeState({ status: 'failed', pid: null }); + }); + + service.registerChild(child); + } + async waitForApiServer() { const healthUrl = `${this.state.apiTarget}/healthz`; const deadline = Date.now() + this.options.apiTimeoutSeconds * 1000; @@ -1872,6 +2058,26 @@ class DevRunner { throw new Error(`等待 api-server 就绪超时: ${healthUrl}`); } + async waitForBgfilterWorker() { + const readinessUrl = `${this.state.bgfilterWorkerTarget}/readyz`; + const deadline = Date.now() + this.options.apiTimeoutSeconds * 1000; + while (Date.now() < deadline) { + if (await isHttpReady(readinessUrl, 500)) { + return; + } + const runtimeStatus = this.services.get('bgfilter-worker')?.runtime + ?.status; + if (runtimeStatus === 'failed' || runtimeStatus === 'stopped') { + throw new Error( + `bgfilter-worker 在 readiness 前退出,请检查 logs/bgfilter-worker/: ${readinessUrl}`, + ); + } + await sleep(500); + } + + throw new Error(`等待 bgfilter-worker 就绪超时: ${readinessUrl}`); + } + startWeb(service) { const apiTarget = this.resolveFrontendApiTarget(); const endpoint = resolveDevStackServiceEndpoint(this, 'web'); @@ -1992,8 +2198,40 @@ class DevRunner { } const watchConfigs = createWatchConfigs(); + const pendingServiceNames = [...serviceNames]; + const managesRustPair = + this.command === 'all' || this.command === 'api-server'; + if ( + managesRustPair && + pendingServiceNames.some((serviceName) => + ['api-server', 'bgfilter-worker'].includes(serviceName), + ) + ) { + const service = this.services.get('api-server'); + for (const config of watchConfigs['api-server'] ?? []) { + if (!existsSync(config.path)) { + continue; + } - for (const serviceName of serviceNames) { + this.watchers.push( + createServiceWatcher({ + config, + service, + serviceName: 'rust-services', + restartFn: async () => this.restartRustServicePair(), + actionLabel: '组合重启', + }), + ); + } + } + + for (const serviceName of pendingServiceNames) { + if ( + managesRustPair && + ['api-server', 'bgfilter-worker'].includes(serviceName) + ) { + continue; + } const service = this.services.get(serviceName); for (const config of watchConfigs[serviceName] ?? []) { if (!existsSync(config.path)) { @@ -2037,7 +2275,7 @@ class DevRunner { try { if (raw === 'help') { console.log( - '可用命令: rs spacetime(重新发布) | rs api-server | rs web | rs admin-web | rs all | quit', + '可用命令: rs spacetime(重新发布) | rs api-server | rs bgfilter-worker | rs web | rs admin-web | rs all | quit', ); return; } @@ -2055,9 +2293,10 @@ class DevRunner { const target = normalizeServiceName(match[1]); if (target === 'all') { - for (const serviceName of SERVICE_NAMES) { - await this.restartService(serviceName); - } + await this.refreshSpacetimeModule(); + await this.restartRustServicePair(); + await this.services.get('web').restart(); + await this.services.get('admin-web').restart(); return; } @@ -2074,10 +2313,21 @@ class DevRunner { return; } + if ( + (this.command === 'all' || this.command === 'api-server') && + ['api-server', 'bgfilter-worker'].includes(serviceName) + ) { + await this.restartRustServicePair(); + return; + } + await this.services.get(serviceName).restart(); if (serviceName === 'api-server') { await this.waitForApiServer(); } + if (serviceName === 'bgfilter-worker') { + await this.waitForBgfilterWorker(); + } } async shutdown(code = 0) { @@ -2146,7 +2396,7 @@ function stopExistingWindowsApiServer(logStream) { if (output) { const line = `[dev:api-server] 已停止旧 api-server 进程: ${output}\n`; process.stdout.write(line); - logStream.write(line); + logStream?.write(line); } } @@ -2316,6 +2566,12 @@ function hasSkippedPathSegment(filePath) { } function createWatchConfigs() { + const rustApiConfig = { + path: serverRsDir, + filter: (path) => + isCodeFile(path) && + !normalizePath(path).includes('/crates/spacetime-module/'), + }; return { spacetime: [ { @@ -2323,14 +2579,8 @@ function createWatchConfigs() { filter: isCodeFile, }, ], - 'api-server': [ - { - path: serverRsDir, - filter: (path) => - isCodeFile(path) && - !normalizePath(path).includes('/crates/spacetime-module/'), - }, - ], + 'api-server': [rustApiConfig], + 'bgfilter-worker': [rustApiConfig], web: [], 'admin-web': [], }; @@ -2931,6 +3181,8 @@ function buildApiServerProcessEnv({ baseEnv, options, state, + bgfilterInternalToken = baseEnv.GENARRATIVE_BGFILTER_INTERNAL_TOKEN || '', + processRole = baseEnv.GENARRATIVE_PROCESS_ROLE || 'all', platform = process.platform, }) { return applyLocalFfmpegEnv( @@ -2938,10 +3190,44 @@ function buildApiServerProcessEnv({ ...baseEnv, // 本地 dev 允许密码入口直接创建账号,生产默认仍由 api-server 配置保持关闭。 GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED: 'true', - GENARRATIVE_PROCESS_ROLE: baseEnv.GENARRATIVE_PROCESS_ROLE || 'all', + GENARRATIVE_PROCESS_ROLE: processRole, GENARRATIVE_API_HOST: options.apiHost, GENARRATIVE_API_PORT: String(options.apiPort), GENARRATIVE_API_LOG: options.apiLog, + GENARRATIVE_BGFILTER_WORKER_BASE_URL: state.bgfilterWorkerTarget, + GENARRATIVE_BGFILTER_INTERNAL_TOKEN: bgfilterInternalToken, + GENARRATIVE_SPACETIME_SERVER_URL: state.spacetimeServer, + GENARRATIVE_SPACETIME_DATABASE: options.database, + GENARRATIVE_SPACETIME_TOKEN: baseEnv.GENARRATIVE_SPACETIME_TOKEN || '', + }, + platform, + ); +} + +function buildBgfilterWorkerProcessEnv({ + baseEnv, + options, + state, + bgfilterInternalToken, + platform = process.platform, +}) { + return applyLocalFfmpegEnv( + { + ...baseEnv, + GENARRATIVE_PROCESS_ROLE: 'bgfilter-worker', + GENARRATIVE_BGFILTER_WORKER_HOST: options.bgfilterWorkerHost, + GENARRATIVE_BGFILTER_WORKER_PORT: String(options.bgfilterWorkerPort), + GENARRATIVE_BGFILTER_WORKER_BASE_URL: state.bgfilterWorkerTarget, + GENARRATIVE_BGFILTER_INTERNAL_TOKEN: bgfilterInternalToken, + GENARRATIVE_BGFILTER_WORKER_CONCURRENCY: + String( + baseEnv.GENARRATIVE_BGFILTER_WORKER_CONCURRENCY ?? '', + ).trim() || '4', + GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS: + String( + baseEnv.GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS ?? '', + ).trim() || '128', + GENARRATIVE_API_LOG: options.apiLog, GENARRATIVE_SPACETIME_SERVER_URL: state.spacetimeServer, GENARRATIVE_SPACETIME_DATABASE: options.database, GENARRATIVE_SPACETIME_TOKEN: baseEnv.GENARRATIVE_SPACETIME_TOKEN || '', @@ -2950,12 +3236,36 @@ function buildApiServerProcessEnv({ ); } +function resolveBgfilterWorkerLogFile( + repoRootPath, + env = process.env, + now = new Date(), +) { + const explicitLogFile = String( + env.GENARRATIVE_BGFILTER_WORKER_LOG_FILE ?? '', + ).trim(); + if (explicitLogFile) { + return resolve(repoRootPath, explicitLogFile); + } + + const logDir = + String(env.GENARRATIVE_BGFILTER_WORKER_LOG_DIR ?? '').trim() || + 'logs/bgfilter-worker'; + return resolve( + repoRootPath, + logDir, + `bgfilter-worker-${formatApiServerLogTimestamp(now)}.log`, + ); +} + function buildFrontendProcessEnv(baseEnv, overrides = {}) { const env = { ...baseEnv, ...overrides }; delete env.GENARRATIVE_SPACETIME_TOKEN; delete env.GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET; delete env.GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256; delete env.GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET; + delete env.GENARRATIVE_BGFILTER_INTERNAL_TOKEN; + delete env.GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE; return env; } @@ -2964,6 +3274,7 @@ export { assertReusableSpacetimeProcessVersionMatchesWorkspace, assertSpacetimeToolVersionMatchesWorkspace, buildApiServerProcessEnv, + buildBgfilterWorkerProcessEnv, buildDevStackSnapshot, buildFrontendProcessEnv, buildLocalRustProcessEnv, @@ -2980,6 +3291,7 @@ export { parseSpacetimeToolVersion, resolveCurrentSpacetimeCliToken, resolveDevStackStatePath, + resolveBgfilterWorkerLogFile, resolveLocalSpacetimeApiIdentityPath, resolveLocalSpacetimeRuntimeServiceBootstrapSecretPath, shouldAcceptWatchEvent, diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts index b6ebbac90..9be3e71be 100644 --- a/scripts/dev.test.ts +++ b/scripts/dev.test.ts @@ -19,6 +19,7 @@ import { assertReusableSpacetimeProcessVersionMatchesWorkspace, assertSpacetimeToolVersionMatchesWorkspace, buildApiServerProcessEnv, + buildBgfilterWorkerProcessEnv, buildDevStackSnapshot, buildFrontendProcessEnv, buildLocalRustProcessEnv, @@ -85,6 +86,26 @@ describe('dev scheduler argument routing', () => { expect(runner.resolveFrontendApiTarget()).toBe('http://127.0.0.1:8090'); }); + test('独立 BgFilter worker 命令解析内部监听地址', () => { + const { command, explicitOptions, options } = parseArgs( + [ + 'bgfilter-worker', + '--bgfilter-worker-host', + '127.0.0.2', + '--bgfilter-worker-port', + '18083', + ], + {}, + ); + + expect(command).toBe('bgfilter-worker'); + expect(explicitOptions).toEqual( + new Set(['bgfilterWorkerHost', 'bgfilterWorkerPort']), + ); + expect(options.bgfilterWorkerHost).toBe('127.0.0.2'); + expect(options.bgfilterWorkerPort).toBe(18083); + }); + test('单独 dev:web 未显式指定 api 参数时沿用已有 Rust target', () => { const testEnv = { RUST_SERVER_TARGET: 'http://127.0.0.1:3100', @@ -129,7 +150,7 @@ describe('dev scheduler argument routing', () => { ); }); - linuxTest('Linux 启动时按系统级端口段映射四个 dev 端口', async () => { + linuxTest('Linux 启动时按系统级端口段映射五个 dev 端口', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-dev-port-range-')); try { const { command, explicitOptions, options } = parseArgs([], { @@ -156,7 +177,9 @@ describe('dev scheduler argument routing', () => { expect(runner.options.apiPort).toBe(22001); expect(runner.options.spacetimePort).toBe(22002); expect(runner.options.adminWebPort).toBe(22003); + expect(runner.options.bgfilterWorkerPort).toBe(22004); expect(runner.state.apiTarget).toBe('http://127.0.0.1:22001'); + expect(runner.state.bgfilterWorkerTarget).toBe('http://127.0.0.1:22004'); expect(runner.state.spacetimeServer).toBe('http://127.0.0.1:22002'); } finally { rmSync(tempDir, { recursive: true, force: true }); @@ -196,6 +219,7 @@ describe('dev scheduler argument routing', () => { expect(runner.options.apiPort).toBe(22001); expect(runner.options.spacetimePort).toBe(22002); expect(runner.options.adminWebPort).toBe(22003); + expect(runner.options.bgfilterWorkerPort).toBe(22004); } finally { rmSync(tempDir, { recursive: true, force: true }); } @@ -233,6 +257,7 @@ describe('dev scheduler argument routing', () => { expect(runner.options.apiPort).toBe(8082); expect(runner.options.spacetimePort).toBe(3101); expect(runner.options.adminWebPort).toBe(3102); + expect(runner.options.bgfilterWorkerPort).toBe(8083); } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform); @@ -282,6 +307,45 @@ describe('dev scheduler api-server env', () => { expect(env.GENARRATIVE_PROCESS_ROLE).toBe('api'); }); + test('父 API 与独立 BgFilter worker 共享实际 URL 和内部 token', () => { + const { options } = parseArgs([], {}); + options.bgfilterWorkerPort = 18083; + const state = { + spacetimeServer: 'http://127.0.0.1:3199', + bgfilterWorkerTarget: 'http://127.0.0.1:18083', + }; + const internalToken = 'local-bgfilter-token'; + + const apiEnv = buildApiServerProcessEnv({ + baseEnv: {}, + options, + state, + bgfilterInternalToken: internalToken, + processRole: 'all', + }); + const workerEnv = buildBgfilterWorkerProcessEnv({ + baseEnv: {}, + options, + state, + bgfilterInternalToken: internalToken, + }); + + expect(apiEnv.GENARRATIVE_PROCESS_ROLE).toBe('all'); + expect(workerEnv.GENARRATIVE_PROCESS_ROLE).toBe('bgfilter-worker'); + expect(apiEnv.GENARRATIVE_BGFILTER_WORKER_BASE_URL).toBe( + state.bgfilterWorkerTarget, + ); + expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_BASE_URL).toBe( + state.bgfilterWorkerTarget, + ); + expect(apiEnv.GENARRATIVE_BGFILTER_INTERNAL_TOKEN).toBe(internalToken); + expect(workerEnv.GENARRATIVE_BGFILTER_INTERNAL_TOKEN).toBe(internalToken); + expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_HOST).toBe('127.0.0.1'); + expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_PORT).toBe('18083'); + expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_CONCURRENCY).toBe('4'); + expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS).toBe('128'); + }); + test('Windows 本地 dev 自动注入已安装的 FFmpeg 路径', () => { const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-ffmpeg-')); try { @@ -339,6 +403,100 @@ describe('dev scheduler api-server env', () => { }); }); +describe('dev scheduler Rust service orchestration', () => { + test('Rust 双进程重启时先全部停止,再先 ready BgFilter、后 ready API', async () => { + const { explicitOptions, options } = parseArgs([], {}); + const runner = new DevRunner(options, {}, explicitOptions); + const events: string[] = []; + runner.command = 'all'; + runner.windowsApiServerCleanupCompleted = true; + runner.services = new Map([ + [ + 'api-server', + { + stop: async () => events.push('stop-api'), + start: async () => events.push('start-api'), + }, + ], + [ + 'bgfilter-worker', + { + stop: async () => events.push('stop-bgfilter'), + start: async () => events.push('start-bgfilter'), + }, + ], + ]); + vi.spyOn(runner, 'waitForBgfilterWorker').mockImplementation(async () => { + events.push('ready-bgfilter'); + }); + vi.spyOn(runner, 'waitForApiServer').mockImplementation(async () => { + events.push('ready-api'); + }); + + await runner.restartRustServicePair(); + + expect(events).toEqual([ + 'stop-api', + 'stop-bgfilter', + 'start-bgfilter', + 'ready-bgfilter', + 'start-api', + 'ready-api', + ]); + }); + + test('dev:api-server 安全自动带起同 runner 的 BgFilter worker', async () => { + const { explicitOptions, options } = parseArgs(['api-server'], {}); + const runner = new DevRunner(options, {}, explicitOptions); + const startPair = vi + .spyOn(runner, 'startRustServicePair') + .mockResolvedValue(undefined); + const startWatchers = vi + .spyOn(runner, 'startWatchers') + .mockImplementation(() => {}); + + await runner.startCommand('api-server'); + + expect(startPair).toHaveBeenCalledOnce(); + expect(startWatchers).toHaveBeenCalledWith([ + 'api-server', + 'bgfilter-worker', + ]); + }); + + test('完整栈只为两个 Rust 角色创建一套组合 watcher', () => { + const { explicitOptions, options } = parseArgs(['--watch'], {}); + const runner = new DevRunner(options, {}, explicitOptions); + runner.command = 'all'; + runner.registerServices(); + + try { + runner.startWatchers(['api-server', 'bgfilter-worker']); + expect(runner.watchers).toHaveLength(1); + } finally { + for (const watcher of runner.watchers) { + watcher.close(); + } + runner.watchers = []; + } + }); + + test('BgFilter worker 在 readiness 前退出时立即失败', async () => { + const { explicitOptions, options } = parseArgs([], {}); + const runner = new DevRunner(options, {}, explicitOptions); + runner.services = new Map([ + ['bgfilter-worker', { runtime: { status: 'failed' } }], + ]); + globalThis.fetch = vi.fn(async () => ({ + status: 503, + })) as unknown as typeof fetch; + + await expect(runner.waitForBgfilterWorker()).rejects.toThrow( + 'bgfilter-worker 在 readiness 前退出', + ); + }); +}); + describe('dev scheduler local worker cleanup', () => { const expected = { expectedDatabase: 'xushi-p4wfr', @@ -471,6 +629,8 @@ describe('dev scheduler stack state file', () => { options: { apiHost: '127.0.0.1', apiPort: 8090, + bgfilterWorkerHost: '127.0.0.1', + bgfilterWorkerPort: 8091, webHost: '0.0.0.0', webPort: 3010, adminWebHost: '127.0.0.1', @@ -483,6 +643,7 @@ describe('dev scheduler stack state file', () => { }, state: { apiTarget: 'http://127.0.0.1:8090', + bgfilterWorkerTarget: 'http://127.0.0.1:8091', adminWebTargetHost: '127.0.0.1', spacetimeServer: 'http://127.0.0.1:3120', }, @@ -527,6 +688,12 @@ describe('dev scheduler stack state file', () => { port: 8090, url: 'http://127.0.0.1:8090', }); + expect(snapshot.services['bgfilter-worker']).toMatchObject({ + status: 'idle', + pid: null, + port: 8091, + url: 'http://127.0.0.1:8091', + }); }); }); @@ -1258,6 +1425,8 @@ spacetimedb tool version 2.6.0; spacetimedb-lib version 2.6.0; 'migration-secret-hash', GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET: 'runtime-secret', + GENARRATIVE_BGFILTER_INTERNAL_TOKEN: 'bgfilter-token', + GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE: 'bgfilter-token-file', SAFE_VALUE: 'kept', }, { RUST_SERVER_TARGET: 'http://127.0.0.1:8082' }, @@ -1273,6 +1442,8 @@ spacetimedb tool version 2.6.0; spacetimedb-lib version 2.6.0; expect(env).not.toHaveProperty( 'GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET', ); + expect(env).not.toHaveProperty('GENARRATIVE_BGFILTER_INTERNAL_TOKEN'); + expect(env).not.toHaveProperty('GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE'); expect(env.SAFE_VALUE).toBe('kept'); }); diff --git a/scripts/jenkins-server-provision.sh b/scripts/jenkins-server-provision.sh index 89d1b6cdc..40bfbb0fa 100755 --- a/scripts/jenkins-server-provision.sh +++ b/scripts/jenkins-server-provision.sh @@ -6,6 +6,7 @@ SPACETIME_BIN_SOURCE="${SPACETIME_BIN_SOURCE:-${PROVISION_TOOLS_DIR}/spacetime/s OTELCOL_BIN_SOURCE="${OTELCOL_BIN_SOURCE:-${PROVISION_TOOLS_DIR}/otelcol-contrib}" WORKER_ENV_FILE="${WORKER_ENV_FILE:-/etc/genarrative/external-generation-worker.env}" CONTROLLER_ENV_FILE="${CONTROLLER_ENV_FILE:-/etc/genarrative/external-generation-controller.env}" +BGFILTER_WORKER_ENV_FILE="${BGFILTER_WORKER_ENV_FILE:-/etc/genarrative/bgfilter-worker.env}" GENARRATIVE_OPENSSL_VERSION="${GENARRATIVE_OPENSSL_VERSION:-3.2.0}" GENARRATIVE_OPENSSL_PREFIX="${GENARRATIVE_OPENSSL_PREFIX:-/opt/genarrative/openssl-3.2.0}" GENARRATIVE_OPENSSL_SOURCE_URL="${GENARRATIVE_OPENSSL_SOURCE_URL:-https://github.com/openssl/openssl/releases/download/openssl-${GENARRATIVE_OPENSSL_VERSION}/openssl-${GENARRATIVE_OPENSSL_VERSION}.tar.gz}" @@ -408,6 +409,101 @@ read_env_value() { done <"${file}" } +env_contains_nonempty_assignment() { + local file="$1" + local key="$2" + local line value first_char last_char + + if [[ ! -f "${file}" ]]; then + return 1 + fi + + while IFS= read -r line || [[ -n "${line}" ]]; do + line="${line#"${line%%[![:space:]]*}"}" + if [[ -z "${line}" || "${line}" == \#* || "${line}" != "${key}="* ]]; then + continue + fi + value="${line#*=}" + value="${value%$'\r'}" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + if [[ ${#value} -ge 2 ]]; then + first_char="${value:0:1}" + last_char="${value: -1}" + if [[ "${first_char}" == "${last_char}" && ( "${first_char}" == '"' || "${first_char}" == "'" ) ]]; then + value="${value:1:${#value}-2}" + fi + fi + if [[ "${value}" =~ [^[:space:]] ]]; then + return 0 + fi + done <"${file}" + return 1 +} + +env_has_assignment() { + local file="$1" + local key="$2" + local line current_key + + if [[ ! -f "${file}" ]]; then + return 1 + fi + + while IFS= read -r line || [[ -n "${line}" ]]; do + line="${line%$'\r'}" + line="${line#"${line%%[![:space:]]*}"}" + if [[ -z "${line}" || "${line}" == \#* || "${line}" != *"="* ]]; then + continue + fi + current_key="${line%%=*}" + current_key="${current_key%"${current_key##*[![:space:]]}"}" + if [[ "${current_key}" == "${key}" ]]; then + return 0 + fi + done <"${file}" + return 1 +} + +read_effective_env_value() { + local file="$1" + local key="$2" + local line current_key value first_char last_char matched_value="" found="false" + + if [[ ! -f "${file}" ]]; then + return + fi + + while IFS= read -r line || [[ -n "${line}" ]]; do + line="${line%$'\r'}" + line="${line#"${line%%[![:space:]]*}"}" + if [[ -z "${line}" || "${line}" == \#* || "${line}" != *"="* ]]; then + continue + fi + current_key="${line%%=*}" + current_key="${current_key%"${current_key##*[![:space:]]}"}" + if [[ "${current_key}" != "${key}" ]]; then + continue + fi + value="${line#*=}" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + if [[ ${#value} -ge 2 ]]; then + first_char="${value:0:1}" + last_char="${value: -1}" + if [[ "${first_char}" == "${last_char}" && ( "${first_char}" == '"' || "${first_char}" == "'" ) ]]; then + value="${value:1:${#value}-2}" + fi + fi + matched_value="${value}" + found="true" + done <"${file}" + + if [[ "${found}" == "true" ]]; then + printf "%s" "${matched_value}" + fi +} + write_env_value() { local file="$1" local key="$2" @@ -525,10 +621,120 @@ ensure_api_runtime_env_defaults() { 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_BGFILTER_WORKER_BASE_URL" "http://127.0.0.1:8083" + ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE" "/etc/genarrative/secrets/bgfilter-worker.token" + ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_BGFILTER_WORKER_CONNECT_TIMEOUT_MS" "2000" ensure_runtime_bootstrap_secret_file_env "${API_ENV_FILE}" ensure_env_value_migrates_old_default "${API_ENV_FILE}" "GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS" "45000" "180000" - 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" +} + +validate_bgfilter_shared_runtime_env() { + local request_timeout_ms connect_timeout_ms + + if [[ "${DRY_RUN}" == "true" ]]; then + echo "+ validate shared BgFilter provider attempt timeout in ${API_ENV_FILE}" + return + fi + + request_timeout_ms="$(read_effective_env_value "${API_ENV_FILE}" "GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS")" + if [[ ! "${request_timeout_ms}" =~ ^[1-9][0-9]*$ ]]; then + echo "[server-provision] GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS 必须在共享 API env 中配置为正整数毫秒: ${API_ENV_FILE}" >&2 + exit 1 + fi + + connect_timeout_ms="$(read_effective_env_value "${API_ENV_FILE}" "GENARRATIVE_BGFILTER_WORKER_CONNECT_TIMEOUT_MS")" + if [[ ! "${connect_timeout_ms}" =~ ^[1-9][0-9]*$ ]]; then + echo "[server-provision] GENARRATIVE_BGFILTER_WORKER_CONNECT_TIMEOUT_MS 必须在共享 API env 中配置为正整数毫秒: ${API_ENV_FILE}" >&2 + exit 1 + fi +} + +validate_no_bgfilter_internal_token_plaintext() { + local env_file + + if [[ "${DRY_RUN}" == "true" ]]; then + echo "+ reject non-empty GENARRATIVE_BGFILTER_INTERNAL_TOKEN in ${API_ENV_FILE}, ${WORKER_ENV_FILE}, and ${BGFILTER_WORKER_ENV_FILE}" + return + fi + + for env_file in "${API_ENV_FILE}" "${WORKER_ENV_FILE}" "${BGFILTER_WORKER_ENV_FILE}"; do + if env_contains_nonempty_assignment "${env_file}" "GENARRATIVE_BGFILTER_INTERNAL_TOKEN"; then + echo "[server-provision] ${env_file} 不得保存 GENARRATIVE_BGFILTER_INTERNAL_TOKEN 明文;生产环境只允许使用 GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE。" >&2 + exit 1 + fi + done +} + +validate_bgfilter_env_file_alignment() { + local env_file="$1" + local label="$2" + local include_provider_credentials="$3" + local key shared_value dedicated_value + local -a shared_keys=( + GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS + GENARRATIVE_BGFILTER_WORKER_BASE_URL + GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE + GENARRATIVE_BGFILTER_WORKER_CONNECT_TIMEOUT_MS + ALIYUN_OSS_BUCKET + ALIYUN_OSS_ENDPOINT + ) + + if [[ "${DRY_RUN}" == "true" ]]; then + echo "+ validate ${label} BgFilter shared configuration alignment with ${API_ENV_FILE}" + return + fi + if [[ ! -f "${env_file}" ]]; then + echo "[server-provision] ${label} 不存在,无法检查 BgFilter 共享配置: ${env_file}" >&2 + exit 1 + fi + + if [[ "${include_provider_credentials}" == "true" ]]; then + shared_keys+=( + GENARRATIVE_EDITOR_BGFILTER_BASE_URL + GENARRATIVE_EDITOR_BGFILTER_TOKEN + ALIYUN_OSS_ACCESS_KEY_ID + ALIYUN_OSS_ACCESS_KEY_SECRET + ALIYUN_OSS_READ_EXPIRE_SECONDS + ) + fi + + for key in "${shared_keys[@]}"; do + if ! env_has_assignment "${env_file}" "${key}"; then + continue + fi + dedicated_value="$(read_effective_env_value "${env_file}" "${key}")" + shared_value="$(read_effective_env_value "${API_ENV_FILE}" "${key}")" + if [[ "${dedicated_value}" != "${shared_value}" ]]; then + echo "[server-provision] ${label} 中的 BgFilter 共享配置与 API env 不一致: ${key};后加载 env 会覆盖进程有效值。" >&2 + exit 1 + fi + done +} + +validate_bgfilter_loopback_endpoint_alignment() { + local base_url host port expected_base_url + + if [[ "${DRY_RUN}" == "true" ]]; then + echo "+ validate BgFilter parent base URL and child listener alignment" + return + fi + + base_url="$(read_effective_env_value "${API_ENV_FILE}" "GENARRATIVE_BGFILTER_WORKER_BASE_URL")" + host="$(read_effective_env_value "${BGFILTER_WORKER_ENV_FILE}" "GENARRATIVE_BGFILTER_WORKER_HOST")" + port="$(read_effective_env_value "${BGFILTER_WORKER_ENV_FILE}" "GENARRATIVE_BGFILTER_WORKER_PORT")" + if [[ "${host}" != "127.0.0.1" ]]; then + echo "[server-provision] BgFilter worker 首版必须监听 127.0.0.1,当前 GENARRATIVE_BGFILTER_WORKER_HOST=${host:-}" >&2 + exit 1 + fi + if [[ ! "${port}" =~ ^[1-9][0-9]{0,4}$ ]] || (( 10#${port} > 65535 )); then + echo "[server-provision] GENARRATIVE_BGFILTER_WORKER_PORT 必须是 1-65535 的有效端口: ${BGFILTER_WORKER_ENV_FILE}" >&2 + exit 1 + fi + expected_base_url="http://${host}:${port}" + if [[ "${base_url%/}" != "${expected_base_url}" ]]; then + echo "[server-provision] 父进程 GENARRATIVE_BGFILTER_WORKER_BASE_URL 必须与 BgFilter worker 有效监听地址一致: expected=${expected_base_url}, actual=${base_url:-}" >&2 + exit 1 + fi } ensure_worker_runtime_env_defaults() { @@ -547,6 +753,80 @@ ensure_worker_runtime_env_defaults() { ensure_runtime_bootstrap_secret_file_env "${WORKER_ENV_FILE}" } +ensure_bgfilter_worker_runtime_env_defaults() { + if [[ "${DRY_RUN}" == "true" ]]; then + echo "+ ensure BgFilter worker runtime env defaults in ${BGFILTER_WORKER_ENV_FILE}" + return + fi + if [[ ! -f "${BGFILTER_WORKER_ENV_FILE}" ]]; then + echo "[server-provision] BgFilter worker 环境文件不存在,无法补齐运行态变量: ${BGFILTER_WORKER_ENV_FILE}" >&2 + exit 1 + fi + + ensure_env_value "${BGFILTER_WORKER_ENV_FILE}" "GENARRATIVE_BGFILTER_WORKER_HOST" "127.0.0.1" + ensure_env_value "${BGFILTER_WORKER_ENV_FILE}" "GENARRATIVE_BGFILTER_WORKER_PORT" "8083" + ensure_env_value "${BGFILTER_WORKER_ENV_FILE}" "GENARRATIVE_BGFILTER_WORKER_CONCURRENCY" "4" + ensure_env_value "${BGFILTER_WORKER_ENV_FILE}" "GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS" "128" + ensure_env_value "${BGFILTER_WORKER_ENV_FILE}" "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD" "3" + ensure_env_value "${BGFILTER_WORKER_ENV_FILE}" "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS" "300" +} + +ensure_bgfilter_internal_token_file() { + local token_file="/etc/genarrative/secrets/bgfilter-worker.token" + local token_dir="/etc/genarrative/secrets" + local openssl_bin="${GENARRATIVE_OPENSSL_PREFIX}/bin/openssl" + local configured_token_file temporary_file token_metadata + + if [[ "${DRY_RUN}" == "true" ]]; then + echo "+ ensure shared BgFilter internal token file ${token_file} (root:genarrative 0440)" + return + fi + configured_token_file="$(read_effective_env_value "${API_ENV_FILE}" "GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE")" + if [[ "${configured_token_file}" != "${token_file}" ]]; then + echo "[server-provision] GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE 必须与 Provision 管理路径一致: ${token_file}" >&2 + exit 1 + fi + if [[ -L "${token_dir}" || -L "${token_file}" ]]; then + echo "[server-provision] BgFilter 内部 Token 目录和文件不能是符号链接: ${token_file}" >&2 + exit 1 + fi + if [[ -e "${token_file}" && ! -f "${token_file}" ]]; then + echo "[server-provision] BgFilter 内部 Token 必须是普通文件: ${token_file}" >&2 + exit 1 + fi + install -d -o root -g genarrative -m 0750 "${token_dir}" + if [[ ! -f "${token_file}" ]]; then + temporary_file="$(mktemp "${token_dir}/.bgfilter-worker.token.XXXXXX")" + if ! "${openssl_bin}" rand -hex 32 >"${temporary_file}"; then + rm -f "${temporary_file}" + echo "[server-provision] 生成 BgFilter 内部 Token 失败。" >&2 + exit 1 + fi + if ! grep -q '[^[:space:]]' -- "${temporary_file}"; then + rm -f "${temporary_file}" + echo "[server-provision] 生成的 BgFilter 内部 Token 不得为空或只包含空白字符。" >&2 + exit 1 + fi + chown root:genarrative "${temporary_file}" + chmod 0440 "${temporary_file}" + mv -T "${temporary_file}" "${token_file}" + echo "[server-provision] 已生成 BgFilter 内部 Token 文件: ${token_file}" + else + if ! grep -q '[^[:space:]]' -- "${token_file}"; then + echo "[server-provision] BgFilter 内部 Token 文件不得为空或只包含空白字符: ${token_file}" >&2 + exit 1 + fi + chown root:genarrative "${token_file}" + chmod 0440 "${token_file}" + echo "[server-provision] BgFilter 内部 Token 文件已存在,保留内容并收紧权限。" + fi + token_metadata="$(stat -c '%U:%G:%a' -- "${token_file}")" + if [[ "${token_metadata}" != "root:genarrative:440" ]]; then + echo "[server-provision] BgFilter 内部 Token 权限必须为 root:genarrative 0440: ${token_file} (${token_metadata})" >&2 + exit 1 + fi +} + parse_json_string_field() { local json="$1" local key="$2" @@ -684,6 +964,10 @@ render_external_generation_controller_env_example() { cat deploy/env/external-generation-controller.env.example } +render_bgfilter_worker_env_example() { + cat deploy/env/bgfilter-worker.env.example +} + render_otelcol_service() { cat deploy/systemd/otelcol-contrib.service } @@ -927,6 +1211,35 @@ render_external_generation_controller_service() { deploy/systemd/genarrative-external-generation-controller.service } +render_bgfilter_worker_service() { + local current_escaped api_env_escaped bgfilter_env_escaped + current_escaped="$(escape_sed_replacement "${CURRENT_LINK}")" + api_env_escaped="$(escape_sed_replacement "${API_ENV_FILE}")" + bgfilter_env_escaped="$(escape_sed_replacement "${BGFILTER_WORKER_ENV_FILE}")" + sed \ + -e "s|/opt/genarrative/current|${current_escaped}|g" \ + -e "s|/etc/genarrative/api-server.env|${api_env_escaped}|g" \ + -e "s|/etc/genarrative/bgfilter-worker.env|${bgfilter_env_escaped}|g" \ + deploy/systemd/genarrative-bgfilter-worker.service +} + +wait_for_bgfilter_worker_service() { + if [[ "${DRY_RUN}" == "true" ]]; then + echo "+ curl -fsS --max-time 2 http://127.0.0.1:8083/readyz" + return + fi + echo "[server-provision] 等待 BgFilter worker readiness。" + for _ in {1..30}; do + if systemctl is-active --quiet genarrative-bgfilter-worker.service && curl -fsS --max-time 2 http://127.0.0.1:8083/readyz >/dev/null; then + return + fi + sleep 2 + done + systemctl --no-pager --full status genarrative-bgfilter-worker.service || true + echo "[server-provision] BgFilter worker 未在超时时间内通过 readiness。" >&2 + exit 1 +} + render_database_backup_service() { local current_escaped env_escaped current_escaped="$(escape_sed_replacement "${CURRENT_LINK}")" @@ -995,6 +1308,7 @@ require_path deploy/systemd/spacetimedb.service require_path deploy/systemd/genarrative-api.service require_path deploy/systemd/genarrative-external-generation-worker@.service require_path deploy/systemd/genarrative-external-generation-controller.service +require_path deploy/systemd/genarrative-bgfilter-worker.service require_path deploy/systemd/genarrative-database-backup.service require_path deploy/systemd/genarrative-database-backup-files-history.conf require_path deploy/systemd/genarrative-database-backup.timer @@ -1013,6 +1327,7 @@ require_path deploy/logrotate/genarrative-pingora-gateway require_path deploy/env/api-server.env.example require_path deploy/env/external-generation-worker.env.example require_path deploy/env/external-generation-controller.env.example +require_path deploy/env/bgfilter-worker.env.example require_path scripts/deploy/maintenance-on.sh require_path scripts/deploy/maintenance-off.sh require_path scripts/deploy/maintenance-status.sh @@ -1026,7 +1341,7 @@ echo "[server-provision] target=${DEPLOY_TARGET}, dry_run=${DRY_RUN}, nginx_conf run_cmd id require_root_for_real_provision install_nginx_brotli_modules -run_cmd mkdir -p "${SPACETIME_ROOT}" "${RELEASE_ROOT}" "$(dirname "${CURRENT_LINK}")" "$(dirname "${WEB_LINK}")" /etc/genarrative /etc/genarrative/pingora /var/lib/genarrative/maintenance /var/lib/genarrative/auth /var/lib/genarrative/tracking-outbox /var/lib/genarrative/wallet-refund-outbox /var/lib/genarrative/editor-generation-pricing /var/lib/genarrative/spacetime /var/lib/genarrative/database-backups /var/lib/genarrative/health-patrol /var/log/genarrative +run_cmd mkdir -p "${SPACETIME_ROOT}" "${RELEASE_ROOT}" "$(dirname "${CURRENT_LINK}")" "$(dirname "${WEB_LINK}")" /etc/genarrative /etc/genarrative/pingora /etc/genarrative/secrets /var/lib/genarrative/maintenance /var/lib/genarrative/auth /var/lib/genarrative/tracking-outbox /var/lib/genarrative/wallet-refund-outbox /var/lib/genarrative/editor-generation-pricing /var/lib/genarrative/spacetime /var/lib/genarrative/database-backups /var/lib/genarrative/health-patrol /var/log/genarrative if ! id spacetimedb >/dev/null 2>&1; then run_cmd useradd --system --home-dir "${SPACETIME_ROOT}" --shell /usr/sbin/nologin spacetimedb @@ -1069,18 +1384,21 @@ spacetimedb_service="$(mktemp)" api_service="$(mktemp)" external_generation_worker_service="$(mktemp)" external_generation_controller_service="$(mktemp)" +bgfilter_worker_service="$(mktemp)" database_backup_service="$(mktemp)" health_patrol_service="$(mktemp)" render_spacetimedb_service >"${spacetimedb_service}" render_api_service >"${api_service}" render_external_generation_worker_service >"${external_generation_worker_service}" render_external_generation_controller_service >"${external_generation_controller_service}" +render_bgfilter_worker_service >"${bgfilter_worker_service}" render_database_backup_service >"${database_backup_service}" render_health_patrol_service >"${health_patrol_service}" install_file "${spacetimedb_service}" /etc/systemd/system/spacetimedb.service 0644 install_file "${api_service}" /etc/systemd/system/genarrative-api.service 0644 install_file "${external_generation_worker_service}" /etc/systemd/system/genarrative-external-generation-worker@.service 0644 install_file "${external_generation_controller_service}" /etc/systemd/system/genarrative-external-generation-controller.service 0644 +install_file "${bgfilter_worker_service}" /etc/systemd/system/genarrative-bgfilter-worker.service 0644 install_file "${database_backup_service}" /etc/systemd/system/genarrative-database-backup.service 0644 install_file deploy/systemd/genarrative-database-backup.timer /etc/systemd/system/genarrative-database-backup.timer 0644 install_file "${health_patrol_service}" /etc/systemd/system/genarrative-health-patrol.service 0644 @@ -1088,7 +1406,7 @@ install_file deploy/systemd/genarrative-health-patrol.timer /etc/systemd/system/ install_file deploy/systemd/genarrative-pingora-gateway.service /etc/systemd/system/genarrative-pingora-gateway.service 0644 install_file deploy/systemd/genarrative-pingora-gateway-direct-entry.conf /etc/genarrative/pingora/genarrative-pingora-gateway-direct-entry.conf 0644 install_file deploy/logrotate/genarrative-pingora-gateway /etc/logrotate.d/genarrative-pingora-gateway 0644 -rm -f "${spacetimedb_service}" "${api_service}" "${external_generation_worker_service}" "${external_generation_controller_service}" "${database_backup_service}" "${health_patrol_service}" +rm -f "${spacetimedb_service}" "${api_service}" "${external_generation_worker_service}" "${external_generation_controller_service}" "${bgfilter_worker_service}" "${database_backup_service}" "${health_patrol_service}" if [[ ! -f "${API_ENV_FILE}" ]]; then echo "+ create ${API_ENV_FILE} from example" @@ -1101,6 +1419,7 @@ else echo "[server-provision] 已存在环境文件,保留不覆盖: ${API_ENV_FILE}" fi ensure_api_runtime_env_defaults +validate_bgfilter_shared_runtime_env configure_database_backup_profile if [[ ! -f "${WORKER_ENV_FILE}" ]]; then @@ -1115,6 +1434,23 @@ else fi ensure_worker_runtime_env_defaults +if [[ ! -f "${BGFILTER_WORKER_ENV_FILE}" ]]; then + echo "+ create ${BGFILTER_WORKER_ENV_FILE} from example" + if [[ "${DRY_RUN}" != "true" ]]; then + render_bgfilter_worker_env_example >"${BGFILTER_WORKER_ENV_FILE}" + chmod 0600 "${BGFILTER_WORKER_ENV_FILE}" + chown root:root "${BGFILTER_WORKER_ENV_FILE}" + fi +else + echo "[server-provision] 已存在 BgFilter worker 环境文件,保留不覆盖: ${BGFILTER_WORKER_ENV_FILE}" +fi +ensure_bgfilter_worker_runtime_env_defaults +validate_bgfilter_env_file_alignment "${WORKER_ENV_FILE}" "外部生成 worker env" "false" +validate_bgfilter_env_file_alignment "${BGFILTER_WORKER_ENV_FILE}" "BgFilter 专属 env" "true" +validate_bgfilter_loopback_endpoint_alignment +validate_no_bgfilter_internal_token_plaintext +ensure_bgfilter_internal_token_file + if [[ ! -f "${CONTROLLER_ENV_FILE}" ]]; then echo "+ create ${CONTROLLER_ENV_FILE} from example" if [[ "${DRY_RUN}" != "true" ]]; then @@ -1149,7 +1485,7 @@ if [[ "${ENABLE_SERVICES}" == "true" ]]; then run_cmd systemctl enable otelcol-contrib.service fi stamp_database_backup_timer_now - run_cmd systemctl enable spacetimedb.service genarrative-api.service genarrative-database-backup.timer genarrative-external-generation-worker@1.service genarrative-external-generation-controller.service genarrative-health-patrol.timer + run_cmd systemctl enable spacetimedb.service genarrative-bgfilter-worker.service genarrative-api.service genarrative-database-backup.timer genarrative-external-generation-worker@1.service genarrative-external-generation-controller.service genarrative-health-patrol.timer run_cmd systemctl start genarrative-database-backup.timer if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then run_cmd systemctl restart otelcol-contrib.service @@ -1158,13 +1494,17 @@ if [[ "${ENABLE_SERVICES}" == "true" ]]; then wait_for_spacetimedb_service ensure_spacetime_owner_client_token if [[ -x "${CURRENT_LINK}/api-server" ]]; then + run_cmd systemctl enable genarrative-bgfilter-worker.service + run_cmd systemctl stop genarrative-bgfilter-worker.service + run_cmd systemctl start genarrative-bgfilter-worker.service + wait_for_bgfilter_worker_service run_cmd systemctl restart genarrative-api.service run_cmd systemctl enable --now genarrative-external-generation-worker@1.service run_cmd systemctl restart genarrative-external-generation-worker@1.service run_cmd systemctl enable --now genarrative-external-generation-controller.service run_cmd systemctl restart genarrative-external-generation-controller.service else - echo "[server-provision] 尚未发现 ${CURRENT_LINK}/api-server,跳过 api-server、外部生成 worker 和 controller 首次启动。后续 API deploy 会启用并启动默认 worker 与 controller。" + echo "[server-provision] 尚未发现 ${CURRENT_LINK}/api-server,跳过 BgFilter worker、api-server、外部生成 worker 和 controller 首次启动。后续 API deploy 会按顺序启动。" fi fi diff --git a/scripts/ops/production-health-patrol.mjs b/scripts/ops/production-health-patrol.mjs index 35b61870c..0b941d070 100644 --- a/scripts/ops/production-health-patrol.mjs +++ b/scripts/ops/production-health-patrol.mjs @@ -18,12 +18,14 @@ const DEFAULT_PUBLIC_PATHS = [ const DEFAULT_SERVICES = [ 'genarrative-api.service', + 'genarrative-bgfilter-worker.service', 'genarrative-external-generation-controller.service', 'spacetimedb.service', 'nginx.service', ]; const PINGORA_DIRECT_SERVICES = [ 'genarrative-api.service', + 'genarrative-bgfilter-worker.service', 'genarrative-external-generation-controller.service', 'spacetimedb.service', 'genarrative-pingora-gateway.service', @@ -37,6 +39,7 @@ function usage() { Options: --api-base-url API direct base URL, default http://127.0.0.1:8082 + --bgfilter-base-url BgFilter worker base URL, default http://127.0.0.1:8083 --spacetime-base-url SpacetimeDB base URL, default http://127.0.0.1:3101 --public-base-url Nginx/public base URL, default http://127.0.0.1 --public-host Optional public Host header, useful when probing 127.0.0.1 @@ -88,6 +91,9 @@ function parseArgs(argv) { apiBaseUrl: process.env.GENARRATIVE_HEALTH_PATROL_API_BASE_URL || 'http://127.0.0.1:8082', + bgfilterBaseUrl: + process.env.GENARRATIVE_HEALTH_PATROL_BGFILTER_BASE_URL || + 'http://127.0.0.1:8083', spacetimeBaseUrl: process.env.GENARRATIVE_HEALTH_PATROL_SPACETIME_BASE_URL || 'http://127.0.0.1:3101', @@ -131,6 +137,9 @@ function parseArgs(argv) { case '--api-base-url': config.apiBaseUrl = requireValue(argv, ++index, arg); break; + case '--bgfilter-base-url': + config.bgfilterBaseUrl = requireValue(argv, ++index, arg); + break; case '--spacetime-base-url': config.spacetimeBaseUrl = requireValue(argv, ++index, arg); break; @@ -693,6 +702,13 @@ async function main() { config, ), ); + checks.push( + await checkHttp( + 'bgfilter:/readyz', + joinUrl(config.bgfilterBaseUrl, '/readyz'), + config, + ), + ); checks.push( await checkHttp( 'spacetimedb:/v1/ping', diff --git a/server-rs/crates/api-server/src/bgfilter_worker.rs b/server-rs/crates/api-server/src/bgfilter_worker.rs new file mode 100644 index 000000000..6fa8304f5 --- /dev/null +++ b/server-rs/crates/api-server/src/bgfilter_worker.rs @@ -0,0 +1,2775 @@ +use std::{ + io::Cursor, + sync::{Arc, Mutex, OnceLock}, + time::{Duration, Instant}, +}; + +use axum::{ + Json, Router, + body::Body, + extract::{DefaultBodyLimit, Extension, Request, State, rejection::JsonRejection}, + http::{ + HeaderMap, HeaderValue, StatusCode, + header::{AUTHORIZATION, CONTENT_TYPE, RETRY_AFTER}, + }, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::{get, post}, +}; +use http_body_util::BodyExt; +use opentelemetry::{ + KeyValue, global, + metrics::{Counter, Histogram, ObservableGauge, UpDownCounter}, +}; +use platform_oss::OssSignedGetObjectUrlRequest; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tokio::{ + sync::{Notify, OwnedSemaphorePermit, Semaphore, TryAcquireError}, + time::timeout_at, +}; +use uuid::Uuid; + +use crate::{external_api_audit::ExternalApiAuditContext, http_error::AppError, state::AppState}; + +pub(crate) const BGFILTER_INTERNAL_REMOVE_BACKGROUND_PATH: &str = + "/internal/bgfilter/v1/remove-background"; +const BGFILTER_INTERNAL_REQUEST_BODY_MAX_BYTES: usize = 64 * 1024; +const BGFILTER_INTERNAL_ERROR_BODY_MAX_BYTES: usize = 64 * 1024; +const BGFILTER_MAX_RESPONSE_BYTES: usize = 32 * 1024 * 1024; +const BGFILTER_MAX_IMAGE_DIMENSION: u32 = 8192; +const BGFILTER_MAX_REQUEST_BUDGET_MS: u64 = 600_000; +const BGFILTER_PROVIDER_MAX_ATTEMPTS: usize = 2; +const BGFILTER_PROVIDER_ATTEMPT_RESERVE: Duration = Duration::from_secs(1); +const BGFILTER_INTERNAL_CLIENT_RESPONSE_RESERVE: Duration = Duration::from_secs(2); +const BGFILTER_SOURCE_URL_EXPIRE_SECONDS: u64 = 600; +const BGFILTER_PROVIDER_TOKEN_HEADER: &str = "X-Genarrative-Image-Token"; + +static BGFILTER_FLAT_CIRCUIT: OnceLock> = OnceLock::new(); + +struct BgfilterMetrics { + _circuit_state: ObservableGauge, + waiting_requests: UpDownCounter, + in_flight: UpDownCounter, + internal_request_seconds: Histogram, + provider_http_seconds: Histogram, + internal_request_total: Counter, + internal_response_bytes: Histogram, +} + +fn bgfilter_metrics() -> &'static BgfilterMetrics { + static METRICS: OnceLock = OnceLock::new(); + METRICS.get_or_init(|| { + let meter = global::meter("genarrative-bgfilter-worker"); + let circuit_state = meter + .i64_observable_gauge("bgfilter_circuit_state") + .with_description("Flat BgFilter circuit state: 0 closed, 1 open") + .with_callback(|observer| { + let open = flat_circuit_state() + .lock() + .ok() + .and_then(|circuit| circuit.open_until) + .is_some_and(|open_until| open_until > Instant::now()); + observer.observe(i64::from(open), &[KeyValue::new("mode", "flat")]); + }) + .build(); + BgfilterMetrics { + _circuit_state: circuit_state, + waiting_requests: meter + .i64_up_down_counter("bgfilter_internal_waiting_requests") + .with_unit("{request}") + .with_description("Internal requests waiting for a provider permit") + .build(), + in_flight: meter + .i64_up_down_counter("bgfilter_internal_in_flight") + .with_unit("{request}") + .with_description("Logical provider calls holding an N permit") + .build(), + internal_request_seconds: meter + .f64_histogram("bgfilter_internal_request_seconds") + .with_unit("s") + .with_description("Internal logical request duration") + .build(), + provider_http_seconds: meter + .f64_histogram("bgfilter_provider_http_seconds") + .with_unit("s") + .with_description("BgFilter provider HTTP attempt duration") + .build(), + internal_request_total: meter + .u64_counter("bgfilter_internal_request_total") + .with_unit("{request}") + .with_description("Internal logical request outcomes") + .build(), + internal_response_bytes: meter + .u64_histogram("bgfilter_internal_response_bytes") + .with_unit("By") + .with_description("Successful internal image response size") + .build(), + } + }) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum BgfilterBackgroundMode { + Flat, + Complex, +} + +impl BgfilterBackgroundMode { + fn as_str(self) -> &'static str { + match self { + Self::Flat => "flat", + Self::Complex => "complex", + } + } + + fn uses_flat_circuit(self) -> bool { + matches!(self, Self::Flat) + } +} + +#[derive(Clone, Debug)] +pub(crate) struct BgfilterImage { + pub(crate) bytes: Vec, + pub(crate) mime_type: String, + pub(crate) extension: String, + pub(crate) width: u32, + pub(crate) height: u32, +} + +#[derive(Clone, Debug)] +pub(crate) struct BgfilterClientError { + code: &'static str, + message: String, + status: StatusCode, + timeout: bool, + transport: bool, +} + +impl BgfilterClientError { + pub(crate) fn code(&self) -> &'static str { + self.code + } + + pub(crate) fn allows_flat_fallback(&self) -> bool { + !matches!(self.code, "invalid_request" | "unauthorized" | "cancelled") + } + + pub(crate) fn into_app_error(self) -> AppError { + let status = match self.code { + "invalid_request" => StatusCode::BAD_REQUEST, + "unauthorized" => StatusCode::SERVICE_UNAVAILABLE, + "cancelled" => StatusCode::REQUEST_TIMEOUT, + "deadline_exceeded" => StatusCode::GATEWAY_TIMEOUT, + "overloaded" | "circuit_open" => StatusCode::SERVICE_UNAVAILABLE, + _ => StatusCode::BAD_GATEWAY, + }; + AppError::from_status(status).with_details(json!({ + "provider": "bgfilter-worker", + "message": self.message, + "workerCode": self.code, + "workerStatus": self.status.as_u16(), + "timeout": self.timeout, + "transport": self.transport, + })) + } + + fn local(code: &'static str, message: impl Into) -> Self { + Self { + code, + message: message.into(), + status: StatusCode::BAD_GATEWAY, + timeout: code == "deadline_exceeded", + transport: false, + } + } +} + +#[derive(Clone)] +struct BgfilterWorkerRuntime { + app_state: AppState, + admission: Arc, + provider: Arc, + internal_token: Arc, + task_tracker: BgfilterTaskTracker, +} + +impl BgfilterWorkerRuntime { + fn new(app_state: AppState, task_tracker: BgfilterTaskTracker) -> Result { + let token = app_state + .config + .bgfilter_internal_token + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| "GENARRATIVE_BGFILTER_INTERNAL_TOKEN(_FILE) 未配置".to_string())?; + if app_state.oss_client().is_none() { + return Err("bgfilter-worker 需要完整的 ALIYUN_OSS_* 读取配置".to_string()); + } + validate_provider_base_url(app_state.config.editor_bgfilter_base_url.as_str())?; + let concurrency = app_state.config.bgfilter_worker_concurrency; + let max_requests = app_state.config.bgfilter_worker_max_requests; + if concurrency == 0 || max_requests == 0 { + return Err("BgFilter worker 的 N/Q 必须大于 0".to_string()); + } + if max_requests < concurrency { + return Err( + "GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS 不能小于 CONCURRENCY".to_string(), + ); + } + if concurrency > Semaphore::MAX_PERMITS || max_requests > Semaphore::MAX_PERMITS { + return Err("BgFilter worker 的 N/Q 超过 semaphore 支持上限".to_string()); + } + Ok(Self { + app_state, + admission: Arc::new(Semaphore::new(max_requests)), + provider: Arc::new(Semaphore::new(concurrency)), + internal_token: Arc::from(token), + task_tracker, + }) + } +} + +#[derive(Clone)] +pub(crate) struct BgfilterTaskTracker { + inner: Arc, +} + +struct BgfilterTaskTrackerInner { + state: Mutex, + drained: Notify, +} + +struct BgfilterTaskTrackerState { + accepting: bool, + in_flight: usize, +} + +impl BgfilterTaskTracker { + fn new() -> Self { + Self { + inner: Arc::new(BgfilterTaskTrackerInner { + state: Mutex::new(BgfilterTaskTrackerState { + accepting: true, + in_flight: 0, + }), + drained: Notify::new(), + }), + } + } + + fn register(&self) -> Option { + let mut state = self.inner.state.lock().ok()?; + if !state.accepting { + return None; + } + state.in_flight = state.in_flight.saturating_add(1); + Some(BgfilterTaskGuard { + inner: self.inner.clone(), + }) + } + + /// 为已经获准启动的内部子工作增加排空计数,不受 close() 停止新请求影响。 + fn track_started(&self) -> BgfilterTaskGuard { + let mut state = self + .inner + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.in_flight = state.in_flight.saturating_add(1); + BgfilterTaskGuard { + inner: self.inner.clone(), + } + } + + fn is_accepting(&self) -> bool { + self.inner.state.lock().is_ok_and(|state| state.accepting) + } + + pub(crate) fn close(&self) { + if let Ok(mut state) = self.inner.state.lock() { + state.accepting = false; + if state.in_flight == 0 { + self.inner.drained.notify_waiters(); + } + } + } + + pub(crate) async fn wait_for_drain(&self) { + loop { + let drained = self.inner.drained.notified(); + if self + .inner + .state + .lock() + .is_ok_and(|state| state.in_flight == 0) + { + return; + } + drained.await; + } + } +} + +struct BgfilterTaskGuard { + inner: Arc, +} + +impl Drop for BgfilterTaskGuard { + fn drop(&mut self) { + if let Ok(mut state) = self.inner.state.lock() { + state.in_flight = state.in_flight.saturating_sub(1); + if state.in_flight == 0 { + self.inner.drained.notify_waiters(); + } + } + } +} + +pub(crate) fn build_bgfilter_worker_router( + app_state: AppState, +) -> Result<(Router, BgfilterTaskTracker), String> { + let task_tracker = BgfilterTaskTracker::new(); + let runtime = BgfilterWorkerRuntime::new(app_state, task_tracker.clone())?; + let _ = bgfilter_metrics(); + let router = Router::new() + .route("/healthz", get(bgfilter_health)) + .route("/readyz", get(bgfilter_readiness)) + .route( + BGFILTER_INTERNAL_REMOVE_BACKGROUND_PATH, + post(remove_background), + ) + .layer(DefaultBodyLimit::max( + BGFILTER_INTERNAL_REQUEST_BODY_MAX_BYTES, + )) + .layer(middleware::from_fn_with_state( + runtime.clone(), + authenticate_and_admit, + )) + .with_state(runtime); + Ok((router, task_tracker)) +} + +async fn bgfilter_health() -> Json { + Json(json!({"ok": true, "service": "genarrative-bgfilter-worker"})) +} + +async fn bgfilter_readiness(State(runtime): State) -> Response { + if runtime.app_state.is_ready() && runtime.task_tracker.is_accepting() { + return Json(json!({ + "ok": true, + "ready": true, + "service": "genarrative-bgfilter-worker", + })) + .into_response(); + } + worker_error_response( + StatusCode::SERVICE_UNAVAILABLE, + WorkerFailure::new( + "internal_error", + "bgfilter-worker 正在退出,不再接收新请求", + true, + ), + None, + ) +} + +async fn authenticate_and_admit( + State(runtime): State, + mut request: Request, + next: Next, +) -> Response { + if matches!(request.uri().path(), "/healthz" | "/readyz") { + return next.run(request).await; + } + + let started_at = Instant::now(); + let request_id = internal_request_id(request.headers()); + if !valid_internal_authorization(request.headers(), runtime.internal_token.as_ref()) { + record_internal_outcome_metrics("unknown", "unauthorized", started_at.elapsed(), None); + return worker_error_response( + StatusCode::UNAUTHORIZED, + WorkerFailure::new("unauthorized", "内部调用鉴权失败", false), + Some(request_id.as_str()), + ); + } + + let permit = match runtime.admission.clone().try_acquire_owned() { + Ok(permit) => permit, + Err(TryAcquireError::NoPermits) | Err(TryAcquireError::Closed) => { + record_internal_outcome_metrics("unknown", "overloaded", started_at.elapsed(), None); + let mut response = worker_error_response( + StatusCode::TOO_MANY_REQUESTS, + WorkerFailure::new("overloaded", "BgFilter 等待队列已满", true), + Some(request_id.as_str()), + ); + response + .headers_mut() + .insert(RETRY_AFTER, HeaderValue::from_static("1")); + return response; + } + }; + let guard = Arc::new(AdmissionGuard { + _permit: permit, + admitted_at: Instant::now(), + request_id, + }); + request.extensions_mut().insert(guard.clone()); + hold_admission_until_response_dropped(next.run(request).await, guard) +} + +fn valid_internal_authorization(headers: &HeaderMap, expected_token: &str) -> bool { + let Some(value) = headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + else { + return false; + }; + let value = value.trim(); + let Some(separator) = value.find(char::is_whitespace) else { + return false; + }; + let (scheme, credentials) = value.split_at(separator); + let token = credentials.trim(); + if !scheme.eq_ignore_ascii_case("Bearer") + || token.is_empty() + || token.chars().any(char::is_whitespace) + { + return false; + } + constant_time_eq(token.as_bytes(), expected_token.as_bytes()) +} + +fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { + let max_len = left.len().max(right.len()); + let mut difference = left.len() ^ right.len(); + for index in 0..max_len { + let left_byte = left.get(index).copied().unwrap_or_default(); + let right_byte = right.get(index).copied().unwrap_or_default(); + difference |= usize::from(left_byte ^ right_byte); + } + difference == 0 +} + +fn internal_request_id(headers: &HeaderMap) -> String { + headers + .get("x-request-id") + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty() && value.len() <= 128 && !has_control(value)) + .map(ToOwned::to_owned) + .unwrap_or_else(|| Uuid::new_v4().to_string()) +} + +struct AdmissionGuard { + _permit: OwnedSemaphorePermit, + admitted_at: Instant, + request_id: String, +} + +fn hold_admission_until_response_dropped( + response: Response, + guard: Arc, +) -> Response { + response.map(|body| { + Body::new(body.map_frame(move |frame| { + let _guard = &guard; + frame + })) + }) +} + +fn hold_provider_until_response_dropped( + response: Response, + permit: Option, +) -> Response { + let Some(permit) = permit else { + return response; + }; + response.map(|body| { + Body::new(body.map_frame(move |frame| { + let _permit = &permit; + frame + })) + }) +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct BgfilterInternalAuditContext { + #[serde(default)] + user_id: Option, + #[serde(default)] + profile_id: Option, + #[serde(default)] + request_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct BgfilterInternalRequest { + request_id: String, + source_object_key: String, + background_mode: BgfilterBackgroundMode, + #[serde(default)] + screen_color: Option, + seg_model: String, + cross_check: bool, + request_budget_ms: u64, + #[serde(default)] + audit_context: Option, +} + +async fn remove_background( + State(runtime): State, + Extension(admission): Extension>, + payload: Result, JsonRejection>, +) -> Response { + let request = match payload { + Ok(Json(request)) => request, + Err(error) => { + record_internal_outcome_metrics( + "unknown", + "invalid_request", + admission.admitted_at.elapsed(), + None, + ); + return worker_error_response( + status_for_json_rejection(&error), + WorkerFailure::new( + "invalid_request", + format!("内部请求 JSON 无效:{error}"), + false, + ), + Some(admission.request_id.as_str()), + ); + } + }; + if let Err(error) = validate_internal_request(&request, &admission) { + record_internal_outcome_metrics( + request.background_mode.as_str(), + "invalid_request", + admission.admitted_at.elapsed(), + None, + ); + return worker_error_response( + StatusCode::BAD_REQUEST, + error, + Some(admission.request_id.as_str()), + ); + } + + let Some(task_guard) = runtime.task_tracker.register() else { + record_internal_outcome_metrics( + request.background_mode.as_str(), + "internal_error", + admission.admitted_at.elapsed(), + None, + ); + return worker_error_response( + StatusCode::SERVICE_UNAVAILABLE, + WorkerFailure::new( + "internal_error", + "bgfilter-worker 正在退出,不再启动新的 provider 请求", + true, + ), + Some(admission.request_id.as_str()), + ); + }; + + let deadline = admission + .admitted_at + .checked_add(Duration::from_millis(request.request_budget_ms)) + .unwrap_or(admission.admitted_at); + let background_mode = request.background_mode; + let drain_guard = admission.clone(); + let task_runtime = runtime.clone(); + let task = tokio::spawn(async move { + let _task_guard = task_guard; + let _drain_guard = drain_guard; + execute_logical_request(task_runtime, request, deadline).await + }); + + let outcome = match task.await { + Ok(outcome) => outcome, + Err(error) => WorkerOutcome { + result: Err(WorkerFailure::new( + "internal_error", + format!("BgFilter worker 内部任务异常:{error}"), + true, + )), + provider_permit: None, + }, + }; + record_internal_request_metrics( + background_mode, + &outcome.result, + admission.admitted_at.elapsed(), + ); + let response = match outcome.result { + Ok(image) => worker_image_response(image, admission.request_id.as_str()), + Err(error) => worker_error_response( + error.status_code(), + error, + Some(admission.request_id.as_str()), + ), + }; + hold_provider_until_response_dropped(response, outcome.provider_permit) +} + +fn record_internal_request_metrics( + mode: BgfilterBackgroundMode, + result: &Result, + elapsed: Duration, +) { + let outcome = match result { + Ok(_) => "success", + Err(error) => error.code, + }; + let response_bytes = result.as_ref().ok().map(|image| image.bytes.len()); + record_internal_outcome_metrics(mode.as_str(), outcome, elapsed, response_bytes); +} + +fn record_internal_outcome_metrics( + mode: &'static str, + outcome: &'static str, + elapsed: Duration, + response_bytes: Option, +) { + let labels = [ + KeyValue::new("mode", mode), + KeyValue::new("outcome", outcome), + ]; + let metrics = bgfilter_metrics(); + metrics.internal_request_total.add(1, &labels); + metrics + .internal_request_seconds + .record(elapsed.as_secs_f64(), &labels); + if let Some(response_bytes) = response_bytes { + metrics.internal_response_bytes.record( + response_bytes.min(u64::MAX as usize) as u64, + &[KeyValue::new("mode", mode)], + ); + } +} + +fn status_for_json_rejection(error: &JsonRejection) -> StatusCode { + if error.status() == StatusCode::PAYLOAD_TOO_LARGE { + StatusCode::PAYLOAD_TOO_LARGE + } else { + StatusCode::BAD_REQUEST + } +} + +fn validate_internal_request( + request: &BgfilterInternalRequest, + admission: &AdmissionGuard, +) -> Result<(), WorkerFailure> { + if request.request_id != admission.request_id { + return Err(WorkerFailure::new( + "invalid_request", + "requestId 必须与 X-Request-Id 一致", + false, + )); + } + validate_bounded_field("requestId", request.request_id.as_str(), 128)?; + validate_bounded_field("sourceObjectKey", request.source_object_key.as_str(), 1024)?; + validate_bounded_field("segModel", request.seg_model.as_str(), 64)?; + if request.request_budget_ms == 0 || request.request_budget_ms > BGFILTER_MAX_REQUEST_BUDGET_MS + { + return Err(WorkerFailure::new( + "invalid_request", + "requestBudgetMs 超出允许范围", + false, + )); + } + let normalized_object_key = crate::editor_project::normalize_editor_reference_object_key( + request.source_object_key.as_str(), + ) + .map_err(|_| { + WorkerFailure::new( + "invalid_request", + "sourceObjectKey 不是允许的私有生成对象键", + false, + ) + })?; + if normalized_object_key != request.source_object_key { + return Err(WorkerFailure::new( + "invalid_request", + "sourceObjectKey 必须使用不带前导斜杠的规范 object key", + false, + )); + } + if !valid_canonical_object_key(request.source_object_key.as_str()) { + return Err(WorkerFailure::new( + "invalid_request", + "sourceObjectKey 包含路径逃逸、空路径段或 URL 分隔符", + false, + )); + } + if !matches!(request.seg_model.as_str(), "birefnet" | "anime-seg") { + return Err(WorkerFailure::new( + "invalid_request", + "segModel 必须是 birefnet 或 anime-seg", + false, + )); + } + match request.background_mode { + BgfilterBackgroundMode::Flat => { + let screen_color = request.screen_color.as_deref().ok_or_else(|| { + WorkerFailure::new("invalid_request", "flat 请求缺少 screenColor", false) + })?; + if !valid_screen_color(screen_color) { + return Err(WorkerFailure::new( + "invalid_request", + "screenColor 必须是 #RRGGBB", + false, + )); + } + } + BgfilterBackgroundMode::Complex => { + if request.screen_color.is_some() { + return Err(WorkerFailure::new( + "invalid_request", + "complex 请求不得携带 screenColor", + false, + )); + } + if request.seg_model != "birefnet" || request.cross_check { + return Err(WorkerFailure::new( + "invalid_request", + "complex 请求必须使用 birefnet 且 crossCheck=false", + false, + )); + } + } + } + if let Some(audit) = request.audit_context.as_ref() { + for (field, value) in [ + ("auditContext.userId", audit.user_id.as_deref()), + ("auditContext.profileId", audit.profile_id.as_deref()), + ("auditContext.requestId", audit.request_id.as_deref()), + ] { + if let Some(value) = value { + validate_bounded_field(field, value, 256)?; + } + } + } + Ok(()) +} + +fn validate_bounded_field(field: &str, value: &str, max_len: usize) -> Result<(), WorkerFailure> { + if value.trim().is_empty() || value.len() > max_len || has_control(value) { + return Err(WorkerFailure::new( + "invalid_request", + format!("{field} 为空、过长或包含控制字符"), + false, + )); + } + Ok(()) +} + +fn has_control(value: &str) -> bool { + value.chars().any(char::is_control) +} + +fn valid_canonical_object_key(value: &str) -> bool { + !value + .chars() + .any(|character| matches!(character, '\\' | '?' | '#' | '%')) + && value + .split('/') + .all(|segment| !segment.is_empty() && !matches!(segment, "." | "..")) +} + +fn valid_screen_color(value: &str) -> bool { + value.len() == 7 + && value.starts_with('#') + && value.as_bytes()[1..].iter().all(u8::is_ascii_hexdigit) +} + +struct WorkerOutcome { + result: Result, + provider_permit: Option, +} + +struct WaitingRequestMetricGuard { + mode: &'static str, +} + +impl WaitingRequestMetricGuard { + fn new(mode: BgfilterBackgroundMode) -> Self { + let mode = mode.as_str(); + bgfilter_metrics() + .waiting_requests + .add(1, &[KeyValue::new("mode", mode)]); + Self { mode } + } +} + +impl Drop for WaitingRequestMetricGuard { + fn drop(&mut self) { + bgfilter_metrics() + .waiting_requests + .add(-1, &[KeyValue::new("mode", self.mode)]); + } +} + +struct ProviderInFlightGuard { + _permit: OwnedSemaphorePermit, + mode: &'static str, +} + +impl ProviderInFlightGuard { + fn new(permit: OwnedSemaphorePermit, mode: BgfilterBackgroundMode) -> Self { + let mode = mode.as_str(); + bgfilter_metrics() + .in_flight + .add(1, &[KeyValue::new("mode", mode)]); + Self { + _permit: permit, + mode, + } + } +} + +impl Drop for ProviderInFlightGuard { + fn drop(&mut self) { + bgfilter_metrics() + .in_flight + .add(-1, &[KeyValue::new("mode", self.mode)]); + } +} + +async fn execute_logical_request( + runtime: BgfilterWorkerRuntime, + request: BgfilterInternalRequest, + deadline: Instant, +) -> WorkerOutcome { + if request.background_mode.uses_flat_circuit() { + if let Some(remaining) = flat_circuit_open_remaining(&runtime.app_state) { + return WorkerOutcome { + result: Err(WorkerFailure::new( + "circuit_open", + format!( + "BgFilter flat 熔断仍有 {}ms", + remaining.as_millis().min(u128::from(u64::MAX)) + ), + true, + )), + provider_permit: None, + }; + } + } + + let waiting_metric = WaitingRequestMetricGuard::new(request.background_mode); + let provider_acquire = timeout_at( + tokio::time::Instant::from_std(deadline), + runtime.provider.clone().acquire_owned(), + ) + .await; + drop(waiting_metric); + let provider_permit = match provider_acquire { + Ok(Ok(permit)) => ProviderInFlightGuard::new(permit, request.background_mode), + Ok(Err(_)) => { + return WorkerOutcome { + result: Err(WorkerFailure::new( + "internal_error", + "BgFilter provider 并发控制器已关闭", + true, + )), + provider_permit: None, + }; + } + Err(_) => { + return WorkerOutcome { + result: Err( + WorkerFailure::deadline("等待 BgFilter provider 并发槽位超时") + .with_phase("queue"), + ), + provider_permit: None, + }; + } + }; + + // 中文注释:排队期间熔断可能刚被其它请求打开。必须在取得 provider permit 后、 + // 第一次真实 HTTP 前再检查一次;本调用一旦通过该检查,自己的第二次 attempt 不再重查。 + if request.background_mode.uses_flat_circuit() { + if let Some(remaining) = flat_circuit_open_remaining(&runtime.app_state) { + return WorkerOutcome { + result: Err(WorkerFailure::new( + "circuit_open", + format!( + "BgFilter flat 熔断仍有 {}ms", + remaining.as_millis().min(u128::from(u64::MAX)) + ), + true, + )), + provider_permit: None, + }; + } + } + + let attempt_state = &runtime.app_state; + let attempt_request = &request; + let attempt_tracker = &runtime.task_tracker; + let attempts = run_sequential_attempts( + BGFILTER_PROVIDER_MAX_ATTEMPTS, + |attempt| async move { + // 中文注释:签名 URL 在每次 attempt 前重新生成,避免首试耗时后让二试沿用临近过期 URL。 + let source_url = + sign_source_url(attempt_state, attempt_request.source_object_key.as_str()) + .map_err(|error| { + SequentialAttemptFailure::not_started(ProviderAttemptError::internal( + error.message, + )) + })?; + // 中文注释:签名耗时属于同一 requestBudget;必须在签名后重新读取剩余时间, + // 不能让签名前算出的 timeout 把 provider HTTP 推过 RPC deadline。 + let attempt_budget = + provider_attempt_budget(attempt_state, deadline).ok_or_else(|| { + SequentialAttemptFailure::not_started(ProviderAttemptError::deadline( + "BgFilter 请求预算不足,未启动新的 provider attempt", + )) + })?; + request_provider_once( + attempt_state, + attempt_request, + source_url.as_str(), + attempt_budget, + attempt, + deadline, + attempt_tracker, + ) + .await + .map_err(SequentialAttemptFailure::started) + }, + ProviderAttemptError::should_retry, + |attempt, error| { + tracing::warn!( + request_id = %request.request_id, + background_mode = request.background_mode.as_str(), + attempt, + max_attempts = BGFILTER_PROVIDER_MAX_ATTEMPTS, + timeout = error.timeout, + transport = error.transport, + error = %error.message, + "bgfilter_worker_provider_attempt_failed" + ); + audit_provider_attempt_failure( + runtime.app_state.clone(), + request.clone(), + attempt, + error.clone(), + ); + if request.background_mode.uses_flat_circuit() && error.counts_for_circuit { + record_flat_failure(&runtime.app_state); + } + }, + ) + .await; + + match attempts { + SequentialAttemptOutcome::Success { value, .. } => { + if request.background_mode.uses_flat_circuit() { + record_flat_success(); + } + WorkerOutcome { + result: Ok(value), + provider_permit: Some(provider_permit), + } + } + SequentialAttemptOutcome::Failure { + error, + attempts_started, + } => WorkerOutcome { + result: Err(error.into_worker_failure(attempts_started)), + provider_permit: Some(provider_permit), + }, + } +} + +struct SequentialAttemptFailure { + error: E, + attempt_started: bool, +} + +impl SequentialAttemptFailure { + fn started(error: E) -> Self { + Self { + error, + attempt_started: true, + } + } + + fn not_started(error: E) -> Self { + Self { + error, + attempt_started: false, + } + } +} + +enum SequentialAttemptOutcome { + Success { value: T }, + Failure { error: E, attempts_started: usize }, +} + +async fn run_sequential_attempts( + max_attempts: usize, + mut run: Run, + mut should_retry: ShouldRetry, + mut on_failure: OnFailure, +) -> SequentialAttemptOutcome +where + Run: FnMut(usize) -> RunFuture, + RunFuture: std::future::Future>>, + ShouldRetry: FnMut(&E) -> bool, + OnFailure: FnMut(usize, &E), +{ + assert!(max_attempts > 0, "provider max attempts must be positive"); + let mut attempts_started = 0usize; + for attempt in 1..=max_attempts { + match run(attempt).await { + Ok(value) => { + return SequentialAttemptOutcome::Success { value }; + } + Err(failure) => { + if failure.attempt_started { + attempts_started = attempts_started.saturating_add(1); + } + let retry = attempt < max_attempts && should_retry(&failure.error); + on_failure(attempt, &failure.error); + if !retry { + return SequentialAttemptOutcome::Failure { + error: failure.error, + attempts_started, + }; + } + } + } + } + unreachable!("positive sequential attempt loop always returns") +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct ProviderAttemptBudget { + timeout: Duration, + budget_limited: bool, +} + +struct ProviderAttemptMetricGuard { + mode: &'static str, + attempt: usize, + started_at: Instant, + outcome: &'static str, +} + +impl ProviderAttemptMetricGuard { + fn new(mode: BgfilterBackgroundMode, attempt: usize) -> Self { + Self { + mode: mode.as_str(), + attempt, + started_at: Instant::now(), + outcome: "internal_error", + } + } + + fn outcome(&mut self, outcome: &'static str) { + self.outcome = outcome; + } +} + +impl Drop for ProviderAttemptMetricGuard { + fn drop(&mut self) { + bgfilter_metrics().provider_http_seconds.record( + self.started_at.elapsed().as_secs_f64(), + &[ + KeyValue::new("mode", self.mode), + KeyValue::new("attempt", self.attempt as i64), + KeyValue::new("outcome", self.outcome), + ], + ); + } +} + +fn provider_attempt_budget(state: &AppState, deadline: Instant) -> Option { + provider_attempt_budget_at( + Instant::now(), + deadline, + Duration::from_millis(state.config.editor_bgfilter_request_timeout_ms.max(1)), + ) +} + +fn provider_attempt_budget_at( + now: Instant, + deadline: Instant, + provider_timeout: Duration, +) -> Option { + let remaining = deadline.checked_duration_since(now)?; + let available = remaining.checked_sub(BGFILTER_PROVIDER_ATTEMPT_RESERVE)?; + if available.is_zero() { + return None; + } + let provider_timeout = provider_timeout.max(Duration::from_millis(1)); + Some(ProviderAttemptBudget { + timeout: available.min(provider_timeout), + budget_limited: available < provider_timeout, + }) +} + +fn sign_source_url(state: &AppState, object_key: &str) -> Result { + let client = state + .oss_client() + .ok_or_else(|| WorkerFailure::new("internal_error", "OSS 读取配置不可用", true))?; + client + .sign_get_object_url(OssSignedGetObjectUrlRequest { + object_key: object_key.to_string(), + expire_seconds: Some(BGFILTER_SOURCE_URL_EXPIRE_SECONDS), + }) + .map(|signed| signed.signed_url) + .map_err(|error| { + WorkerFailure::new( + "internal_error", + format!("签发源图 OSS 读取地址失败:{error}"), + true, + ) + }) +} + +async fn request_provider_once( + state: &AppState, + request: &BgfilterInternalRequest, + source_url: &str, + attempt_budget: ProviderAttemptBudget, + attempt: usize, + deadline: Instant, + task_tracker: &BgfilterTaskTracker, +) -> Result { + let mut metric = ProviderAttemptMetricGuard::new(request.background_mode, attempt); + let endpoint = match provider_endpoint(state.config.editor_bgfilter_base_url.as_str()) { + Ok(endpoint) => endpoint, + Err(error) => { + metric.outcome(error.metric_outcome()); + return Err(error); + } + }; + let started_at = Instant::now(); + let response_deadline = + provider_response_deadline_at(started_at, attempt_budget.timeout, deadline); + let mut form = reqwest::multipart::Form::new() + .text("image_url", source_url.to_string()) + .text("seg_model", request.seg_model.clone()) + .text("background_mode", request.background_mode.as_str()) + .text( + "cross_check", + if request.cross_check { "on" } else { "off" }, + ); + if let Some(screen_color) = request.screen_color.as_ref() { + form = form.text("screen_color", screen_color.clone()); + } + let mut outbound = state + .bgfilter_provider_http_client() + .post(endpoint.as_str()) + .timeout(attempt_budget.timeout) + .multipart(form); + if let Some(token) = state + .config + .editor_bgfilter_token + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + outbound = outbound.header(BGFILTER_PROVIDER_TOKEN_HEADER, token); + } + let response = match outbound.send().await { + Ok(response) => response, + Err(error) => { + let error = ProviderAttemptError::transport( + format!("请求 BgFilter provider 失败:{error}"), + error.is_timeout(), + started_at.elapsed(), + attempt_budget.budget_limited, + ); + metric.outcome(error.metric_outcome()); + return Err(error); + } + }; + let status = response.status(); + if !status.is_success() { + drain_provider_error_body(response).await; + let error = ProviderAttemptError::upstream( + format!("BgFilter provider 返回非成功状态 {status}"), + status.as_u16(), + None, + started_at.elapsed(), + ); + metric.outcome(error.metric_outcome()); + return Err(error); + } + let declared_mime = match response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .and_then(normalize_image_mime) + .map(ToOwned::to_owned) + { + Some(mime) => mime, + None => { + let error = ProviderAttemptError::invalid_result( + "BgFilter provider 成功响应缺少受支持的 Content-Type", + started_at.elapsed(), + ); + metric.outcome(error.metric_outcome()); + return Err(error); + } + }; + let validation_permit = match timeout_at( + tokio::time::Instant::from_std(response_deadline), + state.bgfilter_image_validation_limiter().acquire_owned(), + ) + .await + { + Ok(Ok(permit)) => permit, + Ok(Err(_)) => { + let error = ProviderAttemptError::internal("BgFilter worker 图片校验并发控制器已关闭"); + metric.outcome(error.metric_outcome()); + return Err(error); + } + Err(_) => { + let error = ProviderAttemptError::response_deadline( + "等待 BgFilter worker 图片校验槽位超时", + started_at.elapsed(), + ); + metric.outcome(error.metric_outcome()); + return Err(error); + } + }; + let bytes = match timeout_at( + tokio::time::Instant::from_std(response_deadline), + read_provider_success_body(response, started_at, attempt_budget.budget_limited), + ) + .await + { + Ok(Ok(bytes)) => bytes, + Ok(Err(error)) => { + metric.outcome(error.metric_outcome()); + return Err(error); + } + Err(_) => { + let error = ProviderAttemptError::transport( + "读取 BgFilter provider 成功响应体超时".to_string(), + true, + started_at.elapsed(), + attempt_budget.budget_limited, + ); + metric.outcome(error.metric_outcome()); + return Err(error); + } + }; + let validation_guard = task_tracker.track_started(); + let mut validation = tokio::task::spawn_blocking(move || { + let _validation_permit = validation_permit; + let _validation_guard = validation_guard; + validate_image_result(bytes, declared_mime.as_str()) + }); + let validation = match timeout_at( + tokio::time::Instant::from_std(response_deadline), + &mut validation, + ) + .await + { + Ok(validation) => validation, + Err(_) => { + // spawn_blocking 无法安全取消;JoinHandle drop 后任务继续持有校验槽和 tracker guard, + // handler 则在 RPC deadline 内先返回类型化 response-phase timeout。 + let error = ProviderAttemptError::response_deadline( + "BgFilter 成功响应的图片校验超过内部请求预算", + started_at.elapsed(), + ); + metric.outcome(error.metric_outcome()); + return Err(error); + } + }; + match validation { + Err(error) => { + let error = + ProviderAttemptError::internal(format!("BgFilter 图片校验任务异常:{error}")); + metric.outcome(error.metric_outcome()); + Err(error) + } + Ok(Ok(image)) => { + metric.outcome("success"); + Ok(image) + } + Ok(Err(message)) => { + let error = ProviderAttemptError::invalid_result(message, started_at.elapsed()); + metric.outcome(error.metric_outcome()); + Err(error) + } + } +} + +fn provider_response_deadline_at( + started_at: Instant, + attempt_timeout: Duration, + rpc_deadline: Instant, +) -> Instant { + started_at + .checked_add(attempt_timeout) + .unwrap_or(rpc_deadline) + .min(rpc_deadline) +} + +fn validate_provider_base_url(base_url: &str) -> Result<(), String> { + let base_url = base_url.trim(); + let parsed = url::Url::parse(base_url) + .map_err(|error| format!("GENARRATIVE_EDITOR_BGFILTER_BASE_URL 无效:{error}"))?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err("GENARRATIVE_EDITOR_BGFILTER_BASE_URL 只允许 http(s)".to_string()); + } + if raw_url_authority_has_userinfo(base_url) || url_has_credentials_query_or_fragment(&parsed) { + return Err( + "GENARRATIVE_EDITOR_BGFILTER_BASE_URL 不得包含 userinfo、query 或 fragment".to_string(), + ); + } + Ok(()) +} + +fn raw_url_authority_has_userinfo(value: &str) -> bool { + value + .split_once("://") + .and_then(|(_, remainder)| { + remainder + .split(|character| matches!(character, '/' | '?' | '#')) + .next() + }) + .is_some_and(|authority| authority.contains('@')) +} + +fn url_has_credentials_query_or_fragment(parsed: &url::Url) -> bool { + parsed + .as_str() + .split_once("://") + .and_then(|(_, remainder)| remainder.split('/').next()) + .is_some_and(|authority| authority.contains('@')) + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() +} + +fn provider_endpoint(base_url: &str) -> Result { + validate_provider_base_url(base_url).map_err(ProviderAttemptError::internal)?; + Ok(format!( + "{}/remove-background", + base_url.trim().trim_end_matches('/') + )) +} + +async fn drain_provider_error_body(response: reqwest::Response) { + // Provider 错误体可能回显 multipart 中的签名 URL 或鉴权信息。这里只做有界排空以复用连接, + // 审计保留结构化状态与耗时,不持久化任何 provider 原始响应片段。 + let _ = read_bounded_response(response, BGFILTER_INTERNAL_ERROR_BODY_MAX_BYTES).await; +} + +async fn read_provider_success_body( + response: reqwest::Response, + started_at: Instant, + budget_limited: bool, +) -> Result, ProviderAttemptError> { + read_bounded_response(response, BGFILTER_MAX_RESPONSE_BYTES) + .await + .map_err(|error| match error { + BoundedReadError::TooLarge => ProviderAttemptError::invalid_result( + "BgFilter provider 返回图片超过 32 MiB", + started_at.elapsed(), + ), + BoundedReadError::Transport { message, timeout } => ProviderAttemptError::transport( + message, + timeout, + started_at.elapsed(), + budget_limited, + ), + }) +} + +#[derive(Debug)] +enum BoundedReadError { + TooLarge, + Transport { message: String, timeout: bool }, +} + +async fn read_bounded_response( + mut response: reqwest::Response, + max_bytes: usize, +) -> Result, BoundedReadError> { + if response + .content_length() + .is_some_and(|length| length > max_bytes as u64) + { + return Err(BoundedReadError::TooLarge); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|error| BoundedReadError::Transport { + message: format!("读取 HTTP 响应体失败:{error}"), + timeout: error.is_timeout(), + })? + { + if bytes.len().saturating_add(chunk.len()) > max_bytes { + return Err(BoundedReadError::TooLarge); + } + bytes.extend_from_slice(chunk.as_ref()); + } + Ok(bytes) +} + +fn validate_image_result(bytes: Vec, declared_mime: &str) -> Result { + if bytes.is_empty() { + return Err("BgFilter 返回空图片".to_string()); + } + if bytes.len() > BGFILTER_MAX_RESPONSE_BYTES { + return Err("BgFilter 返回图片超过 32 MiB".to_string()); + } + let format = image::guess_format(bytes.as_slice()) + .map_err(|error| format!("BgFilter 返回内容不是受支持图片:{error}"))?; + let actual_mime = match format { + image::ImageFormat::Png => "image/png", + image::ImageFormat::Jpeg => "image/jpeg", + image::ImageFormat::WebP => "image/webp", + _ => return Err("BgFilter 返回了不支持的图片格式".to_string()), + }; + if actual_mime != declared_mime { + return Err("BgFilter 图片 Content-Type 与文件魔数不一致".to_string()); + } + let mut reader = image::ImageReader::new(Cursor::new(bytes.as_slice())); + reader.set_format(format); + let mut limits = image::Limits::default(); + limits.max_image_width = Some(BGFILTER_MAX_IMAGE_DIMENSION); + limits.max_image_height = Some(BGFILTER_MAX_IMAGE_DIMENSION); + limits.max_alloc = Some(BGFILTER_MAX_RESPONSE_BYTES as u64 * 4); + reader.limits(limits); + let decoded = reader + .decode() + .map_err(|error| format!("BgFilter 返回图片解码失败:{error}"))?; + if decoded.width() > BGFILTER_MAX_IMAGE_DIMENSION + || decoded.height() > BGFILTER_MAX_IMAGE_DIMENSION + { + return Err(format!( + "BgFilter 返回图片尺寸超过 {BGFILTER_MAX_IMAGE_DIMENSION}×{BGFILTER_MAX_IMAGE_DIMENSION}" + )); + } + Ok(BgfilterImage { + bytes, + mime_type: actual_mime.to_string(), + extension: image_extension(actual_mime).to_string(), + width: decoded.width(), + height: decoded.height(), + }) +} + +fn normalize_image_mime(value: &str) -> Option<&'static str> { + let mime = value.split(';').next()?.trim(); + if mime.eq_ignore_ascii_case("image/png") { + Some("image/png") + } else if mime.eq_ignore_ascii_case("image/jpeg") { + Some("image/jpeg") + } else if mime.eq_ignore_ascii_case("image/webp") { + Some("image/webp") + } else { + None + } +} + +fn image_extension(mime: &str) -> &'static str { + match mime { + "image/jpeg" => "jpg", + "image/webp" => "webp", + _ => "png", + } +} + +#[derive(Clone, Debug)] +struct ProviderAttemptError { + message: String, + status_code: Option, + timeout: bool, + transport: bool, + latency_ms: Option, + raw_excerpt: Option, + invalid_result: bool, + budget_exhausted: bool, + counts_for_circuit: bool, + phase: &'static str, +} + +impl ProviderAttemptError { + fn transport(message: String, timeout: bool, elapsed: Duration, budget_limited: bool) -> Self { + let budget_exhausted = timeout && budget_limited; + Self { + message, + status_code: None, + timeout, + transport: true, + latency_ms: Some(duration_millis(elapsed)), + raw_excerpt: None, + invalid_result: false, + budget_exhausted, + // 中文注释:只有被父业务剩余预算截短的 timeout 不算 provider 故障; + // 拿满配置 attempt 窗口后发生的 timeout 必须进入失败审计和 flat 熔断计数。 + counts_for_circuit: !budget_exhausted, + phase: "provider", + } + } + + fn upstream( + message: String, + status_code: u16, + raw_excerpt: Option, + elapsed: Duration, + ) -> Self { + Self { + message, + status_code: Some(status_code), + timeout: false, + transport: false, + latency_ms: Some(duration_millis(elapsed)), + raw_excerpt, + invalid_result: false, + budget_exhausted: false, + counts_for_circuit: true, + phase: "provider", + } + } + + fn invalid_result(message: impl Into, elapsed: Duration) -> Self { + Self { + message: message.into(), + status_code: None, + timeout: false, + transport: false, + latency_ms: Some(duration_millis(elapsed)), + raw_excerpt: None, + invalid_result: true, + budget_exhausted: false, + counts_for_circuit: true, + phase: "provider", + } + } + + fn deadline(message: impl Into) -> Self { + Self { + message: message.into(), + status_code: None, + timeout: true, + transport: false, + latency_ms: None, + raw_excerpt: None, + invalid_result: false, + budget_exhausted: true, + counts_for_circuit: false, + phase: "provider", + } + } + + fn response_deadline(message: impl Into, elapsed: Duration) -> Self { + Self { + message: message.into(), + status_code: None, + timeout: true, + transport: false, + latency_ms: Some(duration_millis(elapsed)), + raw_excerpt: None, + invalid_result: false, + budget_exhausted: true, + counts_for_circuit: false, + phase: "response", + } + } + + fn internal(message: impl Into) -> Self { + Self { + message: message.into(), + status_code: None, + timeout: false, + transport: false, + latency_ms: None, + raw_excerpt: None, + invalid_result: false, + budget_exhausted: false, + counts_for_circuit: false, + phase: "provider", + } + } + + fn should_retry(&self) -> bool { + !self.budget_exhausted + && (self.counts_for_circuit + || self.transport + || self.status_code.is_some() + || self.invalid_result) + } + + fn metric_outcome(&self) -> &'static str { + if self.budget_exhausted { + "deadline_exceeded" + } else if self.invalid_result { + "invalid_result" + } else if self.timeout { + "timeout" + } else if self.transport { + "transport_error" + } else if self.status_code.is_some() { + "http_error" + } else { + "internal_error" + } + } + + fn into_worker_failure(self, attempts_started: usize) -> WorkerFailure { + let failure = if self.budget_exhausted { + WorkerFailure::deadline(self.message) + } else if self.invalid_result { + WorkerFailure::new("invalid_result", self.message, true) + } else if !self.counts_for_circuit && !self.transport && self.status_code.is_none() { + WorkerFailure::new("internal_error", self.message, true) + } else { + WorkerFailure::new("provider_exhausted", self.message, true) + }; + failure + .with_phase(self.phase) + .with_attempts_started(attempts_started) + } +} + +fn audit_provider_attempt_failure( + state: AppState, + request: BgfilterInternalRequest, + attempt: usize, + error: ProviderAttemptError, +) { + if error.budget_exhausted || !error.counts_for_circuit && error.status_code.is_none() { + return; + } + tokio::spawn(async move { + let audit = request + .audit_context + .unwrap_or(BgfilterInternalAuditContext { + user_id: None, + profile_id: None, + request_id: Some(request.request_id.clone()), + }); + let context = ExternalApiAuditContext { + user_id: audit.user_id, + profile_id: audit.profile_id, + request_id: audit.request_id, + external_call_deadline: None, + }; + crate::external_api_audit::record_matting_external_api_failure( + &state, + &context, + "bgfilter", + state.config.editor_bgfilter_base_url.clone(), + if request.background_mode == BgfilterBackgroundMode::Flat { + "editor-screen-background-removal" + } else { + "editor-background-removal" + }, + if attempt == 1 { + "bgfilter_attempt_1" + } else { + "bgfilter_attempt_2" + }, + error.status_code, + error.timeout, + error.transport, + error.latency_ms, + error.message, + error.raw_excerpt, + ) + .await; + }); +} + +#[derive(Clone, Copy, Debug, Default)] +struct BgfilterCircuitState { + consecutive_failures: u32, + open_until: Option, +} + +fn flat_circuit_state() -> &'static Mutex { + BGFILTER_FLAT_CIRCUIT.get_or_init(|| Mutex::new(BgfilterCircuitState::default())) +} + +fn flat_circuit_open_remaining(state: &AppState) -> Option { + if state.config.editor_bgfilter_circuit_failure_threshold == 0 { + return None; + } + let mut circuit = flat_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 = BgfilterCircuitState::default(); + None +} + +fn record_flat_success() { + if let Ok(mut circuit) = flat_circuit_state().lock() { + *circuit = BgfilterCircuitState::default(); + } +} + +fn record_flat_failure(state: &AppState) { + let threshold = state.config.editor_bgfilter_circuit_failure_threshold; + if threshold == 0 { + return; + } + if let Ok(mut circuit) = flat_circuit_state().lock() { + circuit.consecutive_failures = circuit.consecutive_failures.saturating_add(1); + if circuit.consecutive_failures >= threshold { + circuit.open_until = + Some(Instant::now() + state.config.editor_bgfilter_circuit_cooldown); + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct WorkerErrorEnvelope { + error: WorkerErrorBody, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct WorkerErrorBody { + code: String, + #[serde(skip_serializing_if = "Option::is_none")] + phase: Option, + attempts_started: usize, + message: String, + retryable: bool, +} + +#[derive(Clone, Debug)] +struct WorkerFailure { + code: &'static str, + message: String, + retryable: bool, + phase: Option<&'static str>, + attempts_started: usize, +} + +impl WorkerFailure { + fn new(code: &'static str, message: impl Into, retryable: bool) -> Self { + Self { + code, + message: message.into(), + retryable, + phase: None, + attempts_started: 0, + } + } + + fn deadline(message: impl Into) -> Self { + Self::new("deadline_exceeded", message, true) + } + + fn with_phase(mut self, phase: &'static str) -> Self { + debug_assert!(matches!(phase, "queue" | "provider" | "response")); + self.phase = Some(phase); + self + } + + fn with_attempts_started(mut self, attempts_started: usize) -> Self { + self.attempts_started = attempts_started.min(BGFILTER_PROVIDER_MAX_ATTEMPTS); + self + } + + fn status_code(&self) -> StatusCode { + match self.code { + "invalid_request" => StatusCode::BAD_REQUEST, + "unauthorized" => StatusCode::UNAUTHORIZED, + "overloaded" => StatusCode::TOO_MANY_REQUESTS, + "deadline_exceeded" => StatusCode::GATEWAY_TIMEOUT, + "cancelled" | "circuit_open" => StatusCode::SERVICE_UNAVAILABLE, + "provider_exhausted" | "invalid_result" => StatusCode::BAD_GATEWAY, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + +fn worker_error_response( + status: StatusCode, + error: WorkerFailure, + request_id: Option<&str>, +) -> Response { + let mut response = ( + status, + Json(WorkerErrorEnvelope { + error: WorkerErrorBody { + code: error.code.to_string(), + phase: error.phase.map(ToOwned::to_owned), + attempts_started: error.attempts_started, + message: error.message, + retryable: error.retryable, + }, + }), + ) + .into_response(); + if let Some(request_id) = request_id.and_then(|value| HeaderValue::from_str(value).ok()) { + response.headers_mut().insert("x-request-id", request_id); + } + response +} + +fn worker_image_response(image: BgfilterImage, request_id: &str) -> Response { + let mime = HeaderValue::from_str(image.mime_type.as_str()) + .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")); + let mut response = Response::new(Body::from(image.bytes)); + *response.status_mut() = StatusCode::OK; + response.headers_mut().insert(CONTENT_TYPE, mime); + if let Ok(value) = HeaderValue::from_str(request_id) { + response.headers_mut().insert("x-request-id", value); + } + response +} + +pub(crate) async fn request_bgfilter_worker( + state: &AppState, + source_object_key: &str, + background_mode: BgfilterBackgroundMode, + screen_color: Option<&str>, + seg_model: &str, + cross_check: bool, + request_budget_ms: u64, + audit: &ExternalApiAuditContext, +) -> Result { + if request_budget_ms == 0 { + return Err(BgfilterClientError::local( + "deadline_exceeded", + "父流程没有剩余 BgFilter 请求预算", + )); + } + let token = state + .config + .bgfilter_internal_token + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + BgfilterClientError::local("unauthorized", "BgFilter 内部调用 Token 未配置") + })?; + let endpoint = internal_worker_endpoint(state.config.bgfilter_worker_base_url.as_str())?; + let request_id = Uuid::new_v4().to_string(); + let payload = BgfilterInternalRequest { + request_id: request_id.clone(), + source_object_key: source_object_key.to_string(), + background_mode, + screen_color: screen_color.map(ToOwned::to_owned), + seg_model: seg_model.to_string(), + cross_check, + request_budget_ms, + audit_context: Some(BgfilterInternalAuditContext { + user_id: audit.user_id.clone(), + profile_id: audit.profile_id.clone(), + request_id: audit.request_id.clone(), + }), + }; + let client_timeout = internal_client_timeout(request_budget_ms, audit.external_call_deadline)?; + let started_at = Instant::now(); + let client_deadline = started_at.checked_add(client_timeout).unwrap_or(started_at); + let response = state + .bgfilter_worker_http_client() + .post(endpoint) + .bearer_auth(token) + .header("x-request-id", request_id.as_str()) + .timeout(client_timeout) + .json(&payload) + .send() + .await + .map_err(|error| BgfilterClientError { + code: if error.is_timeout() { + "deadline_exceeded" + } else { + "internal_error" + }, + message: format!("请求 BgFilter 内部 worker 失败:{error}"), + status: StatusCode::BAD_GATEWAY, + timeout: error.is_timeout(), + transport: true, + })?; + let status = response.status(); + if !status.is_success() { + return read_worker_error(response, status).await; + } + let mime_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .and_then(normalize_image_mime) + .ok_or_else(|| { + BgfilterClientError::local( + "invalid_result", + "BgFilter worker 成功响应缺少受支持的 Content-Type", + ) + })?; + // 在读取完整成功 body 前先取得父侧有界解码槽。这样动画的其它响应会停在 + // loopback backpressure 上,子 worker 继续持有 N/Q,而不是在父进程累计 48 份大图。 + let validation_permit = acquire_parent_validation_permit( + state.bgfilter_image_validation_limiter(), + client_deadline, + ) + .await?; + let bytes = match timeout_at( + tokio::time::Instant::from_std(client_deadline), + read_bounded_response(response, BGFILTER_MAX_RESPONSE_BYTES), + ) + .await + { + Ok(Ok(bytes)) => bytes, + Ok(Err(BoundedReadError::TooLarge)) => { + return Err(BgfilterClientError::local( + "invalid_result", + "BgFilter worker 返回图片超过 32 MiB", + )); + } + Ok(Err(BoundedReadError::Transport { message, timeout })) => { + return Err(BgfilterClientError { + code: if timeout { + "deadline_exceeded" + } else { + "internal_error" + }, + message, + status: StatusCode::BAD_GATEWAY, + timeout, + transport: true, + }); + } + Err(_) => { + return Err(BgfilterClientError::local( + "deadline_exceeded", + "读取 BgFilter worker 成功响应体超时", + )); + } + }; + if Instant::now() >= client_deadline { + return Err(BgfilterClientError::local( + "deadline_exceeded", + "父流程外部调用预算在校验 BgFilter worker 响应前已耗尽", + )); + } + let mut validation = tokio::task::spawn_blocking(move || { + let _validation_permit = validation_permit; + validate_image_result(bytes, mime_type) + }); + let validation = match timeout_at( + tokio::time::Instant::from_std(client_deadline), + &mut validation, + ) + .await + { + Ok(validation) => validation, + Err(_) => { + return Err(BgfilterClientError::local( + "deadline_exceeded", + "父流程外部调用预算在校验 BgFilter worker 响应时已耗尽", + )); + } + } + .map_err(|error| { + BgfilterClientError::local( + "internal_error", + format!("BgFilter worker 图片校验任务异常:{error}"), + ) + })?; + if Instant::now() >= client_deadline { + return Err(BgfilterClientError::local( + "deadline_exceeded", + "父流程外部调用预算在校验 BgFilter worker 响应时已耗尽", + )); + } + validation + .map_err(|message| BgfilterClientError { + code: "invalid_result", + message, + status: StatusCode::BAD_GATEWAY, + timeout: false, + transport: false, + }) + .map(|image| { + tracing::info!( + request_id = %request_id, + background_mode = background_mode.as_str(), + elapsed_ms = duration_millis(started_at.elapsed()), + width = image.width, + height = image.height, + response_bytes = image.bytes.len(), + "bgfilter_internal_request_done" + ); + image + }) +} + +async fn acquire_parent_validation_permit( + limiter: Arc, + client_deadline: Instant, +) -> Result { + let acquire = limiter.acquire_owned(); + match timeout_at(tokio::time::Instant::from_std(client_deadline), acquire).await { + Ok(Ok(permit)) => Ok(permit), + Ok(Err(_)) => Err(BgfilterClientError::local( + "internal_error", + "父侧 BgFilter 图片校验并发控制器已关闭", + )), + Err(_) => Err(BgfilterClientError::local( + "deadline_exceeded", + "等待父侧 BgFilter 图片校验槽位超时", + )), + } +} + +fn internal_worker_endpoint(base_url: &str) -> Result { + let base_url = base_url.trim(); + let parsed = url::Url::parse(base_url).map_err(|error| { + BgfilterClientError::local( + "internal_error", + format!("GENARRATIVE_BGFILTER_WORKER_BASE_URL 无效:{error}"), + ) + })?; + if parsed.scheme() != "http" { + return Err(BgfilterClientError::local( + "internal_error", + "BgFilter 内部 worker 首版只允许 loopback HTTP", + )); + } + if raw_url_authority_has_userinfo(base_url) || url_has_credentials_query_or_fragment(&parsed) { + return Err(BgfilterClientError::local( + "internal_error", + "BgFilter 内部 worker 地址不得包含 userinfo、query 或 fragment", + )); + } + if parsed.path() != "/" { + return Err(BgfilterClientError::local( + "internal_error", + "BgFilter 内部 worker base URL 不得包含路径前缀", + )); + } + let loopback_host = parsed.host_str().is_some_and(|host| { + host.eq_ignore_ascii_case("localhost") + || host + .parse::() + .is_ok_and(|address| address.is_loopback()) + }); + if !loopback_host { + return Err(BgfilterClientError::local( + "internal_error", + "BgFilter 内部 worker 地址必须指向 loopback", + )); + } + Ok(format!( + "{}{}", + base_url.trim().trim_end_matches('/'), + BGFILTER_INTERNAL_REMOVE_BACKGROUND_PATH + )) +} + +fn internal_client_timeout( + request_budget_ms: u64, + absolute_deadline: Option, +) -> Result { + let desired = Duration::from_millis(request_budget_ms) + .saturating_add(BGFILTER_INTERNAL_CLIENT_RESPONSE_RESERVE); + let Some(deadline) = absolute_deadline else { + return Ok(desired); + }; + let remaining = deadline + .checked_duration_since(Instant::now()) + .ok_or_else(|| { + BgfilterClientError::local("deadline_exceeded", "父流程外部调用预算已耗尽") + })?; + Ok(desired.min(remaining)) +} + +async fn read_worker_error( + response: reqwest::Response, + status: reqwest::StatusCode, +) -> Result { + let body = read_bounded_response(response, BGFILTER_INTERNAL_ERROR_BODY_MAX_BYTES) + .await + .map_err(|_| { + BgfilterClientError::local("internal_error", "读取 BgFilter worker 错误响应失败") + })?; + let parsed = serde_json::from_slice::(body.as_slice()) + .ok() + .map(|body| body.error); + let code = parsed + .as_ref() + .and_then(|body| stable_error_code(body.code.as_str())) + .unwrap_or_else(|| { + if status == reqwest::StatusCode::UNAUTHORIZED { + "unauthorized" + } else if status == reqwest::StatusCode::TOO_MANY_REQUESTS { + "overloaded" + } else { + "internal_error" + } + }); + Err(BgfilterClientError { + code, + message: parsed + .map(|body| body.message) + .unwrap_or_else(|| format!("BgFilter worker 返回非成功状态 {status}")), + status: StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY), + timeout: code == "deadline_exceeded", + transport: false, + }) +} + +fn stable_error_code(value: &str) -> Option<&'static str> { + match value { + "provider_exhausted" => Some("provider_exhausted"), + "circuit_open" => Some("circuit_open"), + "deadline_exceeded" => Some("deadline_exceeded"), + "overloaded" => Some("overloaded"), + "cancelled" => Some("cancelled"), + "invalid_request" => Some("invalid_request"), + "unauthorized" => Some("unauthorized"), + "invalid_result" => Some("invalid_result"), + "internal_error" => Some("internal_error"), + _ => None, + } +} + +pub(crate) fn request_budget_ms( + configured_timeout_ms: u64, + absolute_deadline: Option, + reserve: Duration, +) -> u64 { + let configured = Duration::from_millis( + configured_timeout_ms + .max(1) + .min(BGFILTER_MAX_REQUEST_BUDGET_MS), + ); + let available = absolute_deadline + .and_then(|deadline| deadline.checked_duration_since(Instant::now())) + .and_then(|remaining| remaining.checked_sub(reserve)) + .unwrap_or_else(|| { + if absolute_deadline.is_some() { + Duration::ZERO + } else { + configured + } + }); + duration_millis(configured.min(available)) +} + +fn duration_millis(duration: Duration) -> u64 { + duration.as_millis().min(u128::from(u64::MAX)) as u64 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::AppConfig; + use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; + use tokio::sync::Barrier; + use tower::ServiceExt as _; + + fn encoded_png(width: u32, height: u32) -> Vec { + let image = image::DynamicImage::new_rgba8(width, height); + let mut output = Cursor::new(Vec::new()); + image + .write_to(&mut output, image::ImageFormat::Png) + .expect("test png should encode"); + output.into_inner() + } + + fn test_runtime(admission: usize, provider: usize) -> BgfilterWorkerRuntime { + BgfilterWorkerRuntime { + app_state: AppState::new(AppConfig::default()).expect("test state should build"), + admission: Arc::new(Semaphore::new(admission)), + provider: Arc::new(Semaphore::new(provider)), + internal_token: Arc::from("shared-token"), + task_tracker: BgfilterTaskTracker::new(), + } + } + + fn authorized_request(path: &str, token: &str) -> Request { + Request::builder() + .uri(path) + .header(AUTHORIZATION, format!("Bearer {token}")) + .body(Body::empty()) + .expect("request should build") + } + + #[test] + fn constant_time_token_comparison_checks_content_and_length() { + assert!(constant_time_eq(b"shared-token", b"shared-token")); + assert!(!constant_time_eq(b"shared-token", b"shared-tokee")); + assert!(!constant_time_eq(b"shared-token", b"shared-token-long")); + } + + #[test] + fn bearer_authorization_requires_one_matching_token() { + for value in ["Bearer shared-token", "bearer shared-token"] { + let mut headers = HeaderMap::new(); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(value).expect("valid header"), + ); + assert!(valid_internal_authorization(&headers, "shared-token")); + } + + for value in [ + "Basic shared-token", + "Bearer wrong-token", + "Bearer shared-token extra", + "shared-token", + ] { + let mut headers = HeaderMap::new(); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(value).expect("valid header"), + ); + assert!(!valid_internal_authorization(&headers, "shared-token")); + } + assert!(!valid_internal_authorization( + &HeaderMap::new(), + "shared-token" + )); + } + + #[tokio::test] + async fn middleware_authenticates_before_q_admission_and_sheds_when_full() { + let runtime = test_runtime(1, 1); + let app = Router::new() + .route("/work", get(|| async { StatusCode::NO_CONTENT })) + .layer(middleware::from_fn_with_state( + runtime.clone(), + authenticate_and_admit, + )); + + let unauthorized = app + .clone() + .oneshot( + Request::builder() + .uri("/work") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should complete"); + assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED); + assert_eq!(runtime.admission.available_permits(), 1); + + let held = runtime + .admission + .clone() + .try_acquire_owned() + .expect("test should hold admission permit"); + let overloaded = app + .clone() + .oneshot(authorized_request("/work", "shared-token")) + .await + .expect("request should complete"); + assert_eq!(overloaded.status(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!( + overloaded.headers().get(RETRY_AFTER), + Some(&HeaderValue::from_static("1")) + ); + drop(held); + + let accepted = app + .oneshot(authorized_request("/work", "shared-token")) + .await + .expect("request should complete"); + assert_eq!(accepted.status(), StatusCode::NO_CONTENT); + assert_eq!(runtime.admission.available_permits(), 0); + drop(accepted); + assert_eq!(runtime.admission.available_permits(), 1); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn provider_semaphore_caps_peak_at_n_for_48_waiters() { + const N: usize = 4; + const REQUESTS: usize = 48; + let runtime = test_runtime(REQUESTS, N); + let active = Arc::new(AtomicUsize::new(0)); + let peak = Arc::new(AtomicUsize::new(0)); + let batch_barrier = Arc::new(Barrier::new(N)); + let mut tasks = Vec::with_capacity(REQUESTS); + + for _ in 0..REQUESTS { + let provider = runtime.provider.clone(); + let active = active.clone(); + let peak = peak.clone(); + let batch_barrier = batch_barrier.clone(); + tasks.push(tokio::spawn(async move { + let permit = provider.acquire_owned().await.expect("provider semaphore"); + let current = active.fetch_add(1, AtomicOrdering::SeqCst) + 1; + peak.fetch_max(current, AtomicOrdering::SeqCst); + batch_barrier.wait().await; + active.fetch_sub(1, AtomicOrdering::SeqCst); + drop(permit); + })); + } + for task in tasks { + task.await.expect("waiter task should finish"); + } + + assert_eq!(peak.load(AtomicOrdering::SeqCst), N); + assert_eq!(active.load(AtomicOrdering::SeqCst), 0); + assert_eq!(runtime.provider.available_permits(), N); + } + + #[test] + fn internal_request_enforces_flat_and_complex_mode_contracts() { + let admission = AdmissionGuard { + _permit: Arc::new(Semaphore::new(1)) + .try_acquire_owned() + .expect("test permit"), + admitted_at: Instant::now(), + request_id: "request-1".to_string(), + }; + let mut request = BgfilterInternalRequest { + request_id: "request-1".to_string(), + source_object_key: "generated-character-drafts/editor/source.png".to_string(), + background_mode: BgfilterBackgroundMode::Flat, + screen_color: None, + seg_model: "birefnet".to_string(), + cross_check: true, + request_budget_ms: 1_000, + audit_context: None, + }; + assert!(validate_internal_request(&request, &admission).is_err()); + request.screen_color = Some("#CFEFFF".to_string()); + assert!(validate_internal_request(&request, &admission).is_ok()); + for malicious in [ + "/generated-character-drafts/editor/source.png", + "generated-character-drafts/../secret.png", + "generated-character-drafts/./source.png", + "generated-character-drafts//source.png", + "generated-character-drafts/editor\\source.png", + "generated-character-drafts/editor/source.png?download=1", + "generated-character-drafts/editor/source.png#fragment", + "generated-character-drafts/%2e%2e/secret.png", + ] { + request.source_object_key = malicious.to_string(); + assert!( + validate_internal_request(&request, &admission).is_err(), + "malicious object key should fail: {malicious}" + ); + } + request.source_object_key = "generated-character-drafts/editor/source.png".to_string(); + request.background_mode = BgfilterBackgroundMode::Complex; + assert!(validate_internal_request(&request, &admission).is_err()); + request.screen_color = None; + request.cross_check = false; + assert!(validate_internal_request(&request, &admission).is_ok()); + request.seg_model = "anime-seg".to_string(); + assert!(validate_internal_request(&request, &admission).is_err()); + request.seg_model = "birefnet".to_string(); + request.cross_check = true; + assert!(validate_internal_request(&request, &admission).is_err()); + } + + #[test] + fn provider_timeout_distinguishes_full_attempt_from_budget_truncation() { + let now = Instant::now(); + let provider_timeout = Duration::from_secs(180); + let full = + provider_attempt_budget_at(now, now + Duration::from_secs(181), provider_timeout) + .expect("full attempt budget"); + assert_eq!(full.timeout, provider_timeout); + assert!(!full.budget_limited); + + let truncated = + provider_attempt_budget_at(now, now + Duration::from_secs(11), provider_timeout) + .expect("truncated attempt budget"); + assert_eq!(truncated.timeout, Duration::from_secs(10)); + assert!(truncated.budget_limited); + assert!( + provider_attempt_budget_at( + now, + now + BGFILTER_PROVIDER_ATTEMPT_RESERVE, + provider_timeout, + ) + .is_none() + ); + + let provider_timeout_error = ProviderAttemptError::transport( + "provider timeout".to_string(), + true, + provider_timeout, + false, + ); + assert!(provider_timeout_error.counts_for_circuit); + assert!(!provider_timeout_error.budget_exhausted); + assert!(provider_timeout_error.should_retry()); + let provider_failure = provider_timeout_error.into_worker_failure(1); + assert_eq!(provider_failure.code, "provider_exhausted"); + assert_eq!(provider_failure.phase, Some("provider")); + assert_eq!(provider_failure.attempts_started, 1); + + let budget_timeout_error = ProviderAttemptError::transport( + "budget timeout".to_string(), + true, + Duration::from_secs(10), + true, + ); + assert!(!budget_timeout_error.counts_for_circuit); + assert!(budget_timeout_error.budget_exhausted); + assert!(!budget_timeout_error.should_retry()); + let budget_failure = budget_timeout_error.into_worker_failure(1); + assert_eq!(budget_failure.code, "deadline_exceeded"); + assert_eq!(budget_failure.phase, Some("provider")); + + let response_timeout_error = ProviderAttemptError::response_deadline( + "response validation timeout", + Duration::from_secs(1), + ); + assert!(!response_timeout_error.should_retry()); + let response_failure = response_timeout_error.into_worker_failure(1); + assert_eq!(response_failure.code, "deadline_exceeded"); + assert_eq!(response_failure.phase, Some("response")); + } + + #[test] + fn provider_response_deadline_uses_earlier_attempt_or_rpc_deadline() { + let now = Instant::now(); + assert_eq!( + provider_response_deadline_at( + now, + Duration::from_secs(5), + now + Duration::from_secs(10), + ), + now + Duration::from_secs(5), + ); + assert_eq!( + provider_response_deadline_at( + now, + Duration::from_secs(20), + now + Duration::from_secs(10), + ), + now + Duration::from_secs(10), + ); + } + + #[tokio::test] + async fn task_tracker_closes_admission_and_waits_for_detached_work() { + let tracker = BgfilterTaskTracker::new(); + let guard = tracker.register().expect("tracker should accept work"); + let child_guard = tracker.track_started(); + tracker.close(); + assert!(!tracker.is_accepting()); + assert!(tracker.register().is_none()); + + let waiter_tracker = tracker.clone(); + let waiter = tokio::spawn(async move { + waiter_tracker.wait_for_drain().await; + }); + tokio::task::yield_now().await; + assert!(!waiter.is_finished()); + drop(guard); + tokio::task::yield_now().await; + assert!(!waiter.is_finished()); + drop(child_guard); + waiter.await.expect("drain waiter should finish"); + } + + #[tokio::test] + async fn parent_validation_limiter_applies_backpressure_until_client_deadline() { + let limiter = Arc::new(Semaphore::new(1)); + let held = limiter + .clone() + .acquire_owned() + .await + .expect("first validation permit"); + let error = acquire_parent_validation_permit(limiter.clone(), Instant::now()) + .await + .expect_err("full limiter should time out at the client deadline"); + assert_eq!(error.code(), "deadline_exceeded"); + + drop(held); + let permit = acquire_parent_validation_permit( + limiter.clone(), + Instant::now() + Duration::from_secs(1), + ) + .await + .expect("released limiter should admit validation"); + assert_eq!(limiter.available_permits(), 0); + drop(permit); + assert_eq!(limiter.available_permits(), 1); + } + + #[test] + fn complex_mode_never_uses_flat_circuit_and_internal_errors_do_not_retry() { + assert!(BgfilterBackgroundMode::Flat.uses_flat_circuit()); + assert!(!BgfilterBackgroundMode::Complex.uses_flat_circuit()); + assert!(!ProviderAttemptError::internal("config error").should_retry()); + assert_eq!(BGFILTER_PROVIDER_MAX_ATTEMPTS, 2); + } + + #[tokio::test] + async fn retry_helper_runs_two_attempts_strictly_sequentially() { + let active = Arc::new(AtomicUsize::new(0)); + let peak = Arc::new(AtomicUsize::new(0)); + let calls = Arc::new(AtomicUsize::new(0)); + let outcome = run_sequential_attempts( + 2, + |_attempt| { + let active = active.clone(); + let peak = peak.clone(); + let calls = calls.clone(); + async move { + calls.fetch_add(1, AtomicOrdering::SeqCst); + let current = active.fetch_add(1, AtomicOrdering::SeqCst) + 1; + peak.fetch_max(current, AtomicOrdering::SeqCst); + tokio::task::yield_now().await; + active.fetch_sub(1, AtomicOrdering::SeqCst); + Err::<(), _>(SequentialAttemptFailure::started("provider failure")) + } + }, + |_| true, + |_attempt, _error| {}, + ) + .await; + + match outcome { + SequentialAttemptOutcome::Failure { + error, + attempts_started, + } => { + assert_eq!(error, "provider failure"); + assert_eq!(attempts_started, 2); + } + SequentialAttemptOutcome::Success { .. } => panic!("attempts should fail"), + } + assert_eq!(calls.load(AtomicOrdering::SeqCst), 2); + assert_eq!(peak.load(AtomicOrdering::SeqCst), 1); + assert_eq!(active.load(AtomicOrdering::SeqCst), 0); + } + + #[test] + fn provider_and_internal_base_urls_reject_credentials_query_and_fragment() { + assert!(validate_provider_base_url("https://provider.example/bgfilter").is_ok()); + for value in [ + "https://user:password@provider.example/bgfilter", + "https://@provider.example/bgfilter", + "https://provider.example/bgfilter?token=secret", + "https://provider.example/bgfilter#fragment", + ] { + assert!(validate_provider_base_url(value).is_err()); + } + + assert!(internal_worker_endpoint("http://127.0.0.1:8083").is_ok()); + assert!(internal_worker_endpoint("http://localhost:8083").is_ok()); + for value in [ + "http://user:password@127.0.0.1:8083", + "http://@127.0.0.1:8083", + "http://127.0.0.1:8083?token=secret", + "http://127.0.0.1:8083#fragment", + "http://127.0.0.1:8083/prefix", + "https://127.0.0.1:8083", + "http://192.0.2.10:8083", + ] { + assert!(internal_worker_endpoint(value).is_err()); + } + } + + #[test] + fn image_validation_enforces_size_magic_mime_and_dimensions() { + let png = encoded_png(2, 3); + let image = validate_image_result(png.clone(), "image/png").expect("valid png"); + assert_eq!(image.bytes, png); + assert_eq!((image.width, image.height), (2, 3)); + assert!(validate_image_result(encoded_png(1, 1), "image/jpeg").is_err()); + assert!(validate_image_result(Vec::new(), "image/png").is_err()); + assert!( + validate_image_result(vec![0; BGFILTER_MAX_RESPONSE_BYTES + 1], "image/png").is_err() + ); + assert!( + validate_image_result( + encoded_png(BGFILTER_MAX_IMAGE_DIMENSION + 1, 1), + "image/png" + ) + .is_err() + ); + } + + #[tokio::test] + async fn success_response_is_raw_bytes_and_holds_n_until_body_finishes() { + let provider = Arc::new(Semaphore::new(1)); + let permit = provider + .clone() + .try_acquire_owned() + .expect("provider permit"); + let bytes = encoded_png(1, 1); + let image = validate_image_result(bytes.clone(), "image/png").expect("valid png"); + let response = hold_provider_until_response_dropped( + worker_image_response(image, "request-1"), + Some(ProviderInFlightGuard::new( + permit, + BgfilterBackgroundMode::Flat, + )), + ); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(CONTENT_TYPE), + Some(&HeaderValue::from_static("image/png")) + ); + assert_eq!(provider.available_permits(), 0); + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + assert_eq!(body.as_ref(), bytes.as_slice()); + assert_eq!(provider.available_permits(), 1); + } + + #[test] + fn dropping_unread_response_releases_both_n_and_q_permits() { + let admission = Arc::new(Semaphore::new(1)); + let provider = Arc::new(Semaphore::new(1)); + let admission_guard = Arc::new(AdmissionGuard { + _permit: admission + .clone() + .try_acquire_owned() + .expect("admission permit"), + admitted_at: Instant::now(), + request_id: "request-1".to_string(), + }); + let provider_guard = ProviderInFlightGuard::new( + provider + .clone() + .try_acquire_owned() + .expect("provider permit"), + BgfilterBackgroundMode::Flat, + ); + let image = validate_image_result(encoded_png(1, 1), "image/png").expect("valid png"); + let response = hold_admission_until_response_dropped( + hold_provider_until_response_dropped( + worker_image_response(image, "request-1"), + Some(provider_guard), + ), + admission_guard, + ); + assert_eq!(admission.available_permits(), 0); + assert_eq!(provider.available_permits(), 0); + drop(response); + assert_eq!(admission.available_permits(), 1); + assert_eq!(provider.available_permits(), 1); + } + + #[tokio::test] + async fn typed_error_response_uses_nested_contract() { + let response = worker_error_response( + StatusCode::GATEWAY_TIMEOUT, + WorkerFailure::deadline("queue timeout") + .with_phase("queue") + .with_attempts_started(0), + Some("request-1"), + ); + assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); + assert_eq!( + response.headers().get("x-request-id"), + Some(&HeaderValue::from_static("request-1")) + ); + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: serde_json::Value = + serde_json::from_slice(body.as_ref()).expect("error json should decode"); + assert_eq!(payload["error"]["code"], "deadline_exceeded"); + assert_eq!(payload["error"]["phase"], "queue"); + assert_eq!(payload["error"]["attemptsStarted"], 0); + assert!(payload.get("code").is_none()); + } + + #[test] + fn worker_rechecks_flat_circuit_and_signs_each_attempt_while_parent_sends_once() { + let source = include_str!("bgfilter_worker.rs"); + let execute = source + .split_once("async fn execute_logical_request") + .expect("execute function") + .1 + .split_once("struct ProviderAttemptBudget") + .expect("attempt budget boundary") + .0; + let circuit_checks = execute + .match_indices("flat_circuit_open_remaining") + .map(|(index, _)| index) + .collect::>(); + assert_eq!(circuit_checks.len(), 2); + let acquire = execute + .find("runtime.provider.clone().acquire_owned()") + .expect("provider acquire"); + assert!(circuit_checks[0] < acquire && acquire < circuit_checks[1]); + let sequential_runner = execute + .find("run_sequential_attempts") + .expect("sequential attempt runner"); + let signing = execute.find("sign_source_url").expect("source signing"); + let budget_after_signing = execute + .find("provider_attempt_budget") + .expect("post-signing budget check"); + assert!(signing > sequential_runner); + assert!(budget_after_signing > signing); + + let provider_once = source + .split_once("async fn request_provider_once") + .expect("provider request function") + .1 + .split_once("fn validate_provider_base_url") + .expect("provider request boundary") + .0; + assert_eq!( + provider_once.matches("from_std(response_deadline)").count(), + 3, + "validation permit、成功 body 与 blocking 校验必须共用同一 attempt/RPC deadline", + ); + + let parent_client = source + .split_once("pub(crate) async fn request_bgfilter_worker") + .expect("parent client") + .1 + .split_once("fn internal_worker_endpoint") + .expect("parent client boundary") + .0; + assert_eq!(parent_client.matches(".send()").count(), 1); + assert!(!parent_client.contains("for attempt")); + } + + #[test] + fn request_budget_reserves_parent_continuation_time() { + let deadline = Instant::now() + Duration::from_secs(40); + let budget = request_budget_ms(180_000, Some(deadline), Duration::from_secs(35)); + assert!(budget <= 5_000); + assert!(budget > 0); + assert_eq!( + request_budget_ms(180_000, None, Duration::from_secs(35)), + 180_000 + ); + } + + #[test] + fn stable_worker_error_codes_are_closed() { + assert_eq!( + stable_error_code("provider_exhausted"), + Some("provider_exhausted") + ); + assert_eq!(stable_error_code("unknown"), None); + } +} diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index 43aa0111b..d809736b3 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -118,7 +118,6 @@ const EDITOR_CHARACTER_ANIMATION_MODEL: &str = "seedance2.0-fast"; const EDITOR_CHARACTER_ANIMATION_ASSET_KIND: &str = "editor_character_animation"; const EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND: &str = "character-animation"; const EDITOR_CHARACTER_ANIMATION_PROVIDER_SOURCE_SLOT: &str = "provider_source"; -const EDITOR_CHARACTER_ANIMATION_BGFILTER_TIMEOUT_PER_FRAME_MS: u64 = 2_000; const EDITOR_VIDEO_ASSET_KIND: &str = "editor_video"; const EDITOR_VIDEO_ENTITY_KIND: &str = "editor_canvas"; const EDITOR_VIDEO_SLOT: &str = "video_preview"; @@ -706,6 +705,7 @@ pub(crate) async fn generate_editor_character_animation_for_owner( user_id: Some(owner_user_id.clone()), profile_id: project_id.clone(), request_id: Some(request_context.request_id().to_string()), + external_call_deadline: request_context.external_call_deadline(), }; let result = execute_billable_asset_operation_with_cost( @@ -734,6 +734,7 @@ pub(crate) async fn generate_editor_character_animation_for_owner( user_id: Some(owner_user_id.clone()), profile_id: project_id.clone(), request_id: Some(request_context.request_id().to_string()), + external_call_deadline: request_context.external_call_deadline(), }, }), ) @@ -2295,18 +2296,6 @@ async fn create_editor_ark_image_to_video_task( }) } -fn editor_character_animation_bgfilter_request_timeout_ms( - base_timeout_ms: u64, - frame_count: usize, -) -> u64 { - let frame_timeout_increment_ms = u64::try_from(frame_count) - .unwrap_or(u64::MAX) - .saturating_mul(EDITOR_CHARACTER_ANIMATION_BGFILTER_TIMEOUT_PER_FRAME_MS); - base_timeout_ms - .saturating_add(frame_timeout_increment_ms) - .max(1) -} - async fn extract_and_persist_editor_character_animation_frames( state: &AppState, owner_user_id: &str, @@ -2337,10 +2326,7 @@ async fn extract_and_persist_editor_character_animation_frames( use futures_util::StreamExt as _; let frame_count = finalized_frames.len(); - let bgfilter_request_timeout_ms = editor_character_animation_bgfilter_request_timeout_ms( - state.config.editor_bgfilter_request_timeout_ms, - frame_count, - ); + let bgfilter_request_timeout_ms = state.config.editor_bgfilter_request_timeout_ms; let frame_results = futures_util::stream::iter(finalized_frames.into_iter().enumerate().map( |(frame_index, frame)| async move { process_and_persist_editor_character_animation_frame( @@ -2360,8 +2346,8 @@ async fn extract_and_persist_editor_character_animation_frames( .map_err(|error| (frame_index, error)) }, )) - // 中文注释:动作帧上限固定为 48。全部帧连续进入在途集合,由 BgFilter 服务端既有进程锁自行排队; - // api-server 不再等待前一帧返回,也不因返回乱序产生队头阻塞。 + // 中文注释:动作帧上限固定为 48。全部帧连续进入在途集合,由唯一内部 + // bgfilter-worker 的 admission 与 provider semaphore 统一排队和限流;父流程不因返回乱序产生队头阻塞。 .buffer_unordered(frame_count.max(1)) // 中文注释:不能 try_collect 提前取消。已经发出的请求必须全部排空,避免服务端完成计算后无人接收。 .collect::>() @@ -6431,31 +6417,26 @@ mod tests { } #[test] - fn editor_character_animation_bgfilter_timeout_scales_with_frame_count() { - assert_eq!( - editor_character_animation_bgfilter_request_timeout_ms(180_000, 32), - 244_000 - ); - assert_eq!( - editor_character_animation_bgfilter_request_timeout_ms(180_000, 40), - 260_000 - ); - assert_eq!( - editor_character_animation_bgfilter_request_timeout_ms(180_000, 48), - 276_000 - ); - assert_eq!( - editor_character_animation_bgfilter_request_timeout_ms(0, 32), - 64_000 - ); - assert_eq!( - editor_character_animation_bgfilter_request_timeout_ms(0, 0), - 1 - ); - assert_eq!( - editor_character_animation_bgfilter_request_timeout_ms(u64::MAX - 1, 48), - u64::MAX + fn editor_character_animation_bgfilter_timeout_uses_configured_single_request_limit() { + let source = include_str!("character_animation_assets.rs"); + assert_function_contains( + source, + "async fn extract_and_persist_editor_character_animation_frames", + "async fn process_and_persist_editor_character_animation_frame", + &[ + "let bgfilter_request_timeout_ms = state.config.editor_bgfilter_request_timeout_ms;", + "process_and_persist_editor_character_animation_frame", + "bgfilter_request_timeout_ms", + ], ); + assert!(!source.contains(concat!( + "EDITOR_CHARACTER_ANIMATION_BGFILTER_TIMEOUT_", + "PER_FRAME_MS" + ))); + assert!(!source.contains(concat!( + "editor_character_animation_bgfilter_", + "request_timeout_ms" + ))); } #[test] @@ -6571,7 +6552,6 @@ mod tests { &[ "apply_chroma_key: false", "prepare_for_bgfilter_input: true", - "editor_character_animation_bgfilter_request_timeout_ms", "state.config.editor_bgfilter_request_timeout_ms", "process_and_persist_editor_character_animation_frame", "bgfilter_request_timeout_ms", diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index e4cb6de96..2d2ea6b8a 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -32,6 +32,13 @@ pub struct AppConfig { pub listen_backlog: i32, pub worker_threads: Option, pub process_role: ProcessRole, + pub bgfilter_worker_host: String, + pub bgfilter_worker_port: u16, + pub bgfilter_worker_base_url: String, + pub bgfilter_internal_token: Option, + pub bgfilter_worker_concurrency: usize, + pub bgfilter_worker_max_requests: usize, + pub bgfilter_worker_connect_timeout_ms: u64, pub external_generation_mode: ExternalGenerationMode, pub external_generation_worker_id: String, pub external_generation_worker_concurrency: usize, @@ -206,6 +213,7 @@ pub struct AppConfig { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ProcessRole { Api, + BgfilterWorker, ExternalGenerationWorker, ExternalGenerationController, All, @@ -234,6 +242,7 @@ impl ProcessRole { pub fn as_str(self) -> &'static str { match self { Self::Api => "api", + Self::BgfilterWorker => "bgfilter-worker", Self::ExternalGenerationWorker => "external-generation-worker", Self::ExternalGenerationController => "external-generation-controller", Self::All => "all", @@ -244,6 +253,10 @@ impl ProcessRole { matches!(self, Self::Api | Self::All) } + pub fn runs_bgfilter_worker(self) -> bool { + matches!(self, Self::BgfilterWorker) + } + pub fn runs_external_generation_worker(self) -> bool { matches!(self, Self::ExternalGenerationWorker | Self::All) } @@ -261,6 +274,13 @@ impl Default for AppConfig { listen_backlog: 1024, worker_threads: None, process_role: ProcessRole::Api, + bgfilter_worker_host: "127.0.0.1".to_string(), + bgfilter_worker_port: 8083, + bgfilter_worker_base_url: "http://127.0.0.1:8083".to_string(), + bgfilter_internal_token: None, + bgfilter_worker_concurrency: 4, + bgfilter_worker_max_requests: 128, + bgfilter_worker_connect_timeout_ms: 2_000, external_generation_mode: ExternalGenerationMode::Queue, external_generation_worker_id: default_external_generation_worker_id(), external_generation_worker_concurrency: 2, @@ -473,6 +493,36 @@ impl AppConfig { config.bind_port = parsed_port; } + if let Some(host) = read_first_non_empty_env(&["GENARRATIVE_BGFILTER_WORKER_HOST"]) { + config.bgfilter_worker_host = host; + } + if let Some(port) = read_first_positive_u16_env(&["GENARRATIVE_BGFILTER_WORKER_PORT"]) { + config.bgfilter_worker_port = port; + } + if let Some(base_url) = read_first_non_empty_env(&["GENARRATIVE_BGFILTER_WORKER_BASE_URL"]) + { + config.bgfilter_worker_base_url = base_url; + } + config.bgfilter_internal_token = read_secret_env_or_file( + &["GENARRATIVE_BGFILTER_INTERNAL_TOKEN"], + &["GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE"], + ); + if let Some(concurrency) = + read_first_usize_env(&["GENARRATIVE_BGFILTER_WORKER_CONCURRENCY"]) + { + config.bgfilter_worker_concurrency = concurrency; + } + if let Some(max_requests) = + read_first_usize_env(&["GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS"]) + { + config.bgfilter_worker_max_requests = max_requests; + } + if let Some(connect_timeout_ms) = + read_first_positive_u64_env(&["GENARRATIVE_BGFILTER_WORKER_CONNECT_TIMEOUT_MS"]) + { + config.bgfilter_worker_connect_timeout_ms = connect_timeout_ms; + } + if let Ok(log_filter) = env::var("GENARRATIVE_API_LOG") && !log_filter.trim().is_empty() { @@ -1370,6 +1420,7 @@ fn default_external_generation_worker_id() -> String { fn parse_process_role(value: &str) -> Option { match trim_quoted_env_value(value).to_ascii_lowercase().as_str() { "api" => Some(ProcessRole::Api), + "bgfilter-worker" | "bgfilter_worker" => Some(ProcessRole::BgfilterWorker), "external-generation-worker" | "external_generation_worker" | "worker" => { Some(ProcessRole::ExternalGenerationWorker) } @@ -1543,6 +1594,13 @@ mod tests { fn default_keeps_non_public_model_and_base_url_empty() { let config = AppConfig::default(); + assert_eq!(config.bgfilter_worker_host, "127.0.0.1"); + assert_eq!(config.bgfilter_worker_port, 8083); + assert_eq!(config.bgfilter_worker_base_url, "http://127.0.0.1:8083"); + assert!(config.bgfilter_internal_token.is_none()); + assert_eq!(config.bgfilter_worker_concurrency, 4); + assert_eq!(config.bgfilter_worker_max_requests, 128); + assert_eq!(config.bgfilter_worker_connect_timeout_ms, 2_000); assert!(config.llm_model.is_empty()); assert!(config.llm_base_url.is_empty()); // assert!(config.apimart_base_url.is_empty()); @@ -1607,6 +1665,14 @@ mod tests { #[test] fn process_role_controls_http_and_external_generation_worker_roles() { assert_eq!(parse_process_role("api"), Some(ProcessRole::Api)); + assert_eq!( + parse_process_role("bgfilter-worker"), + Some(ProcessRole::BgfilterWorker) + ); + assert_eq!( + parse_process_role("'bgfilter_worker'"), + Some(ProcessRole::BgfilterWorker) + ); assert_eq!( parse_process_role("\"external-generation-worker\""), Some(ProcessRole::ExternalGenerationWorker) @@ -1629,17 +1695,26 @@ mod tests { ); assert_eq!(parse_process_role("all"), Some(ProcessRole::All)); assert_eq!(parse_process_role("unknown"), None); + assert_eq!(ProcessRole::BgfilterWorker.as_str(), "bgfilter-worker"); assert!(ProcessRole::Api.runs_http()); + assert!(!ProcessRole::Api.runs_bgfilter_worker()); assert!(!ProcessRole::Api.runs_external_generation_worker()); assert!(!ProcessRole::Api.runs_external_generation_controller()); + assert!(!ProcessRole::BgfilterWorker.runs_http()); + assert!(ProcessRole::BgfilterWorker.runs_bgfilter_worker()); + assert!(!ProcessRole::BgfilterWorker.runs_external_generation_worker()); + assert!(!ProcessRole::BgfilterWorker.runs_external_generation_controller()); assert!(!ProcessRole::ExternalGenerationWorker.runs_http()); + assert!(!ProcessRole::ExternalGenerationWorker.runs_bgfilter_worker()); assert!(ProcessRole::ExternalGenerationWorker.runs_external_generation_worker()); assert!(!ProcessRole::ExternalGenerationWorker.runs_external_generation_controller()); assert!(!ProcessRole::ExternalGenerationController.runs_http()); + assert!(!ProcessRole::ExternalGenerationController.runs_bgfilter_worker()); assert!(!ProcessRole::ExternalGenerationController.runs_external_generation_worker()); assert!(ProcessRole::ExternalGenerationController.runs_external_generation_controller()); assert!(ProcessRole::All.runs_http()); + assert!(!ProcessRole::All.runs_bgfilter_worker()); assert!(ProcessRole::All.runs_external_generation_worker()); assert!(!ProcessRole::All.runs_external_generation_controller()); } @@ -1872,6 +1947,68 @@ mod tests { let _ = fs::remove_file(secret_path); } + #[test] + fn from_env_reads_bgfilter_worker_settings_and_secret_from_value_or_file() { + let _guard = ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("env lock should not poison"); + let secret_path = std::env::temp_dir().join(format!( + "genarrative-bgfilter-internal-token-{}.txt", + std::process::id() + )); + fs::write(&secret_path, " file-token \n").expect("secret file should write"); + + unsafe { + std::env::remove_var("GENARRATIVE_BGFILTER_INTERNAL_TOKEN"); + std::env::set_var("GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE", &secret_path); + std::env::set_var("GENARRATIVE_BGFILTER_WORKER_HOST", "127.0.0.2"); + std::env::set_var("GENARRATIVE_BGFILTER_WORKER_PORT", "18083"); + std::env::set_var( + "GENARRATIVE_BGFILTER_WORKER_BASE_URL", + "http://127.0.0.2:18083", + ); + std::env::set_var("GENARRATIVE_BGFILTER_WORKER_CONCURRENCY", "6"); + std::env::set_var("GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS", "192"); + std::env::set_var("GENARRATIVE_BGFILTER_WORKER_CONNECT_TIMEOUT_MS", "3500"); + std::env::set_var("GENARRATIVE_PROCESS_ROLE", "bgfilter_worker"); + } + + let config = AppConfig::from_env(); + assert_eq!(config.process_role, ProcessRole::BgfilterWorker); + assert_eq!(config.bgfilter_worker_host, "127.0.0.2"); + assert_eq!(config.bgfilter_worker_port, 18_083); + assert_eq!(config.bgfilter_worker_base_url, "http://127.0.0.2:18083"); + assert_eq!( + config.bgfilter_internal_token.as_deref(), + Some("file-token") + ); + assert_eq!(config.bgfilter_worker_concurrency, 6); + assert_eq!(config.bgfilter_worker_max_requests, 192); + assert_eq!(config.bgfilter_worker_connect_timeout_ms, 3_500); + + unsafe { + std::env::set_var("GENARRATIVE_BGFILTER_INTERNAL_TOKEN", "direct-token"); + } + assert_eq!( + AppConfig::from_env().bgfilter_internal_token.as_deref(), + Some("direct-token") + ); + + unsafe { + std::env::remove_var("GENARRATIVE_BGFILTER_INTERNAL_TOKEN"); + std::env::remove_var("GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE"); + std::env::remove_var("GENARRATIVE_BGFILTER_WORKER_HOST"); + std::env::remove_var("GENARRATIVE_BGFILTER_WORKER_PORT"); + std::env::remove_var("GENARRATIVE_BGFILTER_WORKER_BASE_URL"); + std::env::remove_var("GENARRATIVE_BGFILTER_WORKER_CONCURRENCY"); + std::env::remove_var("GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS"); + std::env::remove_var("GENARRATIVE_BGFILTER_WORKER_CONNECT_TIMEOUT_MS"); + std::env::remove_var("GENARRATIVE_PROCESS_ROLE"); + } + let _ = fs::remove_file(secret_path); + } + #[test] fn from_env_reads_api_runtime_performance_settings() { 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 1cf741c6b..23b4fda4f 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -2,7 +2,6 @@ use std::{ borrow::Cow, collections::BTreeMap, io::Cursor, - sync::{Mutex, OnceLock}, time::{Duration, Instant}, }; @@ -131,22 +130,15 @@ const EDITOR_PROVIDER_SOURCE_SLOT: &str = "provider_source"; pub(crate) const EDITOR_BGFILTER_DEFAULT_SEG_MODEL: &str = "birefnet"; const EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG: &str = "anime-seg"; const EDITOR_MATTING_SOURCE_URL_EXPIRE_SECONDS: u64 = 600; -const EDITOR_BGFILTER_BACKGROUND_MODE_FLAT: &str = "flat"; -const EDITOR_BGFILTER_BACKGROUND_MODE_COMPLEX: &str = "complex"; pub(crate) const EDITOR_BGFILTER_CROSS_CHECK_ENABLED: bool = true; pub(crate) const EDITOR_BGFILTER_CROSS_CHECK_DISABLED: bool = false; -const EDITOR_BGFILTER_RETRY_COUNT: usize = 1; +const EDITOR_BGFILTER_LOGICAL_ATTEMPT_COUNT: u64 = 2; +const EDITOR_BGFILTER_WORKER_RESPONSE_WINDOW_MS: u64 = 1_000; +const EDITOR_BGFILTER_PARENT_TRANSPORT_WINDOW: Duration = Duration::from_secs(2); +const EDITOR_BGFILTER_FLAT_LOCAL_RESERVE: Duration = Duration::from_secs(7); 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 { @@ -1682,6 +1674,7 @@ pub(crate) async fn generate_editor_image_for_owner( .clone() .or_else(|| payload.project_id.clone()), request_id: Some(request_context.request_id().to_string()), + external_call_deadline: request_context.external_call_deadline(), }, }), ) @@ -1836,6 +1829,7 @@ pub(crate) async fn generate_editor_image_for_owner( .clone() .or_else(|| payload.project_id.clone()), request_id: Some(request_context.request_id().to_string()), + external_call_deadline: request_context.external_call_deadline(), }; let removal = remove_editor_generated_screen_background_with_bgfilter( state, @@ -3160,9 +3154,21 @@ pub(crate) async fn remove_editor_image_background_for_owner( ) .await?; validate_editor_background_removal_source(state, source_object_key.as_str()).await?; - let removed = - request_editor_background_removal_image_with_retry(state, source_object_key.as_str()) - .await?; + let matting_audit = crate::external_api_audit::ExternalApiAuditContext { + user_id: caller.audit_subject_user_id.clone(), + profile_id: caller + .audit_project_id + .clone() + .or_else(|| payload.project_id.clone()), + request_id: Some(request_context.request_id().to_string()), + external_call_deadline: request_context.external_call_deadline(), + }; + let removed = request_editor_background_removal_image_with_bgfilter_worker( + state, + source_object_key.as_str(), + &matting_audit, + ) + .await?; let task_id = normalize_optional_string(payload.task_id.clone()) .unwrap_or_else(|| format!("background-removal-{}", current_utc_micros())); let persisted = persist_editor_generated_image( @@ -3371,36 +3377,53 @@ fn editor_background_removal_source_unsupported(message: &str) -> AppError { })) } -async fn request_editor_background_removal_image_with_retry( +fn editor_bgfilter_worker_request_budget_limit_ms(single_attempt_timeout_ms: u64) -> u64 { + single_attempt_timeout_ms + .max(1) + .saturating_mul(EDITOR_BGFILTER_LOGICAL_ATTEMPT_COUNT) + .saturating_add(EDITOR_BGFILTER_WORKER_RESPONSE_WINDOW_MS) +} + +fn editor_bgfilter_flat_deadline_reserve(aliyun_timeout_ms: u64) -> Duration { + Duration::from_millis(aliyun_timeout_ms) + .saturating_add(EDITOR_BGFILTER_FLAT_LOCAL_RESERVE) + .saturating_add(EDITOR_BGFILTER_PARENT_TRANSPORT_WINDOW) +} + +async fn request_editor_background_removal_image_with_bgfilter_worker( state: &AppState, source_object_key: &str, + audit: &crate::external_api_audit::ExternalApiAuditContext, ) -> Result { - let max_attempts = EDITOR_BGFILTER_RETRY_COUNT + 1; - let mut final_error = None; - for attempt in 1..=max_attempts { - match request_editor_background_removal_image(state, source_object_key).await { - Ok(removed) => return Ok(removed), - Err(error) => { - let will_retry = attempt < max_attempts; - tracing::warn!( - provider = "bgfilter", - background_mode = EDITOR_BGFILTER_BACKGROUND_MODE_COMPLEX, - attempt, - max_attempts, - will_retry, - error = %error, - error_details = ?error.details(), - "editor_background_removal_request_attempt_failed" - ); - if will_retry { - continue; - } - final_error = Some(error); - } - } - } - - Err(final_error.expect("BgFilter complex retry loop should retain its final error")) + let request_budget_limit_ms = editor_bgfilter_worker_request_budget_limit_ms( + state.config.editor_bgfilter_request_timeout_ms, + ); + let request_budget_ms = crate::bgfilter_worker::request_budget_ms( + request_budget_limit_ms, + audit.external_call_deadline, + EDITOR_BGFILTER_PARENT_TRANSPORT_WINDOW, + ); + let removed = crate::bgfilter_worker::request_bgfilter_worker( + state, + source_object_key, + crate::bgfilter_worker::BgfilterBackgroundMode::Complex, + None, + EDITOR_BGFILTER_DEFAULT_SEG_MODEL, + EDITOR_BGFILTER_CROSS_CHECK_DISABLED, + request_budget_ms, + audit, + ) + .await + .map_err(crate::bgfilter_worker::BgfilterClientError::into_app_error)?; + Ok(EditorBackgroundRemovalImage { + width: removed.width, + height: removed.height, + image: DownloadedOpenAiImage { + bytes: removed.bytes, + extension: removed.extension, + mime_type: removed.mime_type, + }, + }) } pub(crate) struct EditorScreenBackgroundRemovalOutput { @@ -3438,109 +3461,63 @@ pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter_with request_timeout_ms: u64, audit: &crate::external_api_audit::ExternalApiAuditContext, ) -> Result { - if let Some(remaining) = editor_bgfilter_circuit_open_remaining(state) { - // 熔断打开只跳过 BgFilter 调用本身,仍要走阿里云兜底;绝不能在 cooldown 内直接退化到 - // 低质量本地扣色,否则整段冷却期从「双供应商降级」退化成单一本地键色扣除。 - tracing::warn!( - provider = "bgfilter", - screen_color = screen_color.hex, - seg_model, - cross_check, - cooldown_ms = remaining.as_millis() as u64, - "editor_bgfilter_circuit_open_fallback_to_aliyun_matting" - ); - return fallback_editor_screen_background_removal( - state, - source_object_key, - screen_color, - audit, - ) - .await; - } - - let max_attempts = EDITOR_BGFILTER_RETRY_COUNT + 1; - let mut final_error = None; - for attempt in 1..=max_attempts { - match request_editor_generated_screen_background_with_bgfilter( - state, - source_object_key, - screen_color, - seg_model, - cross_check, - request_timeout_ms, - ) - .await - { - Ok(image) => { - record_editor_bgfilter_success(); - return Ok(EditorScreenBackgroundRemovalOutput { - image, - provider: "BgFilter", - model: seg_model.to_string(), - }); - } - Err(error) => { - record_editor_bgfilter_failure(state); - let will_retry = attempt < max_attempts; - tracing::warn!( - provider = "bgfilter", - screen_color = screen_color.hex, - seg_model, - cross_check, - attempt, - max_attempts, - will_retry, - error = %error, - error_details = ?error.details(), - "editor_bgfilter_request_attempt_failed" - ); - crate::external_api_audit::record_matting_external_api_failure( - state, - audit, - "bgfilter", - state.config.editor_bgfilter_base_url.clone(), - "editor-screen-background-removal", - "bgfilter_segment", - crate::external_api_audit::matting_failure_audit_status_code(&error), - crate::external_api_audit::matting_failure_audit_timeout(&error), - crate::external_api_audit::matting_failure_audit_is_transport(&error), - crate::external_api_audit::matting_failure_audit_latency_ms(&error), - error.message().to_string(), - crate::external_api_audit::matting_failure_audit_raw_excerpt(&error), - ) - .await; - if will_retry { - continue; - } - final_error = Some(error); - } + let request_budget_limit_ms = + editor_bgfilter_worker_request_budget_limit_ms(request_timeout_ms); + let request_budget_ms = crate::bgfilter_worker::request_budget_ms( + request_budget_limit_ms, + audit.external_call_deadline, + editor_bgfilter_flat_deadline_reserve(state.config.aliyun_matting_request_timeout_ms), + ); + match crate::bgfilter_worker::request_bgfilter_worker( + state, + source_object_key, + crate::bgfilter_worker::BgfilterBackgroundMode::Flat, + Some(screen_color.hex), + seg_model, + cross_check, + request_budget_ms, + audit, + ) + .await + { + Ok(image) => { + return Ok(EditorScreenBackgroundRemovalOutput { + image: DownloadedOpenAiImage { + bytes: image.bytes, + extension: image.extension, + mime_type: image.mime_type, + }, + provider: "BgFilter", + model: seg_model.to_string(), + }); + } + Err(error) if !error.allows_flat_fallback() => { + return Err(error.into_app_error()); + } + Err(error) + if audit + .external_call_deadline + .is_some_and(|deadline| Instant::now() >= deadline) => + { + return Err(error.into_app_error()); + } + Err(error) => { + tracing::warn!( + provider = "bgfilter-worker", + worker_code = error.code(), + screen_color = screen_color.hex, + seg_model, + cross_check, + "editor_bgfilter_worker_fallback_to_aliyun_matting" + ); } - } - - if let Some(error) = final_error { - tracing::warn!( - provider = "bgfilter", - screen_color = screen_color.hex, - seg_model, - cross_check, - error = %error, - error_details = ?error.details(), - "editor_bgfilter_fallback_to_aliyun_matting" - ); - return fallback_editor_screen_background_removal( - state, - source_object_key, - screen_color, - audit, - ) - .await; } fallback_editor_screen_background_removal(state, source_object_key, screen_color, audit).await } -/// BgFilter 不可用(熔断打开或调用失败)时的统一兜底链:先阿里云通用抠图,失败再退本地键色扣除。 -/// 熔断打开路径与 BgFilter 调用失败路径共用此链,确保 cooldown 内仍是「阿里云 → 本地」而非直接本地。 +/// BgFilter 内部 worker 对 flat 调用返回可降级错误时的统一兜底链: +/// 先阿里云通用抠图,失败再退本地键色扣除。 async fn fallback_editor_screen_background_removal( state: &AppState, source_object_key: &str, @@ -3610,350 +3587,6 @@ async fn fallback_editor_screen_background_removal( } } -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, - source_object_key: &str, - screen_color: EditorScreenBackgroundColor, - seg_model: &str, - cross_check: bool, - request_timeout_ms: u64, -) -> Result { - let url = editor_bgfilter_endpoint(state)?; - let signed_image_url = sign_editor_private_object_read_url( - state, - source_object_key, - EDITOR_MATTING_SOURCE_URL_EXPIRE_SECONDS, - )?; - let call_id = format!("bgfilter-call-{}", current_utc_micros()); - let request_started_at = Instant::now(); - let timeout_ms = request_timeout_ms.max(1); - tracing::info!( - %call_id, - upstream_url = %url, - source_object_key, - image_url_expire_seconds = EDITOR_MATTING_SOURCE_URL_EXPIRE_SECONDS, - screen_color = screen_color.hex, - seg_model, - background_mode = EDITOR_BGFILTER_BACKGROUND_MODE_FLAT, - cross_check, - timeout_ms, - "editor_bgfilter_request_start" - ); - let http_client = state.editor_bgfilter_http_client(); - let form = reqwest::multipart::Form::new() - .text("image_url", signed_image_url.clone()) - .text("screen_color", screen_color.hex.to_string()) - .text("seg_model", seg_model.to_string()) - .text("background_mode", EDITOR_BGFILTER_BACKGROUND_MODE_FLAT) - .text( - "cross_check", - editor_bgfilter_cross_check_form_value(cross_check), - ); - let mut request = http_client - .post(url.as_str()) - .timeout(Duration::from_millis(timeout_ms)) - .multipart(form); - if let Some(token) = state - .config - .editor_bgfilter_token - .as_deref() - .map(str::trim) - .filter(|token| !token.is_empty()) - { - request = request.header("X-Genarrative-Image-Token", token); - } - let response = request.send().await.map_err(|error| { - let latency_ms = request_started_at.elapsed().as_millis() as u64; - tracing::warn!( - %call_id, - elapsed_ms = latency_ms, - timeout = error.is_timeout(), - error = %error, - "editor_bgfilter_request_failed" - ); - map_editor_bgfilter_error(error, latency_ms) - })?; - let status = response.status(); - if !status.is_success() { - let message = response - .text() - .await - .unwrap_or_else(|error| format!("读取上游错误失败:{error}")); - let message = sanitize_editor_bgfilter_upstream_message(&message, &signed_image_url); - tracing::warn!( - %call_id, - upstream_status = status.as_u16(), - elapsed_ms = request_started_at.elapsed().as_millis() as u64, - upstream_message = %message.chars().take(160).collect::(), - "editor_bgfilter_upstream_rejected" - ); - return Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "bgfilter", - "message": "BgFilter 服务返回非成功状态", - "upstreamStatus": status.as_u16(), - "latencyMs": request_started_at.elapsed().as_millis() as u64, - "upstreamMessage": message.chars().take(500).collect::(), - })), - ); - } - let mime_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .and_then(normalize_editor_reference_image_mime_type) - .unwrap_or("image/png") - .to_string(); - let upstream_elapsed_ms = response - .headers() - .get("x-bgfilter-elapsed-ms") - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.parse::().ok()); - let upstream_seg_model = response - .headers() - .get("x-bgfilter-seg-model") - .and_then(|value| value.to_str().ok()) - .map(ToOwned::to_owned); - let upstream_screen_color = response - .headers() - .get("x-bgfilter-screen-color") - .and_then(|value| value.to_str().ok()) - .map(ToOwned::to_owned); - let upstream_cross_check = response - .headers() - .get("x-bgfilter-cross-check") - .and_then(|value| value.to_str().ok()) - .map(ToOwned::to_owned); - let bytes = read_editor_image_removal_response_bytes( - response, - "bgfilter", - "BgFilter", - request_started_at, - ) - .await?; - if bytes.is_empty() { - // HTTP 已成功但 body 为空属于上游内容缺陷(5xx 归类正确),仍补 latencyMs 与 Aliyun 兜底口径对齐。 - return Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "bgfilter", - "message": "BgFilter 服务未返回图片", - "latencyMs": request_started_at.elapsed().as_millis() as u64, - })), - ); - } - let decoded = decode_editor_removed_background_image(bytes.as_slice(), "bgfilter")?; - let image = DownloadedOpenAiImage { - bytes, - extension: editor_reference_image_extension(mime_type.as_str()).to_string(), - mime_type, - }; - tracing::info!( - %call_id, - status = status.as_u16(), - width = decoded.width(), - height = decoded.height(), - screen_color = screen_color.hex, - seg_model, - cross_check, - upstream_screen_color = upstream_screen_color.as_deref().unwrap_or(""), - upstream_seg_model = upstream_seg_model.as_deref().unwrap_or(""), - upstream_cross_check = upstream_cross_check.as_deref().unwrap_or(""), - output_bytes = image.bytes.len(), - elapsed_ms = request_started_at.elapsed().as_millis() as u64, - upstream_elapsed_ms, - "editor_bgfilter_request_done" - ); - - Ok(image) -} - -async fn request_editor_background_removal_image( - state: &AppState, - source_object_key: &str, -) -> Result { - let url = editor_bgfilter_endpoint(state)?; - let signed_image_url = sign_editor_private_object_read_url( - state, - source_object_key, - EDITOR_MATTING_SOURCE_URL_EXPIRE_SECONDS, - )?; - let call_id = format!("background-removal-call-{}", current_utc_micros()); - let request_started_at = Instant::now(); - let timeout_ms = state.config.editor_bgfilter_request_timeout_ms.max(1); - tracing::info!( - %call_id, - upstream_url = %url, - source_object_key, - image_url_expire_seconds = EDITOR_MATTING_SOURCE_URL_EXPIRE_SECONDS, - background_mode = EDITOR_BGFILTER_BACKGROUND_MODE_COMPLEX, - cross_check = EDITOR_BGFILTER_CROSS_CHECK_DISABLED, - timeout_ms, - "editor_background_removal_request_start" - ); - let http_client = state.editor_bgfilter_http_client(); - let form = reqwest::multipart::Form::new() - .text("image_url", signed_image_url.clone()) - .text("background_mode", EDITOR_BGFILTER_BACKGROUND_MODE_COMPLEX) - .text("seg_model", EDITOR_BGFILTER_DEFAULT_SEG_MODEL) - .text( - "cross_check", - editor_bgfilter_cross_check_form_value(EDITOR_BGFILTER_CROSS_CHECK_DISABLED), - ); - let mut request = http_client.post(url.as_str()).multipart(form); - if let Some(token) = state - .config - .editor_bgfilter_token - .as_deref() - .map(str::trim) - .filter(|token| !token.is_empty()) - { - request = request.header("X-Genarrative-Image-Token", token); - } - let response = request.send().await.map_err(|error| { - tracing::warn!( - %call_id, - elapsed_ms = request_started_at.elapsed().as_millis() as u64, - timeout = error.is_timeout(), - error = %error, - "editor_background_removal_request_failed" - ); - map_editor_bgfilter_error(error, request_started_at.elapsed().as_millis() as u64) - })?; - let status = response.status(); - if !status.is_success() { - let message = response - .text() - .await - .unwrap_or_else(|error| format!("读取上游错误失败:{error}")); - let message = sanitize_editor_bgfilter_upstream_message(&message, &signed_image_url); - tracing::warn!( - %call_id, - upstream_status = status.as_u16(), - elapsed_ms = request_started_at.elapsed().as_millis() as u64, - upstream_message = %message.chars().take(160).collect::(), - "editor_background_removal_upstream_rejected" - ); - return Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "bgfilter", - "message": "BgFilter 服务返回非成功状态", - "upstreamStatus": status.as_u16(), - "upstreamMessage": message.chars().take(500).collect::(), - })), - ); - } - let mime_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .and_then(normalize_editor_reference_image_mime_type) - .unwrap_or("image/png") - .to_string(); - let upstream_elapsed_ms = response - .headers() - .get("x-bgfilter-elapsed-ms") - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.parse::().ok()); - let bytes = read_editor_background_removal_bytes(response, request_started_at).await?; - if bytes.is_empty() { - return Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "bgfilter", - "message": "BgFilter 服务未返回图片", - })), - ); - } - let decoded = decode_editor_background_removal_image(bytes.as_slice())?; - let image = DownloadedOpenAiImage { - bytes, - extension: editor_reference_image_extension(mime_type.as_str()).to_string(), - mime_type, - }; - tracing::info!( - %call_id, - status = status.as_u16(), - width = decoded.width(), - height = decoded.height(), - output_bytes = image.bytes.len(), - elapsed_ms = request_started_at.elapsed().as_millis() as u64, - upstream_elapsed_ms, - "editor_background_removal_request_done" - ); - - Ok(EditorBackgroundRemovalImage { - image, - width: decoded.width(), - height: decoded.height(), - }) -} - -fn editor_bgfilter_endpoint(state: &AppState) -> Result { - let base_url = state.config.editor_bgfilter_base_url.trim(); - if base_url.is_empty() { - return Err( - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ - "provider": "bgfilter", - "message": "BgFilter 服务地址未配置", - })), - ); - } - Ok(format!( - "{}/remove-background", - base_url.trim_end_matches('/') - )) -} - fn sign_editor_private_object_read_url( state: &AppState, object_key: &str, @@ -3974,14 +3607,6 @@ fn sign_editor_private_object_read_url( .map_err(|error| map_oss_error(error, "aliyun-oss")) } -fn sanitize_editor_bgfilter_upstream_message(message: &str, signed_image_url: &str) -> String { - let sanitized = message.replace(signed_image_url, "[signed OSS URL redacted]"); - if sanitized.to_ascii_lowercase().contains("x-oss-") { - return "BgFilter 服务返回错误(OSS 签名 URL 已脱敏)".to_string(); - } - sanitized.chars().take(500).collect() -} - fn parse_editor_bgfilter_seg_model(value: Option<&str>) -> Result<&'static str, AppError> { let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(EDITOR_BGFILTER_DEFAULT_SEG_MODEL); @@ -4002,131 +3627,6 @@ fn parse_editor_bgfilter_seg_model(value: Option<&str>) -> Result<&'static str, } } -fn editor_bgfilter_cross_check_form_value(enabled: bool) -> &'static str { - if enabled { "on" } else { "off" } -} - -fn map_editor_bgfilter_error(error: reqwest::Error, latency_ms: u64) -> AppError { - let status = if error.is_timeout() { - StatusCode::GATEWAY_TIMEOUT - } else { - StatusCode::BAD_GATEWAY - }; - AppError::from_status(status).with_details(json!({ - "provider": "bgfilter", - "message": format!("请求 BgFilter 服务失败:{error}"), - "timeout": error.is_timeout(), - "transport": true, - "latencyMs": latency_ms, - })) -} - -async fn read_editor_background_removal_bytes( - response: reqwest::Response, - request_started_at: Instant, -) -> Result, AppError> { - read_editor_image_removal_response_bytes(response, "bgfilter", "BgFilter", request_started_at) - .await -} - -async fn read_editor_image_removal_response_bytes( - mut response: reqwest::Response, - provider: &str, - service_label: &str, - request_started_at: Instant, -) -> Result, AppError> { - if response.content_length().is_some_and(|content_length| { - content_length > EDITOR_BACKGROUND_REMOVAL_MAX_RESPONSE_BYTES as u64 - }) { - return Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": provider, - "message": format!("{service_label}返回图片过大"), - "maxBytes": EDITOR_BACKGROUND_REMOVAL_MAX_RESPONSE_BYTES, - })), - ); - } - let mut bytes = Vec::new(); - // response.chunk() 失败属于「HTTP 状态已成功、读 body 时链路断裂」的传输层故障, - // 必须打上 transport/timeout/latencyMs/rawExcerpt,否则外部 API 失败审计会把它错记成 502/5xx。 - while let Some(chunk) = response.chunk().await.map_err(|error| { - editor_image_removal_body_read_error( - provider, - service_label, - error.is_timeout(), - request_started_at.elapsed().as_millis() as u64, - &error.to_string(), - ) - })? { - if bytes.len().saturating_add(chunk.len()) > EDITOR_BACKGROUND_REMOVAL_MAX_RESPONSE_BYTES { - return Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": provider, - "message": format!("{service_label}返回图片过大"), - "maxBytes": EDITOR_BACKGROUND_REMOVAL_MAX_RESPONSE_BYTES, - })), - ); - } - bytes.extend_from_slice(chunk.as_ref()); - } - Ok(bytes) -} - -/// 读取抠图上游响应体失败(response.chunk 中断)时构造的传输层失败错误。 -/// 与 `map_editor_bgfilter_error` 对齐:timeout 走 504、其余 502,均带 transport 标记以及 -/// latencyMs / rawExcerpt,供外部 API 失败审计正确归类为 transport 而非 5xx。 -fn editor_image_removal_body_read_error( - provider: &str, - service_label: &str, - is_timeout: bool, - latency_ms: u64, - error_text: &str, -) -> AppError { - let status = if is_timeout { - StatusCode::GATEWAY_TIMEOUT - } else { - StatusCode::BAD_GATEWAY - }; - AppError::from_status(status).with_details(json!({ - "provider": provider, - "message": format!("读取{service_label}结果失败:{error_text}"), - "timeout": is_timeout, - "transport": true, - "latencyMs": latency_ms, - "rawExcerpt": error_text, - })) -} - -fn decode_editor_background_removal_image(bytes: &[u8]) -> Result { - decode_editor_removed_background_image(bytes, "bgfilter") -} - -fn decode_editor_removed_background_image( - bytes: &[u8], - provider: &str, -) -> Result { - let mut reader = image::ImageReader::new(Cursor::new(bytes)) - .with_guessed_format() - .map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": provider, - "message": format!("识别抠图结果格式失败:{error}"), - })) - })?; - let mut limits = image::Limits::default(); - limits.max_image_width = Some(EDITOR_BACKGROUND_REMOVAL_MAX_IMAGE_DIMENSION); - limits.max_image_height = Some(EDITOR_BACKGROUND_REMOVAL_MAX_IMAGE_DIMENSION); - limits.max_alloc = Some(EDITOR_BACKGROUND_REMOVAL_MAX_RESPONSE_BYTES as u64 * 4); - reader.limits(limits); - reader.decode().map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": provider, - "message": format!("抠图结果不是有效图片:{error}"), - "maxDimension": EDITOR_BACKGROUND_REMOVAL_MAX_IMAGE_DIMENSION, - })) - }) -} - struct PersistEditorSpritesheetSlicesInput { owner_user_id: String, project_id: Option, @@ -4412,6 +3912,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( .clone() .or_else(|| payload.project_id.clone()), request_id: Some(request_context.request_id().to_string()), + external_call_deadline: request_context.external_call_deadline(), }, }), ) @@ -4512,6 +4013,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( .clone() .or_else(|| payload.project_id.clone()), request_id: Some(request_context.request_id().to_string()), + external_call_deadline: request_context.external_call_deadline(), }; let removal = remove_editor_generated_screen_background_with_bgfilter( state, @@ -5165,6 +4667,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( .clone() .or_else(|| payload.project_id.clone()), request_id: Some(request_context.request_id().to_string()), + external_call_deadline: request_context.external_call_deadline(), }, }), ) @@ -5265,6 +4768,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( .clone() .or_else(|| payload.project_id.clone()), request_id: Some(request_context.request_id().to_string()), + external_call_deadline: request_context.external_call_deadline(), }; let removal = remove_editor_generated_screen_background_with_bgfilter( state, @@ -7582,8 +7086,6 @@ async fn persist_editor_provider_source_resource( const EDITOR_REFERENCE_IMAGE_READ_EXPIRE_SECONDS: u64 = 300; const EDITOR_REFERENCE_IMAGE_MAX_SIZE_BYTES: u64 = 32 * 1024 * 1024; const EDITOR_BACKGROUND_REMOVAL_SOURCE_PROBE_BYTES: u64 = 16; -const EDITOR_BACKGROUND_REMOVAL_MAX_RESPONSE_BYTES: usize = 32 * 1024 * 1024; -const EDITOR_BACKGROUND_REMOVAL_MAX_IMAGE_DIMENSION: u32 = 8192; fn is_editor_inline_media_source(source: &str) -> bool { source @@ -7656,7 +7158,7 @@ fn normalize_editor_reference_image_sources(sources: Option<&[String]>, limit: u .collect() } -fn normalize_editor_reference_object_key(source: &str) -> Result { +pub(crate) fn normalize_editor_reference_object_key(source: &str) -> Result { let object_key = source.trim().trim_start_matches('/').to_string(); if object_key.is_empty() || LegacyAssetPrefix::from_object_key(object_key.as_str()).is_none() { return Err( @@ -8118,13 +7620,23 @@ mod tests { request } - fn spawn_bgfilter_png_mock( - response_png: Vec, - ) -> (String, mpsc::Receiver>, thread::JoinHandle<()>) { - spawn_bgfilter_png_mock_with_delay(response_png, Duration::ZERO) + fn parse_mock_http_json_body(request: &[u8]) -> Value { + let body_start = request + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|index| index + 4) + .expect("mock request should contain an HTTP header terminator"); + serde_json::from_slice(&request[body_start..]) + .expect("mock request body should be valid JSON") } - fn spawn_bgfilter_png_mock_with_delay( + fn spawn_bgfilter_worker_png_mock( + response_png: Vec, + ) -> (String, mpsc::Receiver>, thread::JoinHandle<()>) { + spawn_bgfilter_worker_png_mock_with_delay(response_png, Duration::ZERO) + } + + fn spawn_bgfilter_worker_png_mock_with_delay( response_png: Vec, response_delay: Duration, ) -> (String, mpsc::Receiver>, thread::JoinHandle<()>) { @@ -8134,14 +7646,16 @@ mod tests { .expect("mock listener should expose address"); let (request_sender, request_receiver) = mpsc::channel(); let server = thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("BgFilter request should connect"); + let (mut stream, _) = listener + .accept() + .expect("BgFilter worker request should connect"); let request = read_mock_http_request(&mut stream); request_sender .send(request) .expect("captured request should be delivered"); thread::sleep(response_delay); let headers = format!( - "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nX-BgFilter-Elapsed-Ms: 9\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", response_png.len() ); stream @@ -8221,80 +7735,6 @@ mod tests { assert_eq!(error.status_code(), StatusCode::CONFLICT); } - #[test] - fn bgfilter_body_read_failure_audits_as_transport_not_5xx() { - // HTTP 状态已成功、但读 body 时链路断裂:外部 API 失败审计必须归类为 transport, - // 并保留 latencyMs / rawExcerpt,而不是被错记成 502/5xx。 - let error = editor_image_removal_body_read_error( - "bgfilter", - "BgFilter", - false, - 137, - "error reading a body from connection: connection reset", - ); - - assert_eq!(error.status_code(), StatusCode::BAD_GATEWAY); - assert_eq!( - crate::external_api_audit::matting_failure_audit_status_code(&error), - None, - "body-read 传输故障不应带 HTTP statusCode,否则会落成 5xx" - ); - assert!(!crate::external_api_audit::matting_failure_audit_timeout( - &error - )); - assert_eq!( - crate::external_api_audit::matting_failure_audit_latency_ms(&error), - Some(137) - ); - assert_eq!( - crate::external_api_audit::matting_failure_audit_raw_excerpt(&error).as_deref(), - Some("error reading a body from connection: connection reset") - ); - } - - #[test] - fn bgfilter_upstream_message_redacts_signed_oss_url() { - let signed_url = "https://dev-bucket.oss-cn-beijing.aliyuncs.com/generated/input.png?x-oss-signature=secret&x-oss-expires=600"; - let message = format!("invalid image_url: {signed_url}; code=fetch_failed"); - - let sanitized = sanitize_editor_bgfilter_upstream_message(&message, signed_url); - - assert!(!sanitized.contains("x-oss-signature")); - assert!(!sanitized.contains("secret")); - assert!(sanitized.contains("[signed OSS URL redacted]")); - assert!(sanitized.contains("fetch_failed")); - - let escaped_message = format!("invalid image_url: {}", signed_url.replace('&', "\\u0026")); - let escaped_sanitized = - sanitize_editor_bgfilter_upstream_message(&escaped_message, signed_url); - assert!(!escaped_sanitized.contains("x-oss-")); - assert!(!escaped_sanitized.contains("secret")); - } - - #[test] - fn bgfilter_body_read_timeout_maps_to_gateway_timeout_and_transport() { - let error = editor_image_removal_body_read_error( - "bgfilter", - "BgFilter", - true, - 42, - "operation timed out", - ); - - assert_eq!(error.status_code(), StatusCode::GATEWAY_TIMEOUT); - assert_eq!( - crate::external_api_audit::matting_failure_audit_status_code(&error), - None - ); - assert!(crate::external_api_audit::matting_failure_audit_timeout( - &error - )); - assert_eq!( - crate::external_api_audit::matting_failure_audit_latency_ms(&error), - Some(42) - ); - } - fn manual_screen_background_decision(hex: &str) -> EditorScreenBackgroundDecision { EditorScreenBackgroundDecision { color: parse_editor_screen_background_color(Some(hex)) @@ -10877,34 +10317,91 @@ mod tests { } #[test] - fn editor_bgfilter_cross_check_form_value_matches_service_contract() { - assert_eq!(editor_bgfilter_cross_check_form_value(true), "on"); - assert_eq!(editor_bgfilter_cross_check_form_value(false), "off"); - } + fn editor_bgfilter_worker_budget_covers_two_attempts_and_parent_reserves() { + assert_eq!( + editor_bgfilter_worker_request_budget_limit_ms(180_000), + 361_000 + ); + assert_eq!( + editor_bgfilter_flat_deadline_reserve(30_000), + Duration::from_secs(39) + ); - #[test] - fn editor_bgfilter_background_mode_matches_service_contract() { - assert_eq!(EDITOR_BGFILTER_BACKGROUND_MODE_FLAT, "flat"); - assert_eq!(EDITOR_BGFILTER_BACKGROUND_MODE_COMPLEX, "complex"); + let complex_deadline = Instant::now() + Duration::from_secs(10); + let complex_budget = crate::bgfilter_worker::request_budget_ms( + editor_bgfilter_worker_request_budget_limit_ms(180_000), + Some(complex_deadline), + EDITOR_BGFILTER_PARENT_TRANSPORT_WINDOW, + ); + assert!((7_500..=8_000).contains(&complex_budget)); + + let flat_deadline = Instant::now() + Duration::from_secs(50); + let flat_budget = crate::bgfilter_worker::request_budget_ms( + editor_bgfilter_worker_request_budget_limit_ms(180_000), + Some(flat_deadline), + editor_bgfilter_flat_deadline_reserve(30_000), + ); + assert!((10_500..=11_000).contains(&flat_budget)); } #[tokio::test] - async fn editor_generated_bgfilter_request_timeout_overrides_shared_client_default() { + async fn editor_flat_bgfilter_expired_parent_deadline_does_not_start_fallback() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + let audit = crate::external_api_audit::ExternalApiAuditContext { + user_id: Some("user-deadline".to_string()), + profile_id: None, + request_id: Some("request-deadline".to_string()), + external_call_deadline: Instant::now().checked_sub(Duration::from_millis(1)), + }; + + let error = + match remove_editor_generated_screen_background_with_bgfilter_with_request_timeout( + &state, + "generated-character-drafts/editor/source.png", + parse_editor_screen_background_color(Some("#CFEFFF")) + .expect("screen color should parse"), + EDITOR_BGFILTER_DEFAULT_SEG_MODEL, + EDITOR_BGFILTER_CROSS_CHECK_ENABLED, + 180_000, + &audit, + ) + .await + { + Ok(_) => panic!("expired parent deadline should stop before flat fallback"), + Err(error) => error, + }; + + assert_eq!(error.status_code(), StatusCode::GATEWAY_TIMEOUT); + assert_eq!( + error + .details() + .and_then(|details| details.get("workerCode")), + Some(&json!("deadline_exceeded")) + ); + } + + #[tokio::test] + async fn editor_flat_bgfilter_background_removal_uses_internal_worker_once() { let response_png = encode_test_png(3, 2); - let (base_url, request_receiver, server) = - spawn_bgfilter_png_mock_with_delay(response_png.clone(), Duration::from_millis(100)); + let (base_url, request_receiver, server) = spawn_bgfilter_worker_png_mock_with_delay( + response_png.clone(), + Duration::from_millis(100), + ); let state = AppState::new(AppConfig { - editor_bgfilter_base_url: base_url, + bgfilter_worker_base_url: base_url, + bgfilter_internal_token: Some("flat-internal-token".to_string()), editor_bgfilter_request_timeout_ms: 20, - oss_bucket: Some("genarrative-assets".to_string()), - oss_endpoint: Some("oss-cn-beijing.aliyuncs.com".to_string()), - oss_access_key_id: Some("test-ak".to_string()), - oss_access_key_secret: Some("test-sk".to_string()), ..AppConfig::default() }) .expect("state should build"); + let audit = crate::external_api_audit::ExternalApiAuditContext { + user_id: Some("user-flat".to_string()), + profile_id: Some("project-flat".to_string()), + request_id: Some("request-flat".to_string()), + external_call_deadline: None, + }; - let removed = request_editor_generated_screen_background_with_bgfilter( + let removed = remove_editor_generated_screen_background_with_bgfilter_with_request_timeout( &state, "generated-character-drafts/editor/source.png", parse_editor_screen_background_color(Some("#CFEFFF")) @@ -10912,97 +10409,106 @@ mod tests { EDITOR_BGFILTER_DEFAULT_SEG_MODEL, EDITOR_BGFILTER_CROSS_CHECK_ENABLED, 2_000, + &audit, ) .await - .expect("request timeout override should outlive the shared client default"); - request_receiver - .recv_timeout(Duration::from_secs(1)) - .expect("mock server should capture request"); - server.join().expect("mock server should stop cleanly"); - - assert_eq!(removed.bytes, response_png); - assert_eq!(removed.mime_type, "image/png"); - assert_eq!(removed.extension, "png"); - } - - #[tokio::test] - async fn editor_manual_background_removal_sends_complex_bgfilter_http_contract() { - let response_png = encode_test_png(3, 2); - let (base_url, request_receiver, server) = spawn_bgfilter_png_mock(response_png.clone()); - let state = AppState::new(AppConfig { - editor_bgfilter_base_url: base_url, - editor_bgfilter_token: Some("manual-complex-token".to_string()), - editor_bgfilter_request_timeout_ms: 5_000, - oss_bucket: Some("genarrative-assets".to_string()), - oss_endpoint: Some("oss-cn-beijing.aliyuncs.com".to_string()), - oss_access_key_id: Some("test-ak".to_string()), - oss_access_key_secret: Some("test-sk".to_string()), - ..AppConfig::default() - }) - .expect("state should build"); - - let removed = request_editor_background_removal_image_with_retry( - &state, - "generated-character-drafts/editor/manual-source.png", - ) - .await - .expect("BgFilter PNG response should decode"); + .expect("internal worker PNG response should decode"); let request = request_receiver .recv_timeout(Duration::from_secs(1)) .expect("mock server should capture request"); server.join().expect("mock server should stop cleanly"); let request_text = String::from_utf8_lossy(request.as_slice()); let lowercase_request = request_text.to_ascii_lowercase(); + let payload = parse_mock_http_json_body(request.as_slice()); - assert!(request_text.starts_with("POST /remove-background HTTP/1.1\r\n")); assert!( - lowercase_request.contains("\r\nx-genarrative-image-token: manual-complex-token\r\n") + request_text.starts_with("POST /internal/bgfilter/v1/remove-background HTTP/1.1\r\n") + ); + assert!(lowercase_request.contains("\r\nauthorization: bearer flat-internal-token\r\n")); + let internal_request_id = payload["requestId"] + .as_str() + .expect("internal request id should be a string"); + assert!(uuid::Uuid::parse_str(internal_request_id).is_ok()); + assert_ne!(internal_request_id, "request-flat"); + assert_eq!( + payload["sourceObjectKey"], + json!("generated-character-drafts/editor/source.png") + ); + assert_eq!(payload["backgroundMode"], json!("flat")); + assert_eq!(payload["screenColor"], json!("#CFEFFF")); + assert_eq!(payload["segModel"], json!("birefnet")); + assert_eq!(payload["crossCheck"], json!(true)); + assert_eq!(payload["requestBudgetMs"], json!(5_000)); + assert_eq!(payload["auditContext"]["userId"], json!("user-flat")); + assert_eq!(payload["auditContext"]["requestId"], json!("request-flat")); + + assert_eq!(removed.image.bytes, response_png); + assert_eq!(removed.image.mime_type, "image/png"); + assert_eq!(removed.image.extension, "png"); + assert_eq!(removed.provider, "BgFilter"); + } + + #[tokio::test] + async fn editor_complex_bgfilter_background_removal_uses_internal_worker_once() { + let response_png = encode_test_png(3, 2); + let (base_url, request_receiver, server) = + spawn_bgfilter_worker_png_mock(response_png.clone()); + let state = AppState::new(AppConfig { + bgfilter_worker_base_url: base_url, + bgfilter_internal_token: Some("complex-internal-token".to_string()), + editor_bgfilter_request_timeout_ms: 5_000, + ..AppConfig::default() + }) + .expect("state should build"); + let audit = crate::external_api_audit::ExternalApiAuditContext { + user_id: Some("user-complex".to_string()), + profile_id: None, + request_id: Some("request-complex".to_string()), + external_call_deadline: None, + }; + + let removed = request_editor_background_removal_image_with_bgfilter_worker( + &state, + "generated-character-drafts/editor/manual-source.png", + &audit, + ) + .await + .expect("internal worker PNG response should decode"); + let request = request_receiver + .recv_timeout(Duration::from_secs(1)) + .expect("mock server should capture request"); + server.join().expect("mock server should stop cleanly"); + let request_text = String::from_utf8_lossy(request.as_slice()); + let lowercase_request = request_text.to_ascii_lowercase(); + let payload = parse_mock_http_json_body(request.as_slice()); + + assert!( + request_text.starts_with("POST /internal/bgfilter/v1/remove-background HTTP/1.1\r\n") + ); + assert!(lowercase_request.contains("\r\nauthorization: bearer complex-internal-token\r\n")); + let internal_request_id = payload["requestId"] + .as_str() + .expect("internal request id should be a string"); + assert!(uuid::Uuid::parse_str(internal_request_id).is_ok()); + assert_ne!(internal_request_id, "request-complex"); + assert_eq!( + payload["sourceObjectKey"], + json!("generated-character-drafts/editor/manual-source.png") + ); + assert_eq!(payload["backgroundMode"], json!("complex")); + assert!(payload["screenColor"].is_null()); + assert_eq!(payload["segModel"], json!("birefnet")); + assert_eq!(payload["crossCheck"], json!(false)); + assert_eq!(payload["requestBudgetMs"], json!(11_000)); + assert_eq!( + payload["auditContext"]["requestId"], + json!("request-complex") ); - assert!(request_text.contains("name=\"image_url\"")); - assert!(request_text.contains("genarrative-assets.oss-cn-beijing.aliyuncs.com")); - assert!(lowercase_request.contains("x-oss-signature-version")); - assert!(!request_text.contains("name=\"file\"")); - assert!(request_text.contains("name=\"background_mode\"\r\n\r\ncomplex\r\n")); - assert!(request_text.contains("name=\"seg_model\"\r\n\r\nbirefnet\r\n")); - assert!(request_text.contains("name=\"cross_check\"\r\n\r\noff\r\n")); - assert!(!request_text.contains("name=\"screen_color\"")); assert_eq!((removed.width, removed.height), (3, 2)); assert_eq!(removed.image.mime_type, "image/png"); assert_eq!(removed.image.extension, "png"); assert_eq!(removed.image.bytes, response_png); - - let provider_error = decode_editor_background_removal_image(b"not an image") - .expect_err("invalid BgFilter response should fail decoding"); - assert_eq!( - provider_error - .details() - .and_then(|details| details.get("provider")), - Some(&json!("bgfilter")) - ); - } - - #[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] @@ -11108,7 +10614,7 @@ mod tests { "caller.report_processing_phase(state).await?", "resolve_editor_reference_object_key_for_owner", "validate_editor_background_removal_source", - "request_editor_background_removal_image_with_retry", + "request_editor_background_removal_image_with_bgfilter_worker", ], ); assert_function_contains( @@ -11148,21 +10654,30 @@ mod tests { "async fn remove_editor_generated_screen_background_with_bgfilter_with_request_timeout", "async fn fallback_editor_screen_background_removal", &[ - // 熔断打开分支必须先委派统一兜底链(阿里云→本地),不能直接退化本地扣色; - // BgFilter 调用失败分支同样走该兜底链。 - "editor_bgfilter_circuit_open_remaining", - "fallback_editor_screen_background_removal", - "EDITOR_BGFILTER_RETRY_COUNT + 1", - "request_editor_generated_screen_background_with_bgfilter", - "will_retry", - "record_matting_external_api_failure", + "editor_bgfilter_worker_request_budget_limit_ms(request_timeout_ms)", + "editor_bgfilter_flat_deadline_reserve", + "crate::bgfilter_worker::request_bgfilter_worker", + "crate::bgfilter_worker::BgfilterBackgroundMode::Flat", + "error.allows_flat_fallback()", "fallback_editor_screen_background_removal", ], ); + assert_function_not_contains( + source, + "async fn remove_editor_generated_screen_background_with_bgfilter_with_request_timeout", + "async fn fallback_editor_screen_background_removal", + &[ + "for attempt in", + concat!("EDITOR_BGFILTER_", "RETRY_COUNT"), + concat!("editor_bgfilter_", "circuit"), + "reqwest::multipart", + concat!("editor_bgfilter_", "http_client"), + ], + ); assert_function_contains_in_order( source, "async fn fallback_editor_screen_background_removal", - "async fn request_editor_generated_screen_background_with_bgfilter", + "fn sign_editor_private_object_read_url", &[ "segment_image_url_with_aliyun_matting", "download_editor_persisted_image_object", @@ -11172,67 +10687,32 @@ mod tests { ); assert_function_contains( source, - "async fn request_editor_generated_screen_background_with_bgfilter", - "async fn request_editor_background_removal_image(", + "async fn request_editor_background_removal_image_with_bgfilter_worker", + "pub(crate) struct EditorScreenBackgroundRemovalOutput", &[ - "editor_bgfilter_endpoint", - "sign_editor_private_object_read_url", - "EDITOR_MATTING_SOURCE_URL_EXPIRE_SECONDS", - "\"image_url\"", - "request_timeout_ms.max(1)", - "state.editor_bgfilter_http_client()", - ".timeout(Duration::from_millis(timeout_ms))", - "\"screen_color\"", - "\"seg_model\"", - "seg_model.to_string()", - "\"background_mode\"", - "EDITOR_BGFILTER_BACKGROUND_MODE_FLAT", - "\"cross_check\"", - "editor_bgfilter_cross_check_form_value(cross_check)", - ], - ); - assert_function_not_contains( - source, - "async fn request_editor_generated_screen_background_with_bgfilter", - "async fn request_editor_background_removal_image(", - &[ - ".part(\"file\"", - "reqwest::multipart::Part::bytes", - "reqwest::Client::builder", - ], - ); - assert_function_contains( - source, - "async fn request_editor_background_removal_image(", - "fn editor_bgfilter_endpoint", - &[ - "editor_bgfilter_endpoint", - "sign_editor_private_object_read_url", - "EDITOR_MATTING_SOURCE_URL_EXPIRE_SECONDS", - "\"image_url\"", - "editor_bgfilter_request_timeout_ms.max(1)", - "state.editor_bgfilter_http_client()", - "editor_bgfilter_token", - "\"background_mode\"", - "EDITOR_BGFILTER_BACKGROUND_MODE_COMPLEX", - "\"seg_model\"", + "editor_bgfilter_worker_request_budget_limit_ms", + "crate::bgfilter_worker::request_bgfilter_worker", + "crate::bgfilter_worker::BgfilterBackgroundMode::Complex", "EDITOR_BGFILTER_DEFAULT_SEG_MODEL", - "\"cross_check\"", "EDITOR_BGFILTER_CROSS_CHECK_DISABLED", - "\"provider\": \"bgfilter\"", + ".map_err(crate::bgfilter_worker::BgfilterClientError::into_app_error)?", ], ); assert_function_not_contains( source, - "async fn request_editor_background_removal_image(", - "fn editor_bgfilter_endpoint", + "async fn request_editor_background_removal_image_with_bgfilter_worker", + "pub(crate) struct EditorScreenBackgroundRemovalOutput", &[ - "\"screen_color\"", - ".part(\"file\"", - "reqwest::multipart::Part::bytes", - "reqwest::Client::builder", + "for attempt in", + "fallback_editor_screen_background_removal", + "reqwest::multipart", + concat!("editor_bgfilter_", "http_client"), + concat!("editor_bgfilter_", "base_url"), + concat!("editor_bgfilter_", "token"), ], ); + assert!(!source.contains(concat!("const EDITOR_BGFILTER_", "RETRY_COUNT"))); + assert!(!source.contains(concat!("static EDITOR_BGFILTER_", "CIRCUIT"))); assert_function_not_contains( source, "pub(crate) async fn generate_editor_image_for_owner", @@ -11296,24 +10776,27 @@ mod tests { } #[test] - fn editor_bgfilter_retries_once_before_fallback() { + fn editor_flat_bgfilter_parent_calls_internal_worker_once_before_fallback() { let source = include_str!("editor_project.rs"); - assert!(source.contains("const EDITOR_BGFILTER_RETRY_COUNT: usize = 1;")); - assert_function_contains_in_order( + assert_function_contains( source, "async fn remove_editor_generated_screen_background_with_bgfilter_with_request_timeout", "async fn fallback_editor_screen_background_removal", &[ - "let max_attempts = EDITOR_BGFILTER_RETRY_COUNT + 1", - "for attempt in 1..=max_attempts", - "request_editor_generated_screen_background_with_bgfilter", - "let will_retry = attempt < max_attempts", - "if will_retry", - "continue", - "final_error = Some(error)", + "crate::bgfilter_worker::request_bgfilter_worker", "fallback_editor_screen_background_removal", ], ); + assert_function_not_contains( + source, + "async fn remove_editor_generated_screen_background_with_bgfilter_with_request_timeout", + "async fn fallback_editor_screen_background_removal", + &[ + "for attempt in", + "will_retry", + concat!("EDITOR_BGFILTER_", "RETRY_COUNT"), + ], + ); } #[test] @@ -11537,20 +11020,25 @@ mod tests { } #[test] - fn editor_manual_background_removal_retries_once() { + fn editor_manual_background_removal_calls_internal_worker_once_without_fallback() { let source = include_str!("editor_project.rs"); - assert_function_contains_in_order( + assert_function_contains( source, - "async fn request_editor_background_removal_image_with_retry", - "async fn request_editor_background_removal_image(", + "async fn request_editor_background_removal_image_with_bgfilter_worker", + "pub(crate) struct EditorScreenBackgroundRemovalOutput", &[ - "let max_attempts = EDITOR_BGFILTER_RETRY_COUNT + 1", - "for attempt in 1..=max_attempts", - "request_editor_background_removal_image(state, source_object_key)", - "let will_retry = attempt < max_attempts", - "if will_retry", - "continue", - "final_error = Some(error)", + "crate::bgfilter_worker::request_bgfilter_worker", + "BgfilterBackgroundMode::Complex", + ], + ); + assert_function_not_contains( + source, + "async fn request_editor_background_removal_image_with_bgfilter_worker", + "pub(crate) struct EditorScreenBackgroundRemovalOutput", + &[ + "for attempt in", + "will_retry", + "fallback_editor_screen_background_removal", ], ); } diff --git a/server-rs/crates/api-server/src/editor_screen_background_decision.rs b/server-rs/crates/api-server/src/editor_screen_background_decision.rs index 1d2d872b1..fc355a755 100644 --- a/server-rs/crates/api-server/src/editor_screen_background_decision.rs +++ b/server-rs/crates/api-server/src/editor_screen_background_decision.rs @@ -745,6 +745,7 @@ mod tests { user_id: Some("user-1".to_string()), profile_id: Some("project-1".to_string()), request_id: Some("request-1".to_string()), + external_call_deadline: None, }, ); let tracking = crate::external_api_audit::build_external_api_failure_tracking_draft(&audit); diff --git a/server-rs/crates/api-server/src/external_api_audit.rs b/server-rs/crates/api-server/src/external_api_audit.rs index 0039f7b0a..f8eff93b9 100644 --- a/server-rs/crates/api-server/src/external_api_audit.rs +++ b/server-rs/crates/api-server/src/external_api_audit.rs @@ -1,3 +1,5 @@ +use std::time::Instant; + #[cfg(test)] use axum::http::StatusCode; use module_runtime::RuntimeTrackingScopeKind; @@ -145,6 +147,9 @@ pub(crate) struct ExternalApiAuditContext { pub(crate) user_id: Option, pub(crate) profile_id: Option, pub(crate) request_id: Option, + /// 父流程允许外部调用占用到的绝对时刻。该字段只在进程内用于预算截断, + /// 不写入 tracking metadata,也不会通过内部协议传递绝对时间。 + pub(crate) external_call_deadline: Option, } /// 抠图供应商(BgFilter / 阿里云通用抠图)调用失败的统一失败审计入口。 diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 71c705dc6..43d70f800 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -16,6 +16,7 @@ mod auth_public_user; mod auth_session; mod auth_sessions; mod backpressure; +mod bgfilter_worker; mod character_animation_assets; mod character_visual_assets; mod config; @@ -92,6 +93,7 @@ use tracing::{error, info, warn}; use crate::{ app::{build_router, build_spacetime_unavailable_router}, + bgfilter_worker::build_bgfilter_worker_router, config::{AppConfig, ProcessRole}, external_generation_worker::run_external_generation_worker, external_generation_worker_controller::run_external_generation_worker_controller, @@ -150,6 +152,10 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> { process_metrics::register_process_metrics(); telemetry::register_http_runtime_metrics(); + if config.process_role.runs_bgfilter_worker() { + return run_bgfilter_worker_role(config).await; + } + if !config.process_role.runs_http() { return run_worker_only(config).await; } @@ -157,6 +163,100 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> { run_http_role(config).await } +async fn run_bgfilter_worker_role(mut config: AppConfig) -> Result<(), io::Error> { + let (concurrency, max_requests) = required_bgfilter_worker_capacity_from_env()?; + config.bgfilter_worker_concurrency = concurrency; + config.bgfilter_worker_max_requests = max_requests; + let bind_address = format!( + "{}:{}", + config.bgfilter_worker_host, config.bgfilter_worker_port + ) + .parse::() + .map_err(|error| io::Error::other(format!("bgfilter-worker 监听地址无效:{error}")))?; + if !bind_address.ip().is_loopback() { + return Err(io::Error::other(format!( + "bgfilter-worker 首版只允许监听 loopback,当前地址为 {bind_address}" + ))); + } + let listen_backlog = config.listen_backlog; + let outbox_flush_timeout = config.shutdown_outbox_flush_timeout; + let listener = build_tcp_listener(bind_address, listen_backlog)?; + + // 专用 worker 不共享 api/extgen 进程的落盘 outbox,避免多个进程并发操作同一路径。 + // provider 失败审计仍通过 AppState 的无 outbox 路径 best-effort 写入 SpacetimeDB。 + config.tracking_outbox_enabled = false; + config.wallet_refund_outbox_enabled = false; + let state = AppState::new_with_empty_auth_store(config) + .map_err(|error| io::Error::other(format!("初始化 bgfilter-worker 状态失败:{error}")))?; + let (router, task_tracker) = build_bgfilter_worker_router(state.clone()) + .map_err(|error| io::Error::other(format!("初始化 bgfilter-worker 路由失败:{error}")))?; + let shutdown_context = ShutdownContext { + app_state: Some(state), + tracking_outbox: None, + wallet_refund_outbox: None, + outbox_flush_timeout, + }; + info!( + %bind_address, + listen_backlog, + process_role = ProcessRole::BgfilterWorker.as_str(), + "bgfilter-worker 已开始监听内部 HTTP" + ); + let shutdown_tracker = task_tracker.clone(); + let shutdown_context_for_signal = shutdown_context.clone(); + let result = axum::serve(listener, router) + .with_graceful_shutdown(async move { + shutdown_signal(shutdown_context_for_signal).await; + shutdown_tracker.close(); + }) + .await; + task_tracker.close(); + task_tracker.wait_for_drain().await; + finalize_shutdown(shutdown_context).await; + result +} + +fn required_bgfilter_worker_capacity_from_env() -> Result<(usize, usize), io::Error> { + let concurrency = env::var("GENARRATIVE_BGFILTER_WORKER_CONCURRENCY").ok(); + let max_requests = env::var("GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS").ok(); + parse_required_bgfilter_worker_capacity(concurrency.as_deref(), max_requests.as_deref()) +} + +fn parse_required_bgfilter_worker_capacity( + concurrency: Option<&str>, + max_requests: Option<&str>, +) -> Result<(usize, usize), io::Error> { + fn parse_required_positive(name: &str, raw: Option<&str>) -> Result { + let value = raw + .map(strip_env_value) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| io::Error::other(format!("bgfilter-worker 启动必须显式配置 {name}")))?; + let parsed = value.parse::().map_err(|error| { + io::Error::other(format!( + "bgfilter-worker 配置 {name} 不是有效正整数:{error}" + )) + })?; + if parsed == 0 { + return Err(io::Error::other(format!( + "bgfilter-worker 配置 {name} 必须大于 0" + ))); + } + Ok(parsed) + } + + let concurrency = + parse_required_positive("GENARRATIVE_BGFILTER_WORKER_CONCURRENCY", concurrency)?; + let max_requests = + parse_required_positive("GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS", max_requests)?; + if max_requests < concurrency { + return Err(io::Error::other( + "GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS 不能小于 GENARRATIVE_BGFILTER_WORKER_CONCURRENCY", + )); + } + Ok((concurrency, max_requests)) +} + async fn run_worker_only(config: AppConfig) -> Result<(), io::Error> { let process_role = config.process_role; let state = build_non_http_app_state_for_startup(config).map_err(|error| { @@ -545,7 +645,8 @@ fn is_valid_env_key(key: &str) -> bool { #[cfg(test)] mod tests { use super::{ - AUTH_STORE_STARTUP_RETRY_INTERVAL, is_valid_env_key, protected_env_keys_from, + AUTH_STORE_STARTUP_RETRY_INTERVAL, is_valid_env_key, + 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, strip_env_value, @@ -559,6 +660,28 @@ mod tests { assert_eq!(strip_env_value("plain\r"), "plain"); } + #[test] + fn bgfilter_worker_capacity_must_be_explicit_positive_and_bounded_by_q() { + assert_eq!( + parse_required_bgfilter_worker_capacity(Some("'4'"), Some(" 128 ")) + .expect("valid explicit N/Q"), + (4, 128) + ); + for (concurrency, max_requests) in [ + (None, Some("128")), + (Some("4"), None), + (Some(""), Some("128")), + (Some("0"), Some("128")), + (Some("four"), Some("128")), + (Some("8"), Some("4")), + ] { + assert!( + parse_required_bgfilter_worker_capacity(concurrency, max_requests).is_err(), + "invalid N/Q should fail closed: N={concurrency:?}, Q={max_requests:?}" + ); + } + } + #[test] fn load_env_key_can_strip_utf8_bom_prefix() { let key = "\u{feff}SMS_AUTH_ENABLED" @@ -601,6 +724,9 @@ mod tests { fn auth_store_startup_restore_is_limited_to_http_roles() { assert!(should_restore_auth_store_for_startup(ProcessRole::Api)); assert!(should_restore_auth_store_for_startup(ProcessRole::All)); + assert!(!should_restore_auth_store_for_startup( + ProcessRole::BgfilterWorker + )); assert!(!should_restore_auth_store_for_startup( ProcessRole::ExternalGenerationWorker )); @@ -617,6 +743,9 @@ mod tests { assert!(should_initialize_editor_generation_pricing_for_startup( ProcessRole::All )); + assert!(!should_initialize_editor_generation_pricing_for_startup( + ProcessRole::BgfilterWorker + )); assert!(!should_initialize_editor_generation_pricing_for_startup( ProcessRole::ExternalGenerationWorker )); @@ -633,6 +762,9 @@ mod tests { assert!(should_start_profile_recharge_expiration_listener( ProcessRole::All )); + assert!(!should_start_profile_recharge_expiration_listener( + ProcessRole::BgfilterWorker + )); assert!(!should_start_profile_recharge_expiration_listener( ProcessRole::ExternalGenerationWorker )); diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 6d0b18d9a..a931525f2 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -49,6 +49,7 @@ use crate::work_author::{ const ADMIN_ROLE: &str = "admin"; pub(crate) const CHARACTER_ANIMATION_OSS_MAX_CONCURRENCY: usize = 8; +pub(crate) const BGFILTER_IMAGE_VALIDATION_MAX_CONCURRENCY: usize = 4; pub type HttpRequestPermitPool = Semaphore; @@ -259,7 +260,9 @@ pub struct AppStateInner { llm_client: Option, editor_agent_llm_client: Option, matting_client: Option, - editor_bgfilter_http_client: reqwest::Client, + bgfilter_provider_http_client: reqwest::Client, + bgfilter_worker_http_client: reqwest::Client, + bgfilter_image_validation_limiter: Arc, character_animation_oss_http_client: reqwest::Client, character_animation_oss_io_limiter: Arc, #[cfg(any())] @@ -504,7 +507,18 @@ impl AppState { let llm_client = build_llm_client(&config)?; let editor_agent_llm_client = build_editor_agent_llm_client(&config)?; let matting_client = build_matting_client(&config)?; - let editor_bgfilter_http_client = build_editor_bgfilter_http_client(&config)?; + let bgfilter_provider_http_client = build_bgfilter_provider_http_client(&config)?; + let bgfilter_worker_http_client = build_bgfilter_worker_http_client(&config)?; + let bgfilter_image_validation_concurrency = if config.process_role.runs_bgfilter_worker() { + // 子 worker 已由 provider N 限流;图片校验槽与 N 对齐,避免引入第二个隐藏吞吐上限。 + config.bgfilter_worker_concurrency.max(1) + } else { + // 父 API / external-generation worker 固定限制解码并发,避免动画响应同时进入 blocking pool。 + BGFILTER_IMAGE_VALIDATION_MAX_CONCURRENCY + }; + let bgfilter_image_validation_limiter = Arc::new(Semaphore::new( + bgfilter_image_validation_concurrency.min(Semaphore::MAX_PERMITS), + )); let character_animation_oss_http_client = build_character_animation_oss_http_client()?; let character_animation_oss_io_limiter = Arc::new(Semaphore::new(CHARACTER_ANIMATION_OSS_MAX_CONCURRENCY)); @@ -549,7 +563,9 @@ impl AppState { llm_client, editor_agent_llm_client, matting_client, - editor_bgfilter_http_client, + bgfilter_provider_http_client, + bgfilter_worker_http_client, + bgfilter_image_validation_limiter, character_animation_oss_http_client, character_animation_oss_io_limiter, #[cfg(any())] @@ -1264,8 +1280,16 @@ impl AppState { self.matting_client.as_ref() } - pub fn editor_bgfilter_http_client(&self) -> &reqwest::Client { - &self.editor_bgfilter_http_client + pub fn bgfilter_provider_http_client(&self) -> &reqwest::Client { + &self.bgfilter_provider_http_client + } + + pub fn bgfilter_worker_http_client(&self) -> &reqwest::Client { + &self.bgfilter_worker_http_client + } + + pub fn bgfilter_image_validation_limiter(&self) -> Arc { + self.bgfilter_image_validation_limiter.clone() } pub fn character_animation_oss_http_client(&self) -> &reqwest::Client { @@ -1988,10 +2012,11 @@ fn build_matting_client(config: &AppConfig) -> Result, App .map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string())) } -fn build_editor_bgfilter_http_client( +fn build_bgfilter_provider_http_client( config: &AppConfig, ) -> Result { reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) .timeout(std::time::Duration::from_millis( config.editor_bgfilter_request_timeout_ms.max(1), )) @@ -2002,7 +2027,26 @@ fn build_editor_bgfilter_http_client( .build() .map_err(|error| { AppStateInitError::DependencyUnavailable(format!( - "构建共享 BgFilter HTTP 客户端失败:{error}" + "构建 BgFilter provider HTTP 客户端失败:{error}" + )) + }) +} + +fn build_bgfilter_worker_http_client( + config: &AppConfig, +) -> Result { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(std::time::Duration::from_millis( + config.bgfilter_worker_connect_timeout_ms.max(1), + )) + .pool_idle_timeout(std::time::Duration::from_secs(300)) + .pool_max_idle_per_host(128) + .tcp_keepalive(std::time::Duration::from_secs(60)) + .build() + .map_err(|error| { + AppStateInitError::DependencyUnavailable(format!( + "构建 BgFilter 内部 worker HTTP 客户端失败:{error}" )) }) } @@ -2167,6 +2211,30 @@ mod tests { ); } + #[test] + fn bgfilter_image_validation_limiter_is_bounded_per_process_role() { + let parent = AppState::new(AppConfig::default()).expect("parent state should build"); + assert_eq!( + parent + .bgfilter_image_validation_limiter() + .available_permits(), + BGFILTER_IMAGE_VALIDATION_MAX_CONCURRENCY + ); + + let worker = AppState::new(AppConfig { + process_role: crate::config::ProcessRole::BgfilterWorker, + bgfilter_worker_concurrency: 6, + ..AppConfig::default() + }) + .expect("worker state should build"); + assert_eq!( + worker + .bgfilter_image_validation_limiter() + .available_permits(), + 6 + ); + } + #[test] fn editor_generation_pricing_typed_record_round_trips() { let expected = crate::editor_generation_config::parse_editor_generation_pricing_json( -- 2.52.0 From 7ea7dce3956a517bc271d4abf0e2b946b8149352 Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 21 Jul 2026 14:02:13 +0000 Subject: [PATCH 09/27] =?UTF-8?q?=E8=A1=A5=E5=85=85BgFilter=E5=85=A8?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B=E4=B8=8E=E7=9C=9F=E5=AE=9E=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=E5=86=92=E7=83=9F=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增32/40/48并发、五类故障与harness自测。 新增真实OSS和BgFilter契约冒烟并严格清理临时对象。 补充本地运行方式、凭据边界与staging验收范围。 --- ...架构】BgFilter受限资源调度方案-2026-07-21.md | 17 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 + package.json | 3 + scripts/bgfilter-worker-load-smoke.mjs | 1348 +++++++++++++++++ scripts/bgfilter-worker-load-smoke.test.mjs | 190 +++ .../examples/bgfilter_worker_live_smoke.rs | 830 ++++++++++ 6 files changed, 2392 insertions(+) create mode 100644 scripts/bgfilter-worker-load-smoke.mjs create mode 100644 scripts/bgfilter-worker-load-smoke.test.mjs create mode 100644 server-rs/crates/platform-oss/examples/bgfilter_worker_live_smoke.rs diff --git a/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md b/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md index ec536408f..73aff03bf 100644 --- a/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md +++ b/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md @@ -464,6 +464,23 @@ BgFilter 成功二进制不是一份新的业务资产: - 生产运行期巡检同时检查 `genarrative-bgfilter-worker.service` 为 active 且 `127.0.0.1:8083/readyz` 成功,不能只依赖 systemd 自动重启。 - `npm run dev` 启动独立 BgFilter 子进程并使用解析后的第五个端口;`all` 角色不内嵌 listener,单模块入口、watch、状态文件和退出清理没有遗留进程或硬编码端口。 +本地全进程调度门禁使用已构建的 `api-server` binary 和 loopback mock provider,不读取真实 OSS / BgFilter 密钥,也不访问真实外部服务。`load-smoke` 固定验证 `R = 32 / 40 / 48、N = 4、Q = 128`;`fault-smoke` 用独立 worker / mock 生命周期验证 `Q` 满快速拒绝、queue deadline、`503 → 200` 顺序重试、两次 `503` 后 provider exhausted,以及 provider 成功响应 body 中途 reset 后第二次 attempt 串行成功。默认读取 `server-rs/target/debug/api-server(.exe)`;在 WSL 或自定义 target 目录运行时,通过 `GENARRATIVE_BGFILTER_SMOKE_BINARY` 指定 binary: + +```bash +cargo build -p api-server --manifest-path server-rs/Cargo.toml +npm run bgfilter-worker:smoke-test +npm run bgfilter-worker:load-smoke +npm run bgfilter-worker:fault-smoke +``` + +真实 OSS + BgFilter 契约冒烟不属于默认门禁,会访问真实服务并产生调用成本。执行前必须在当前进程环境中以不回显方式注入与 loopback worker 相同的一次性 `GENARRATIVE_BGFILTER_INTERNAL_TOKEN`,不得把 token 写进命令行、仓库 env 文件或日志;OSS / BgFilter 配置只放本地私密环境。分别设置 `GENARRATIVE_BGFILTER_SMOKE_MODE=flat` 与 `complex` 后运行: + +```bash +cargo run -p platform-oss --example bgfilter_worker_live_smoke --manifest-path server-rs/Cargo.toml +``` + +该 harness 固定使用 `generated-character-drafts/bgfilter-smoke//source.png`:PUT 前必须先确认 HEAD=404,随后验证私有上传、无鉴权 401 精确错误契约、真实 `200 image/png`,最后要求 DELETE 2xx 且 HEAD=404。正常失败也会尝试清理;若进程崩溃或被强杀,必须按输出的 object key 人工复核。它验证真实 OSS / BgFilter 边界,不替代完整父流程验收;父 flat 的 fallback 本地证据仍由真实阿里云 `segment_smoke` 与父路由单测组合提供,`mock worker 失败 → 父 flat → 真实阿里云`、完整动画写回及计费 / lease / 退款链留在 staging 验收。 + 实现后按范围运行: ```bash diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 336d82b01..816bf2426 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -608,6 +608,10 @@ npm run container:down `npm run container:config` 默认只做 quiet 校验,避免把本地 env 中的 token 展开到终端;确需排查完整 compose 时再传 `-- --print`。 隔离验证 worker 队列和 API-only 更新时使用 `npm run container:worker-smoke -- smoke`。该命令不复用 `deploy/container/api-server.env`,会在 `deploy/container/worker-smoke/` 生成本机专用 env 与端口 state,并使用 unsupported job 验证 worker claim / fail 回写,不需要真实外部生成密钥;本机 crates.io 网络不稳时使用 `--local-binary`,由容器内 Cargo 复用本机 Cargo 缓存构建,并把产物放进 Debian bookworm smoke runtime。 +独立 BgFilter worker 的本机全进程验证先运行 `cargo build -p api-server --manifest-path server-rs/Cargo.toml`,再依次运行 `npm run bgfilter-worker:smoke-test`、`npm run bgfilter-worker:load-smoke` 和 `npm run bgfilter-worker:fault-smoke`。三条命令只使用动态 loopback 端口、假 OSS 签名配置和本地 mock provider;不会读取仓库 `.env*` 或请求真实 BgFilter / OSS。自定义或 WSL binary 通过 `GENARRATIVE_BGFILTER_SMOKE_BINARY` 指定。当前 fault 范围包含 overload、queue deadline、两类 HTTP 状态顺序重试结果,以及 provider 成功响应 body 中途 reset 后第二次 attempt 串行成功;慢读、大响应、父侧客户端断连与 SIGTERM 排空另行验证。 + +需要复核真实 OSS + BgFilter 契约时,先启动只监听 loopback 的 worker,并在 worker 与 smoke 的当前进程环境中以不回显方式注入同一个一次性 `GENARRATIVE_BGFILTER_INTERNAL_TOKEN`;token 不得写入命令参数、仓库 env 文件或日志。OSS / BgFilter 凭据继续只放本地私密环境。分别设置 `GENARRATIVE_BGFILTER_SMOKE_MODE=flat` 和 `complex`,运行 `cargo run -p platform-oss --example bgfilter_worker_live_smoke --manifest-path server-rs/Cargo.toml`。该命令会访问真实服务并产生调用成本;成功标准是 PUT 前 HEAD=404、私有上传成功、无鉴权请求返回精确 401 JSON、带鉴权请求返回 `200 image/png`、DELETE 2xx 且最终 HEAD=404。对象只写入 `generated-character-drafts/bgfilter-smoke//source.png`;正常失败会继续清理,进程崩溃或被强杀时需按输出 object key 人工复核。此 smoke 不经过用户 job、计费或父 flat fallback;完整 `mock worker 失败 → 父 flat → 真实阿里云` 和动画写回链在 staging 验收。 + OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日志与 Nginx 文件日志仍保留: - 生产与容器 `api-server` env 模板默认 `GENARRATIVE_OTEL_ENABLED=true`;压测、排障或短期要关闭 OTLP 时,必须显式设置 `GENARRATIVE_OTEL_ENABLED=false`。 diff --git a/package.json b/package.json index 2e1c9a6ef..6e8f7365d 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,9 @@ "dev:spacetime": "node scripts/dev.mjs spacetime", "dev:api-server": "node scripts/dev.mjs api-server", "dev:bgfilter-worker": "node scripts/dev.mjs bgfilter-worker", + "bgfilter-worker:load-smoke": "node scripts/bgfilter-worker-load-smoke.mjs", + "bgfilter-worker:fault-smoke": "node scripts/bgfilter-worker-load-smoke.mjs fault", + "bgfilter-worker:smoke-test": "node --test scripts/bgfilter-worker-load-smoke.test.mjs", "dev:web": "node scripts/dev.mjs web", "dev:admin-web": "node scripts/dev.mjs admin-web", "server-manager:panel": "cargo run -p server-manager-panel --manifest-path server-rs/Cargo.toml", diff --git a/scripts/bgfilter-worker-load-smoke.mjs b/scripts/bgfilter-worker-load-smoke.mjs new file mode 100644 index 000000000..1949748d8 --- /dev/null +++ b/scripts/bgfilter-worker-load-smoke.mjs @@ -0,0 +1,1348 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import http from 'node:http'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(SCRIPT_DIR, '..'); +const INTERNAL_PATH = '/internal/bgfilter/v1/remove-background'; +const LOAD_SCENARIOS = Object.freeze( + [32, 40, 48].map((requestCount) => + Object.freeze({ + mode: 'complex', + name: `load-r${requestCount}`, + requestCount, + }), + ), +); +const DEFAULT_WORKER_CONCURRENCY = 4; +const DEFAULT_WORKER_MAX_REQUESTS = 128; +const PROVIDER_DELAY_MS = 100; +const REQUEST_BUDGET_MS = 30_000; +const REQUEST_TIMEOUT_MS = 35_000; +const WORKER_START_TIMEOUT_MS = 20_000; +const LOAD_SMOKE_TIMEOUT_MS = 60_000; +const FAULT_SCENARIO_TIMEOUT_MS = 15_000; +const OVERLOAD_RESPONSE_MAX_MS = 750; +const QUEUE_DEADLINE_BUDGET_MS = 1_200; +const MAX_CAPTURED_LOG_BYTES = 64 * 1024; +const MAX_MULTIPART_BYTES = 256 * 1024; +const FAKE_OSS_ACCESS_KEY_ID = 'bgfilter-load-smoke-access-key'; +const FAKE_OSS_ACCESS_KEY_SECRET = 'bgfilter-load-smoke-access-secret'; + +export const SMOKE_PNG_BYTES = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64', +); + +const REQUIRED_MULTIPART_FIELDS = Object.freeze([ + 'image_url', + 'seg_model', + 'background_mode', + 'cross_check', +]); + +export function buildIsolatedWorkerEnv({ + concurrency = DEFAULT_WORKER_CONCURRENCY, + maxRequests = DEFAULT_WORKER_MAX_REQUESTS, + processEnv = process.env, + providerBaseUrl, + tempRoot, + token, + workerPort, +}) { + const env = copyRequiredSystemEnv(processEnv); + const workerBaseUrl = `http://127.0.0.1:${workerPort}`; + + Object.assign(env, { + ALIYUN_OSS_ACCESS_KEY_ID: FAKE_OSS_ACCESS_KEY_ID, + ALIYUN_OSS_ACCESS_KEY_SECRET: FAKE_OSS_ACCESS_KEY_SECRET, + ALIYUN_OSS_BUCKET: 'bgfilter-load-smoke', + ALIYUN_OSS_ENDPOINT: 'oss-cn-shanghai.invalid', + GENARRATIVE_ALIYUN_MATTING_ENABLED: 'false', + GENARRATIVE_API_LOG: 'warn', + GENARRATIVE_BGFILTER_INTERNAL_TOKEN: token, + GENARRATIVE_BGFILTER_WORKER_BASE_URL: workerBaseUrl, + GENARRATIVE_BGFILTER_WORKER_CONCURRENCY: String(concurrency), + GENARRATIVE_BGFILTER_WORKER_HOST: '127.0.0.1', + GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS: String(maxRequests), + GENARRATIVE_BGFILTER_WORKER_PORT: String(workerPort), + GENARRATIVE_EDITOR_BGFILTER_BASE_URL: providerBaseUrl, + GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS: '5000', + GENARRATIVE_EDITOR_GENERATION_PRICING_OVERRIDE_PATH: path.join( + tempRoot, + 'missing-pricing-override.json', + ), + GENARRATIVE_OTEL_ENABLED: 'false', + GENARRATIVE_PROCESS_ROLE: 'bgfilter-worker', + GENARRATIVE_SPACETIME_DATABASE: 'bgfilter-load-smoke', + GENARRATIVE_SPACETIME_SERVER_URL: 'http://127.0.0.1:9', + NO_PROXY: '127.0.0.1,localhost', + no_proxy: '127.0.0.1,localhost', + }); + + if (process.platform === 'win32') { + env.TEMP = tempRoot; + env.TMP = tempRoot; + } else { + env.TMPDIR = tempRoot; + } + + return env; +} + +function copyRequiredSystemEnv(processEnv) { + const allowed = new Set([ + 'COMSPEC', + 'DYLD_LIBRARY_PATH', + 'LANG', + 'LC_ALL', + 'LD_LIBRARY_PATH', + 'PATH', + 'PATHEXT', + 'SYSTEMROOT', + 'WINDIR', + ]); + const env = {}; + for (const [key, value] of Object.entries(processEnv)) { + if (allowed.has(key.toUpperCase()) && typeof value === 'string') { + env[key] = value; + } + } + return env; +} + +export function createProviderGate() { + let released = false; + const waiters = new Set(); + return { + get released() { + return released; + }, + release() { + if (released) { + return; + } + released = true; + for (const resolve of waiters) { + resolve(); + } + waiters.clear(); + }, + wait() { + if (released) { + return Promise.resolve(); + } + return new Promise((resolve) => waiters.add(resolve)); + }, + }; +} + +export function createProviderSequenceBehavior(outcomes) { + const sequence = [...outcomes].map((outcome) => + typeof outcome === 'number' ? { statusCode: outcome } : { ...outcome }, + ); + if (sequence.length === 0) { + throw new Error('provider behavior 至少需要一个结果'); + } + return ({ attempt }) => ({ + ...sequence[Math.min(attempt - 1, sequence.length - 1)], + }); +} + +export async function startMockBgfilterProvider({ + behavior = () => ({ statusCode: 200 }), + delayMs = PROVIDER_DELAY_MS, + gate = null, +} = {}) { + let stats = emptyProviderStats(); + const sockets = new Set(); + let closePromise; + + const server = http.createServer((request, response) => { + void handleMockProviderRequest({ + behavior, + delayMs, + gate, + request, + response, + stats, + }).catch((error) => { + stats.violations.push( + error instanceof Error ? error.message : String(error), + ); + if (!response.headersSent) { + response.writeHead(500, { + Connection: 'close', + 'Content-Type': 'text/plain; charset=utf-8', + }); + response.end('mock provider failure'); + } else { + response.destroy(); + } + }); + }); + server.on('connection', (socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + }); + + await listenOnLoopback(server); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('mock provider 未返回 TCP 监听地址'); + } + + return { + baseUrl: `http://127.0.0.1:${address.port}`, + reset() { + if (stats.active !== 0) { + throw new Error(`mock provider 仍有 ${stats.active} 个活跃请求,不能重置`); + } + stats = emptyProviderStats(); + }, + snapshot() { + return { + active: stats.active, + peak: stats.peak, + requests: stats.requests, + timeline: stats.timeline.map((event) => ({ ...event })), + violations: [...stats.violations], + }; + }, + async waitFor(predicate, { signal, timeoutMs = 3_000 } = {}) { + await waitForCondition( + () => predicate(this.snapshot()), + timeoutMs, + '等待 mock provider 状态超时', + signal, + ); + return this.snapshot(); + }, + async close() { + if (!closePromise) { + gate?.release(); + closePromise = closeServer(server, sockets); + } + await closePromise; + }, + }; +} + +function emptyProviderStats() { + return { + active: 0, + peak: 0, + requests: 0, + startedAt: Date.now(), + timeline: [], + violations: [], + }; +} + +async function handleMockProviderRequest({ + behavior, + delayMs, + gate, + request, + response, + stats, +}) { + const body = await readIncomingBody(request, MAX_MULTIPART_BYTES); + const contentType = String(request.headers['content-type'] ?? ''); + const violations = []; + + if (request.method !== 'POST') { + violations.push(`期望 POST,实际 ${request.method ?? '-'}`); + } + if (request.url !== '/remove-background') { + violations.push(`期望 /remove-background,实际 ${request.url ?? '-'}`); + } + if (!/^multipart\/form-data;\s*boundary=/iu.test(contentType)) { + violations.push('请求 Content-Type 不是带 boundary 的 multipart/form-data'); + } + for (const field of REQUIRED_MULTIPART_FIELDS) { + if (!body.includes(Buffer.from(`name="${field}"`, 'utf8'))) { + violations.push(`multipart 缺少字段 ${field}`); + } + } + + const attempt = stats.requests + 1; + stats.requests = attempt; + if (violations.length > 0) { + stats.violations.push(...violations); + response.writeHead(400, { + Connection: 'close', + 'Content-Type': 'text/plain; charset=utf-8', + }); + response.end('invalid mock request'); + return; + } + + stats.active += 1; + stats.peak = Math.max(stats.peak, stats.active); + recordProviderTimeline(stats, { + active: stats.active, + attempt, + event: 'start', + }); + let statusCode = 500; + let completion = 'response'; + try { + await gate?.wait(); + await delay(delayMs); + const outcome = behavior({ attempt }) ?? {}; + statusCode = Number(outcome.statusCode ?? 200); + if (!Number.isInteger(statusCode) || statusCode < 100 || statusCode > 599) { + throw new Error(`mock provider behavior 返回无效状态码:${statusCode}`); + } + const success = statusCode >= 200 && statusCode < 300; + const responseBody = success + ? SMOKE_PNG_BYTES + : Buffer.from(`mock provider HTTP ${statusCode}`, 'utf8'); + response.writeHead(statusCode, { + Connection: 'close', + 'Content-Length': String(responseBody.length), + 'Content-Type': success ? 'image/png' : 'text/plain; charset=utf-8', + }); + if (outcome.resetMidBody === true) { + if (!success) { + throw new Error('mock provider 只能在成功响应中途执行 reset'); + } + completion = 'mid_body_reset'; + await writePartialBodyAndReset(response, responseBody); + return; + } + response.end(responseBody); + } finally { + stats.active -= 1; + recordProviderTimeline(stats, { + active: stats.active, + attempt, + completion, + event: 'finish', + statusCode, + }); + } +} + +async function writePartialBodyAndReset(response, responseBody) { + const partialLength = Math.max(1, Math.floor(responseBody.length / 2)); + await new Promise((resolve, reject) => { + response.write(responseBody.subarray(0, partialLength), (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + const socket = response.socket; + if (!socket || socket.destroyed) { + throw new Error('mock provider 在 mid-body reset 前连接已关闭'); + } + if (typeof socket.resetAndDestroy === 'function') { + socket.resetAndDestroy(); + } else { + socket.destroy(); + } +} + +function recordProviderTimeline(stats, event) { + stats.timeline.push({ + ...event, + atMs: Date.now() - stats.startedAt, + sequence: stats.timeline.length + 1, + }); +} + +async function readIncomingBody(incoming, maxBytes) { + const chunks = []; + let total = 0; + for await (const chunk of incoming) { + total += chunk.length; + if (total > maxBytes) { + throw new Error(`mock provider multipart 超过 ${maxBytes} bytes`); + } + chunks.push(chunk); + } + return Buffer.concat(chunks, total); +} + +async function runLoadSmoke() { + await withWorkerRuntime( + { + concurrency: DEFAULT_WORKER_CONCURRENCY, + maxRequests: DEFAULT_WORKER_MAX_REQUESTS, + name: 'load', + providerOptions: {}, + timeoutMs: LOAD_SMOKE_TIMEOUT_MS, + }, + async ({ provider, signal, token, workerBaseUrl }) => { + for (const scenario of LOAD_SCENARIOS) { + await runScenario({ + provider, + scenario, + signal, + token, + workerBaseUrl, + }); + } + }, + ); + console.log( + `[bgfilter-worker-load-smoke] 全部通过:R=${LOAD_SCENARIOS.map((scenario) => scenario.requestCount).join('/')},N=${DEFAULT_WORKER_CONCURRENCY},Q=${DEFAULT_WORKER_MAX_REQUESTS}`, + ); +} + +async function runScenario({ provider, scenario, signal, token, workerBaseUrl }) { + provider.reset(); + const startedAt = Date.now(); + const responses = await Promise.all( + Array.from({ length: scenario.requestCount }, (_, index) => + requestWorker(workerBaseUrl, token, scenario, index, { signal }), + ), + ); + assertBatchResponses(scenario, responses); + const providerStats = provider.snapshot(); + assertProviderStats(scenario, providerStats); + console.log( + `[bgfilter-worker-load-smoke] ${scenario.name} 通过:${responses.length} 个 2xx image/png,provider peak=${providerStats.peak},耗时=${Date.now() - startedAt}ms`, + ); +} + +async function runFaultSmoke() { + await runOverloadFaultScenario(); + await runQueueDeadlineFaultScenario(); + await runRetryThenSuccessFaultScenario(); + await runProviderExhaustedFaultScenario(); + await runMidBodyResetThenSuccessFaultScenario(); + console.log('[bgfilter-worker-fault-smoke] 全部 5 个 fault 场景通过'); +} + +async function runOverloadFaultScenario() { + const gate = createProviderGate(); + await withWorkerRuntime( + { + concurrency: 2, + maxRequests: 4, + name: 'fault-overload', + providerOptions: { delayMs: 25, gate }, + timeoutMs: FAULT_SCENARIO_TIMEOUT_MS, + }, + async ({ provider, signal, token, workerBaseUrl }) => { + const scenario = { mode: 'complex', name: 'fault-overload' }; + const admitted = Array.from({ length: 4 }, (_, index) => + requestWorker(workerBaseUrl, token, scenario, index, { + signal, + timeoutMs: 10_000, + }), + ); + let faultError = null; + let overloadedResponse = null; + try { + await provider.waitFor( + (stats) => stats.active === 2 && stats.requests === 2, + { signal, timeoutMs: 3_000 }, + ); + // 前两个已在 provider gate,给另外两个完整请求进入 Q admission 的时间。 + await delay(150, signal); + overloadedResponse = await requestWorker( + workerBaseUrl, + token, + scenario, + 4, + { signal, timeoutMs: 1_200 }, + ); + } catch (error) { + faultError = error; + } finally { + gate.release(); + } + + const admittedResponses = unwrapSettledResponses( + await Promise.allSettled(admitted), + 'overload 前四个请求', + ); + if (faultError) { + throw faultError; + } + assertWorkerErrorResponse(overloadedResponse, { + attemptsStarted: 0, + code: 'overloaded', + retryAfter: '1', + statusCode: 429, + }); + if (overloadedResponse.elapsedMs > OVERLOAD_RESPONSE_MAX_MS) { + throw new Error( + `overload 第五个请求返回过慢:${overloadedResponse.elapsedMs}ms > ${OVERLOAD_RESPONSE_MAX_MS}ms`, + ); + } + assertBatchResponses( + { name: 'fault-overload-admitted' }, + admittedResponses, + ); + assertProviderStats( + { + expectedPeak: 2, + maxPeak: 2, + name: 'fault-overload', + requestCount: 4, + }, + provider.snapshot(), + ); + }, + ); + console.log( + '[bgfilter-worker-fault-smoke] overload 通过:N=2/Q=4,第五个请求快速 429,前四个排空成功,provider peak=2', + ); +} + +async function runQueueDeadlineFaultScenario() { + const gate = createProviderGate(); + await withWorkerRuntime( + { + concurrency: 1, + maxRequests: 4, + name: 'fault-queue-deadline', + providerOptions: { delayMs: 25, gate }, + timeoutMs: FAULT_SCENARIO_TIMEOUT_MS, + }, + async ({ provider, signal, token, workerBaseUrl }) => { + const scenario = { mode: 'complex', name: 'fault-queue-deadline' }; + const firstRequest = requestWorker( + workerBaseUrl, + token, + scenario, + 0, + { signal, timeoutMs: 10_000 }, + ); + let faultError = null; + let queueResponse = null; + let providerBeforeRelease = null; + try { + await provider.waitFor( + (stats) => stats.active === 1 && stats.requests === 1, + { signal, timeoutMs: 3_000 }, + ); + queueResponse = await requestWorker( + workerBaseUrl, + token, + scenario, + 1, + { + requestBudgetMs: QUEUE_DEADLINE_BUDGET_MS, + signal, + timeoutMs: 3_500, + }, + ); + providerBeforeRelease = provider.snapshot(); + } catch (error) { + faultError = error; + } finally { + gate.release(); + } + + const [firstResponse] = unwrapSettledResponses( + await Promise.allSettled([firstRequest]), + 'queue deadline 首请求', + ); + if (faultError) { + throw faultError; + } + assertWorkerErrorResponse(queueResponse, { + attemptsStarted: 0, + code: 'deadline_exceeded', + phase: 'queue', + statusCode: 504, + }); + if (queueResponse.elapsedMs < 900 || queueResponse.elapsedMs > 2_500) { + throw new Error( + `queue deadline 响应耗时偏离 1200ms 预算:${queueResponse.elapsedMs}ms`, + ); + } + if (providerBeforeRelease.requests !== 1) { + throw new Error( + `queue deadline 第二个请求不应到 provider,释放前请求数=${providerBeforeRelease.requests}`, + ); + } + assertBatchResponses({ name: 'fault-queue-first' }, [firstResponse]); + assertProviderStats( + { + expectedPeak: 1, + maxPeak: 1, + name: 'fault-queue-deadline', + requestCount: 1, + }, + provider.snapshot(), + ); + }, + ); + console.log( + '[bgfilter-worker-fault-smoke] queue deadline 通过:504 deadline_exceeded,phase=queue,attemptsStarted=0', + ); +} + +async function runRetryThenSuccessFaultScenario() { + await withWorkerRuntime( + { + concurrency: 2, + maxRequests: 4, + name: 'fault-retry-success', + providerOptions: { + behavior: createProviderSequenceBehavior([503, 200]), + delayMs: 25, + }, + timeoutMs: FAULT_SCENARIO_TIMEOUT_MS, + }, + async ({ provider, signal, token, workerBaseUrl }) => { + const scenario = { mode: 'complex', name: 'fault-retry-success' }; + const response = await requestWorker( + workerBaseUrl, + token, + scenario, + 0, + { signal, timeoutMs: 10_000 }, + ); + assertBatchResponses(scenario, [response]); + const stats = provider.snapshot(); + assertProviderStats( + { + expectedPeak: 1, + maxPeak: 2, + name: scenario.name, + requestCount: 2, + }, + stats, + ); + assertStrictSequentialAttempts(stats, [503, 200]); + }, + ); + console.log( + '[bgfilter-worker-fault-smoke] retry success 通过:503 → 200,两次 attempt 严格串行', + ); +} + +async function runProviderExhaustedFaultScenario() { + await withWorkerRuntime( + { + concurrency: 2, + maxRequests: 4, + name: 'fault-provider-exhausted', + providerOptions: { + behavior: createProviderSequenceBehavior([503, 503]), + delayMs: 25, + }, + timeoutMs: FAULT_SCENARIO_TIMEOUT_MS, + }, + async ({ provider, signal, token, workerBaseUrl }) => { + const scenario = { mode: 'complex', name: 'fault-provider-exhausted' }; + const response = await requestWorker( + workerBaseUrl, + token, + scenario, + 0, + { signal, timeoutMs: 10_000 }, + ); + assertWorkerErrorResponse(response, { + attemptsStarted: 2, + code: 'provider_exhausted', + phase: 'provider', + statusCode: 502, + }); + const stats = provider.snapshot(); + assertProviderStats( + { + expectedPeak: 1, + maxPeak: 2, + name: scenario.name, + requestCount: 2, + }, + stats, + ); + assertStrictSequentialAttempts(stats, [503, 503]); + }, + ); + console.log( + '[bgfilter-worker-fault-smoke] provider exhausted 通过:两次 503 后返回 502 provider_exhausted', + ); +} + +async function runMidBodyResetThenSuccessFaultScenario() { + await withWorkerRuntime( + { + concurrency: 2, + maxRequests: 4, + name: 'fault-mid-body-reset', + providerOptions: { + behavior: createProviderSequenceBehavior([ + { resetMidBody: true, statusCode: 200 }, + { statusCode: 200 }, + ]), + delayMs: 25, + }, + timeoutMs: FAULT_SCENARIO_TIMEOUT_MS, + }, + async ({ provider, signal, token, workerBaseUrl }) => { + const scenario = { mode: 'complex', name: 'fault-mid-body-reset' }; + const response = await requestWorker( + workerBaseUrl, + token, + scenario, + 0, + { signal, timeoutMs: 10_000 }, + ); + assertBatchResponses(scenario, [response]); + const stats = provider.snapshot(); + assertProviderStats( + { + expectedPeak: 1, + maxPeak: 2, + name: scenario.name, + requestCount: 2, + }, + stats, + ); + assertStrictSequentialAttempts(stats, [200, 200], [ + 'mid_body_reset', + 'response', + ]); + }, + ); + console.log( + '[bgfilter-worker-fault-smoke] mid-body reset 通过:首 attempt 响应中途断连,第二 attempt 串行成功,provider peak=1', + ); +} + +async function withWorkerRuntime( + { concurrency, maxRequests, name, providerOptions, timeoutMs }, + run, +) { + const resources = createResourceScope(); + const controller = new AbortController(); + const scenarioTimer = setTimeout(() => { + controller.abort(new Error(`${name} 超过 ${timeoutMs}ms 场景上限`)); + }, timeoutMs); + installSignalCleanup(resources); + + try { + resources.tempRoot = await mkdtemp( + path.join(os.tmpdir(), `genarrative-bgfilter-${name}-smoke-`), + ); + resources.provider = await startMockBgfilterProvider(providerOptions); + const workerPort = await getFreeLoopbackPort(); + const token = randomBytes(32).toString('hex'); + const workerEnv = buildIsolatedWorkerEnv({ + concurrency, + maxRequests, + providerBaseUrl: resources.provider.baseUrl, + tempRoot: resources.tempRoot, + token, + workerPort, + }); + resources.worker = startWorker( + resolveApiServerBinary(), + resources.tempRoot, + workerEnv, + [token, FAKE_OSS_ACCESS_KEY_ID, FAKE_OSS_ACCESS_KEY_SECRET], + ); + const workerBaseUrl = `http://127.0.0.1:${workerPort}`; + await waitForWorkerReady( + workerBaseUrl, + resources.worker, + controller.signal, + ); + await run({ + provider: resources.provider, + signal: controller.signal, + token, + workerBaseUrl, + }); + } finally { + clearTimeout(scenarioTimer); + removeSignalCleanup(resources); + await resources.cleanup(); + } +} + +function createResourceScope() { + let cleanupPromise; + const resources = { + provider: null, + signalHandlers: new Map(), + tempRoot: null, + worker: null, + cleanup() { + if (!cleanupPromise) { + cleanupPromise = cleanupResources(resources); + } + return cleanupPromise; + }, + }; + return resources; +} + +function installSignalCleanup(resources) { + for (const signal of ['SIGINT', 'SIGTERM']) { + const handler = () => { + void resources.cleanup().finally(() => { + process.exit(signal === 'SIGINT' ? 130 : 143); + }); + }; + resources.signalHandlers.set(signal, handler); + process.once(signal, handler); + } +} + +function removeSignalCleanup(resources) { + for (const [signal, handler] of resources.signalHandlers) { + process.off(signal, handler); + } + resources.signalHandlers.clear(); +} + +async function cleanupResources(resources) { + const failures = []; + if (resources.worker) { + try { + await stopChild(resources.worker.child); + } catch (error) { + failures.push(error); + } + } + if (resources.provider) { + try { + await resources.provider.close(); + } catch (error) { + failures.push(error); + } + } + if (resources.tempRoot) { + try { + await rm(resources.tempRoot, { force: true, recursive: true }); + } catch (error) { + failures.push(error); + } + } + if (failures.length > 0) { + throw new AggregateError(failures, '清理 bgfilter load smoke 资源失败'); + } +} + +function resolveApiServerBinary() { + const executable = process.platform === 'win32' ? 'api-server.exe' : 'api-server'; + const explicit = String( + process.env.GENARRATIVE_BGFILTER_SMOKE_BINARY ?? '', + ).trim(); + const candidates = [ + explicit ? path.resolve(explicit) : null, + path.join(REPO_ROOT, 'server-rs', 'target', 'debug', executable), + path.join(REPO_ROOT, 'target', 'debug', executable), + ].filter(Boolean); + const binary = candidates.find((candidate) => existsSync(candidate)); + if (!binary) { + throw new Error( + `未找到已构建的 api-server binary。请先运行 cargo build -p api-server --manifest-path server-rs/Cargo.toml,或设置 GENARRATIVE_BGFILTER_SMOKE_BINARY。候选:${candidates.join(', ')}`, + ); + } + return binary; +} + +function startWorker(binary, cwd, env, secrets) { + const child = spawn(binary, [], { + cwd, + env, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + const logs = createBoundedLogCollector(child, secrets); + let spawnError = null; + child.once('error', (error) => { + spawnError = error; + }); + return { + child, + diagnostic: () => logs.diagnostic(), + spawnError: () => spawnError, + }; +} + +function createBoundedLogCollector(child, secrets) { + let output = ''; + const append = (chunk) => { + output += chunk.toString('utf8'); + if (Buffer.byteLength(output) > MAX_CAPTURED_LOG_BYTES) { + output = output.slice(-MAX_CAPTURED_LOG_BYTES); + } + }; + child.stdout?.on('data', append); + child.stderr?.on('data', append); + return { + diagnostic() { + return redactDiagnostics(output.trim(), secrets); + }, + }; +} + +function redactDiagnostics(value, secrets) { + let redacted = value; + for (const secret of secrets) { + if (secret) { + redacted = redacted.split(secret).join('[redacted]'); + } + } + return redacted + .replace(/Authorization:\s*Bearer\s+\S+/giu, 'Authorization: Bearer [redacted]') + .replace(/https?:\/\/[^\s"']+\?[^\s"']+/gu, '[signed-url-redacted]'); +} + +async function waitForWorkerReady(baseUrl, worker, signal) { + const deadline = Date.now() + WORKER_START_TIMEOUT_MS; + let lastError = null; + while (Date.now() < deadline) { + throwIfAborted(signal); + const spawnError = worker.spawnError(); + if (spawnError) { + throw new Error(`启动 api-server binary 失败:${spawnError.message}`); + } + if (worker.child.exitCode !== null || worker.child.signalCode !== null) { + const diagnostic = worker.diagnostic(); + throw new Error( + `bgfilter-worker 在 readiness 前退出(code=${worker.child.exitCode ?? '-'} signal=${worker.child.signalCode ?? '-'})${diagnostic ? `\n${diagnostic}` : ''}`, + ); + } + try { + const response = await requestHttp(`${baseUrl}/readyz`, { + signal, + timeoutMs: 750, + }); + if (response.statusCode === 200) { + return; + } + lastError = new Error(`readiness 返回 HTTP ${response.statusCode}`); + } catch (error) { + lastError = error; + } + await delay(100, signal); + } + + const diagnostic = worker.diagnostic(); + throw new Error( + `等待 bgfilter-worker readiness 超时:${lastError?.message ?? 'unknown'}${diagnostic ? `\n${diagnostic}` : ''}`, + ); +} + +function requestWorker(baseUrl, token, scenario, index, options = {}) { + const requestId = `bgfilter-${scenario.name}-${String(index).padStart(3, '0')}`; + const body = Buffer.from( + JSON.stringify( + buildScenarioRequest( + scenario, + requestId, + index, + options.requestBudgetMs, + ), + ), + 'utf8', + ); + return requestHttp(`${baseUrl}${INTERNAL_PATH}`, { + body, + headers: { + Authorization: `Bearer ${token}`, + Connection: 'close', + 'Content-Length': String(body.length), + 'Content-Type': 'application/json', + 'X-Request-Id': requestId, + }, + method: 'POST', + signal: options.signal, + timeoutMs: options.timeoutMs ?? REQUEST_TIMEOUT_MS, + }); +} + +function buildScenarioRequest( + scenario, + requestId, + index, + requestBudgetMs = scenario.requestBudgetMs ?? REQUEST_BUDGET_MS, +) { + const common = { + backgroundMode: scenario.mode, + crossCheck: false, + requestBudgetMs, + requestId, + segModel: 'birefnet', + sourceObjectKey: `generated-character-drafts/bgfilter-load-smoke/${scenario.name}/frame-${String(index).padStart(3, '0')}.png`, + }; + if (scenario.mode === 'complex') { + return common; + } + throw new Error(`不支持的 bgfilter load smoke mode:${scenario.mode}`); +} + +function requestHttp(url, options = {}) { + return new Promise((resolve, reject) => { + const startedAt = Date.now(); + const request = http.request( + url, + { + agent: false, + headers: options.headers, + method: options.method ?? 'GET', + signal: options.signal, + }, + async (response) => { + try { + const body = await readIncomingBody(response, 2 * 1024 * 1024); + resolve({ + body, + elapsedMs: Date.now() - startedAt, + headers: response.headers, + statusCode: response.statusCode ?? 0, + }); + } catch (error) { + reject(error); + } + }, + ); + request.setTimeout(options.timeoutMs ?? REQUEST_TIMEOUT_MS, () => { + request.destroy(new Error(`HTTP 请求超时:${url}`)); + }); + request.once('error', reject); + request.end(options.body); + }); +} + +function assertBatchResponses(scenario, responses) { + for (const [index, response] of responses.entries()) { + if (response.statusCode < 200 || response.statusCode >= 300) { + throw new Error( + `${scenario.name} request=${index} 期望 2xx,实际 HTTP ${response.statusCode},bodyBytes=${response.body.length}`, + ); + } + const contentType = String(response.headers['content-type'] ?? '') + .split(';', 1)[0] + .trim() + .toLowerCase(); + if (contentType !== 'image/png') { + throw new Error( + `${scenario.name} request=${index} 期望 image/png,实际 ${contentType || '-'}`, + ); + } + if (!response.body.equals(SMOKE_PNG_BYTES)) { + throw new Error( + `${scenario.name} request=${index} 返回 PNG 字节与 mock 响应不一致`, + ); + } + } +} + +function assertWorkerErrorResponse(response, expected) { + if (!response) { + throw new Error(`${expected.code} 场景没有收到 worker 响应`); + } + if (response.statusCode !== expected.statusCode) { + throw new Error( + `${expected.code} 期望 HTTP ${expected.statusCode},实际 ${response.statusCode}`, + ); + } + const contentType = String(response.headers['content-type'] ?? '') + .split(';', 1)[0] + .trim() + .toLowerCase(); + if (contentType !== 'application/json') { + throw new Error( + `${expected.code} 期望 application/json,实际 ${contentType || '-'}`, + ); + } + let payload; + try { + payload = JSON.parse(response.body.toString('utf8')); + } catch { + throw new Error(`${expected.code} 响应不是合法 JSON`); + } + const error = payload?.error; + if (error?.code !== expected.code) { + throw new Error( + `期望 error.code=${expected.code},实际 ${error?.code ?? '-'}`, + ); + } + if (error.attemptsStarted !== expected.attemptsStarted) { + throw new Error( + `${expected.code} 期望 attemptsStarted=${expected.attemptsStarted},实际 ${error.attemptsStarted ?? '-'}`, + ); + } + if ( + Object.hasOwn(expected, 'phase') && + error.phase !== expected.phase + ) { + throw new Error( + `${expected.code} 期望 phase=${expected.phase},实际 ${error.phase ?? '-'}`, + ); + } + if ( + expected.retryAfter !== undefined && + String(response.headers['retry-after'] ?? '') !== expected.retryAfter + ) { + throw new Error( + `${expected.code} 期望 Retry-After=${expected.retryAfter},实际 ${response.headers['retry-after'] ?? '-'}`, + ); + } + return error; +} + +function unwrapSettledResponses(results, label) { + const failures = results + .filter((result) => result.status === 'rejected') + .map((result) => result.reason); + if (failures.length > 0) { + throw new AggregateError(failures, `${label} 未全部完成`); + } + return results.map((result) => result.value); +} + +function assertStrictSequentialAttempts( + stats, + expectedStatuses, + expectedCompletions = expectedStatuses.map(() => 'response'), +) { + if (stats.timeline.length !== expectedStatuses.length * 2) { + throw new Error( + `provider timeline 事件数应为 ${expectedStatuses.length * 2},实际 ${stats.timeline.length}`, + ); + } + let previousFinish = null; + for (const [index, statusCode] of expectedStatuses.entries()) { + const attempt = index + 1; + const start = stats.timeline.find( + (event) => event.attempt === attempt && event.event === 'start', + ); + const finish = stats.timeline.find( + (event) => event.attempt === attempt && event.event === 'finish', + ); + if (!start || !finish || start.sequence >= finish.sequence) { + throw new Error(`provider attempt=${attempt} 缺少有序 start/finish`); + } + if (start.active !== 1 || finish.active !== 0) { + throw new Error( + `provider attempt=${attempt} 活跃计数异常:start=${start.active} finish=${finish.active}`, + ); + } + if (finish.statusCode !== statusCode) { + throw new Error( + `provider attempt=${attempt} 期望 HTTP ${statusCode},实际 ${finish.statusCode}`, + ); + } + if (finish.completion !== expectedCompletions[index]) { + throw new Error( + `provider attempt=${attempt} 期望 completion=${expectedCompletions[index]},实际 ${finish.completion ?? '-'}`, + ); + } + if (previousFinish && previousFinish.sequence >= start.sequence) { + throw new Error(`provider attempt=${attempt} 与前一次 attempt 发生重叠`); + } + previousFinish = finish; + } +} + +function assertProviderStats(scenario, stats) { + if (stats.violations.length > 0) { + throw new Error(`mock provider 契约错误:${stats.violations.join(';')}`); + } + if (stats.requests !== scenario.requestCount) { + throw new Error( + `${scenario.name} provider 请求数应为 ${scenario.requestCount},实际 ${stats.requests}`, + ); + } + if (stats.active !== 0) { + throw new Error( + `${scenario.name} 完成后 provider 仍有 ${stats.active} 个活跃请求`, + ); + } + const maxPeak = scenario.maxPeak ?? DEFAULT_WORKER_CONCURRENCY; + const expectedPeak = scenario.expectedPeak ?? maxPeak; + if (stats.peak > maxPeak) { + throw new Error( + `${scenario.name} provider peak=${stats.peak} 超过 N=${maxPeak}`, + ); + } + if (stats.peak !== expectedPeak) { + throw new Error( + `${scenario.name} provider peak=${stats.peak},预期 ${expectedPeak}`, + ); + } +} + +function listenOnLoopback(server) { + return new Promise((resolve, reject) => { + const onError = (error) => { + server.off('listening', onListening); + reject(error); + }; + const onListening = () => { + server.off('error', onError); + resolve(); + }; + server.once('error', onError); + server.once('listening', onListening); + server.listen(0, '127.0.0.1'); + }); +} + +function getFreeLoopbackPort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + reject(new Error('无法分配 loopback 临时端口')); + return; + } + const { port } = address; + server.close((error) => { + if (error) { + reject(error); + } else { + resolve(port); + } + }); + }); + }); +} + +async function closeServer(server, sockets) { + if (!server.listening) { + return; + } + server.closeIdleConnections?.(); + const closeFinished = new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + const closed = await Promise.race([ + closeFinished.then(() => true), + delay(2000).then(() => false), + ]); + if (!closed) { + for (const socket of sockets) { + socket.destroy(); + } + await Promise.race([closeFinished, delay(1000)]); + } +} + +async function stopChild(child) { + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + child.kill('SIGTERM'); + if (await waitForExit(child, 5000)) { + return; + } + child.kill('SIGKILL'); + if (!(await waitForExit(child, 3000))) { + throw new Error(`bgfilter-worker 子进程未退出,pid=${child.pid ?? '-'}`); + } +} + +function waitForExit(child, timeoutMs) { + return new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve(true); + return; + } + const timer = setTimeout(() => { + child.off('exit', onExit); + resolve(false); + }, timeoutMs); + const onExit = () => { + clearTimeout(timer); + resolve(true); + }; + child.once('exit', onExit); + }); +} + +async function waitForCondition(predicate, timeoutMs, message, signal) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + throwIfAborted(signal); + if (await predicate()) { + return; + } + await delay(20, signal); + } + throw new Error(message); +} + +function throwIfAborted(signal) { + if (!signal?.aborted) { + return; + } + throw signal.reason instanceof Error + ? signal.reason + : new Error('bgfilter smoke 已取消'); +} + +function delay(milliseconds, signal) { + throwIfAborted(signal); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, milliseconds); + const onAbort = () => { + clearTimeout(timer); + reject( + signal.reason instanceof Error + ? signal.reason + : new Error('bgfilter smoke 已取消'), + ); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} + +export function isDirectModuleExecution( + argv1 = process.argv[1], + moduleUrl = import.meta.url, +) { + if (!argv1) { + return false; + } + return pathToFileURL(path.resolve(argv1)).href === moduleUrl; +} + +async function runCli(args = process.argv.slice(2)) { + const command = args[0] ?? 'load'; + if (command === 'load') { + await runLoadSmoke(); + return; + } + if (command === 'fault') { + await runFaultSmoke(); + return; + } + throw new Error(`仅支持 load 或 fault,实际:${command}`); +} + +if (isDirectModuleExecution()) { + try { + await runCli(); + } catch (error) { + console.error( + `[bgfilter-worker-load-smoke] 失败:${error instanceof Error ? error.message : String(error)}`, + ); + process.exitCode = 1; + } +} diff --git a/scripts/bgfilter-worker-load-smoke.test.mjs b/scripts/bgfilter-worker-load-smoke.test.mjs new file mode 100644 index 000000000..dec322dd5 --- /dev/null +++ b/scripts/bgfilter-worker-load-smoke.test.mjs @@ -0,0 +1,190 @@ +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { afterEach, describe, test } from 'node:test'; + +import { + buildIsolatedWorkerEnv, + createProviderGate, + createProviderSequenceBehavior, + SMOKE_PNG_BYTES, + startMockBgfilterProvider, +} from './bgfilter-worker-load-smoke.mjs'; + +const providers = []; + +afterEach(async () => { + await Promise.all(providers.splice(0).map((provider) => provider.close())); +}); + +describe('bgfilter worker smoke harness', () => { + test('worker 环境不继承真实服务密钥并固定使用假 OSS 配置', () => { + const env = buildIsolatedWorkerEnv({ + processEnv: { + ALIYUN_OSS_ACCESS_KEY_SECRET: 'real-oss-secret', + GENARRATIVE_BGFILTER_INTERNAL_TOKEN: 'real-internal-token', + GENARRATIVE_EDITOR_BGFILTER_TOKEN: 'real-provider-token', + PATH: '/safe/bin', + VECTOR_ENGINE_API_KEY: 'real-vector-secret', + }, + providerBaseUrl: 'http://127.0.0.1:19001', + tempRoot: '/tmp/bgfilter-load-smoke-test', + token: 'ephemeral-test-token', + workerPort: 19002, + }); + + assert.equal(env.PATH, '/safe/bin'); + assert.equal( + env.GENARRATIVE_BGFILTER_INTERNAL_TOKEN, + 'ephemeral-test-token', + ); + assert.equal(env.ALIYUN_OSS_ENDPOINT, 'oss-cn-shanghai.invalid'); + assert.notEqual(env.ALIYUN_OSS_ACCESS_KEY_SECRET, 'real-oss-secret'); + assert.equal(env.GENARRATIVE_EDITOR_BGFILTER_TOKEN, undefined); + assert.equal(env.VECTOR_ENGINE_API_KEY, undefined); + assert.ok(!Object.values(env).includes('real-internal-token')); + assert.ok(!Object.values(env).includes('real-provider-token')); + assert.ok(!Object.values(env).includes('real-vector-secret')); + }); + + test('loopback mock 完整读取 multipart 后记录并发并返回合法 PNG 字节', async () => { + const provider = await startMockBgfilterProvider({ delayMs: 25 }); + providers.push(provider); + const request = multipartFixture(); + + const responses = await Promise.all([ + postMultipart(provider.baseUrl, request), + postMultipart(provider.baseUrl, request), + ]); + + for (const response of responses) { + assert.equal(response.statusCode, 200); + assert.equal(response.contentType, 'image/png'); + assert.ok(response.body.equals(SMOKE_PNG_BYTES)); + } + const stats = provider.snapshot(); + assert.equal(stats.active, 0); + assert.equal(stats.peak, 2); + assert.equal(stats.requests, 2); + assert.deepEqual(stats.violations, []); + assert.equal(stats.timeline.filter((event) => event.event === 'start').length, 2); + assert.equal(stats.timeline.filter((event) => event.event === 'finish').length, 2); + }); + + test('provider gate 与 sequence behavior 生成无重叠 timeline', async () => { + const gate = createProviderGate(); + const provider = await startMockBgfilterProvider({ + behavior: createProviderSequenceBehavior([503, 200]), + delayMs: 5, + gate, + }); + providers.push(provider); + const request = multipartFixture(); + let firstSettled = false; + const first = postMultipart(provider.baseUrl, request).finally(() => { + firstSettled = true; + }); + + await provider.waitFor((stats) => stats.active === 1, { timeoutMs: 1_000 }); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(firstSettled, false); + gate.release(); + assert.equal((await first).statusCode, 503); + assert.equal((await postMultipart(provider.baseUrl, request)).statusCode, 200); + + const stats = provider.snapshot(); + assert.equal(stats.peak, 1); + assert.deepEqual( + stats.timeline.map((event) => [ + event.attempt, + event.event, + event.statusCode ?? null, + ]), + [ + [1, 'start', null], + [1, 'finish', 503], + [2, 'start', null], + [2, 'finish', 200], + ], + ); + }); + + test('provider 可在成功响应 body 中途 reset 并记录完成类型', async () => { + const provider = await startMockBgfilterProvider({ + behavior: createProviderSequenceBehavior([ + { resetMidBody: true, statusCode: 200 }, + ]), + delayMs: 5, + }); + providers.push(provider); + + await assert.rejects(postMultipart(provider.baseUrl, multipartFixture())); + + const stats = provider.snapshot(); + assert.equal(stats.active, 0); + assert.equal(stats.peak, 1); + assert.equal(stats.requests, 1); + assert.deepEqual(stats.violations, []); + assert.equal(stats.timeline[1]?.completion, 'mid_body_reset'); + }); +}); + +function multipartFixture() { + const boundary = 'bgfilter-load-smoke-boundary'; + const body = Buffer.from( + [ + `--${boundary}`, + 'Content-Disposition: form-data; name="image_url"', + '', + 'https://example.invalid/source.png', + `--${boundary}`, + 'Content-Disposition: form-data; name="seg_model"', + '', + 'birefnet', + `--${boundary}`, + 'Content-Disposition: form-data; name="background_mode"', + '', + 'complex', + `--${boundary}`, + 'Content-Disposition: form-data; name="cross_check"', + '', + 'off', + `--${boundary}--`, + '', + ].join('\r\n'), + 'utf8', + ); + return { body, boundary }; +} + +function postMultipart(baseUrl, { body, boundary }) { + return new Promise((resolve, reject) => { + const request = http.request( + `${baseUrl}/remove-background`, + { + agent: false, + headers: { + 'Content-Length': String(body.length), + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + }, + method: 'POST', + }, + (response) => { + const chunks = []; + response.on('data', (chunk) => chunks.push(Buffer.from(chunk))); + response.once('aborted', () => { + reject(new Error('mock provider 响应在 body 中途中止')); + }); + response.once('error', reject); + response.once('end', () => { + resolve({ + body: Buffer.concat(chunks), + contentType: String(response.headers['content-type'] ?? ''), + statusCode: response.statusCode ?? 0, + }); + }); + }, + ); + request.once('error', reject); + request.end(body); + }); +} diff --git a/server-rs/crates/platform-oss/examples/bgfilter_worker_live_smoke.rs b/server-rs/crates/platform-oss/examples/bgfilter_worker_live_smoke.rs new file mode 100644 index 000000000..2fb149048 --- /dev/null +++ b/server-rs/crates/platform-oss/examples/bgfilter_worker_live_smoke.rs @@ -0,0 +1,830 @@ +//! BgFilter worker 真实冒烟验证:私有 OSS 源图 → 内部 worker → 图片响应 → 严格清理源对象。 +//! +//! 默认从仓库根目录的 `.env`、`.env.local`、`.env.secrets.local` 读取 OSS 配置, +//! 但非空 shell 环境变量优先。内部 token 是例外:只读取当前进程环境变量 +//! `GENARRATIVE_BGFILTER_INTERNAL_TOKEN`,不读取 dotenv 文件,也不接受命令行参数: +//! +//! ```text +//! cargo run -p platform-oss --example bgfilter_worker_live_smoke --manifest-path server-rs/Cargo.toml +//! ``` +//! +//! 可选参数:`--worker-url `、`--input `。 +//! `GENARRATIVE_BGFILTER_SMOKE_MODE` 默认为 `flat`,仅允许 `flat` 或 `complex`。 + +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + env, fs, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use hmac::{Hmac, Mac}; +use platform_oss::{ + DEFAULT_POST_EXPIRE_SECONDS, DEFAULT_POST_MAX_SIZE_BYTES, DEFAULT_READ_EXPIRE_SECONDS, + DEFAULT_SUCCESS_ACTION_STATUS, LegacyAssetPrefix, OssClient, OssConfig, OssError, + OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest, +}; +use reqwest::{Method, StatusCode, Url, header}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use time::OffsetDateTime; + +type SmokeResult = Result; +type HmacSha256 = Hmac; + +const INTERNAL_PATH: &str = "/internal/bgfilter/v1/remove-background"; +const INTERNAL_TOKEN_ENV: &str = "GENARRATIVE_BGFILTER_INTERNAL_TOKEN"; +const DEFAULT_WORKER_URL: &str = "http://127.0.0.1:18083"; +const DEFAULT_INPUT: &str = "public/edutainment-baby-object/image2-picture-book-hands/baby-object-hands-1x2-v8-green-preview.png"; +const REQUEST_BUDGET_MS: u64 = 361_000; +const HTTP_TIMEOUT_MS: u64 = 375_000; +const OSS_V4_ALGORITHM: &str = "OSS4-HMAC-SHA256"; +const OSS_V4_REQUEST: &str = "aliyun_v4_request"; +const OSS_V4_SERVICE: &str = "oss"; +const OSS_UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD"; + +static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[derive(Default)] +struct CliOptions { + worker_url: Option, + input: Option, +} + +#[derive(Clone, Copy)] +enum SmokeMode { + Flat, + Complex, +} + +impl SmokeMode { + fn parse(raw: Option<&str>) -> SmokeResult { + match raw.map(str::trim).filter(|value| !value.is_empty()) { + None | Some("flat") => Ok(Self::Flat), + Some("complex") => Ok(Self::Complex), + Some(_) => { + Err("GENARRATIVE_BGFILTER_SMOKE_MODE 只允许使用 flat 或 complex".to_string()) + } + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Flat => "flat", + Self::Complex => "complex", + } + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + if let Err(error) = run().await { + eprintln!("[failed] {error}"); + std::process::exit(1); + } +} + +async fn run() -> SmokeResult<()> { + let cli = parse_cli()?; + let repo_root = repository_root(); + let local_env = load_local_env(&repo_root)?; + let mode = SmokeMode::parse( + local_env + .get("GENARRATIVE_BGFILTER_SMOKE_MODE") + .map(String::as_str), + )?; + let worker_url = cli + .worker_url + .or_else(|| non_empty_env(&local_env, "GENARRATIVE_BGFILTER_WORKER_BASE_URL")) + .unwrap_or_else(|| DEFAULT_WORKER_URL.to_string()); + let worker_endpoint = build_worker_endpoint(&worker_url)?; + let internal_token = required_process_env(INTERNAL_TOKEN_ENV)?; + let input_path = cli.input.unwrap_or_else(|| repo_root.join(DEFAULT_INPUT)); + let input_bytes = fs::read(&input_path).map_err(|_| "读取 smoke 输入 PNG 失败".to_string())?; + if !has_image_magic(&input_bytes, "image/png") { + return Err("smoke 输入必须是非空 PNG".to_string()); + } + + let oss_config = build_oss_config(&local_env)?; + let oss_client = OssClient::new(oss_config.clone()); + let http_client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_millis(HTTP_TIMEOUT_MS)) + .build() + .map_err(|_| "构造 smoke HTTP client 失败".to_string())?; + + check_worker_readiness(&http_client, &worker_endpoint).await?; + + let request_id = new_request_id(); + let object_key = format!( + "{}/bgfilter-smoke/{request_id}/source.png", + LegacyAssetPrefix::CharacterDrafts.as_str() + ); + println!( + "[1/8] worker 已就绪;mode={};requestId={request_id}", + mode.as_str() + ); + println!("[2/8] 准备临时私有对象;objectKey={object_key}"); + + assert_source_absent(&http_client, &oss_client, &object_key).await?; + println!("[3/8] PUT 前 HEAD 明确返回 404,可以创建临时对象"); + + let main_result = run_with_uploaded_source( + &http_client, + &oss_client, + &worker_endpoint, + &internal_token, + mode, + &request_id, + &object_key, + input_bytes, + ) + .await; + let cleanup_result = cleanup_source(&http_client, &oss_client, &oss_config, &object_key).await; + + match (main_result, cleanup_result) { + (Ok(()), Ok(())) => { + println!("[8/8] smoke 通过,临时 OSS 对象已删除并确认不存在"); + Ok(()) + } + (Err(main_error), Ok(())) => Err(format!( + "主流程失败:{main_error};临时 OSS 对象已清理;objectKey={object_key}" + )), + (Ok(()), Err(cleanup_error)) => Err(format!( + "主流程成功但清理失败:{cleanup_error};objectKey={object_key}" + )), + (Err(main_error), Err(cleanup_error)) => Err(format!( + "主流程失败:{main_error};清理同时失败:{cleanup_error};objectKey={object_key}" + )), + } +} + +#[allow(clippy::too_many_arguments)] +async fn run_with_uploaded_source( + http_client: &reqwest::Client, + oss_client: &OssClient, + worker_endpoint: &Url, + internal_token: &str, + mode: SmokeMode, + request_id: &str, + expected_object_key: &str, + input_bytes: Vec, +) -> SmokeResult<()> { + let expected_length = input_bytes.len() as u64; + let upload = oss_client + .put_object( + http_client, + OssPutObjectRequest { + prefix: LegacyAssetPrefix::CharacterDrafts, + path_segments: vec!["bgfilter-smoke".to_string(), request_id.to_string()], + file_name: "source.png".to_string(), + content_type: Some("image/png".to_string()), + access: OssObjectAccess::Private, + metadata: BTreeMap::new(), + body: input_bytes, + }, + ) + .await + .map_err(|error| format!("OSS 上传失败({})", oss_error_label(&error)))?; + if upload.object_key != expected_object_key || upload.content_length != expected_length { + return Err("OSS 上传回执与预期对象不一致".to_string()); + } + + let uploaded = oss_client + .head_object( + http_client, + OssHeadObjectRequest { + object_key: expected_object_key.to_string(), + }, + ) + .await + .map_err(|error| format!("OSS 上传后 HEAD 失败({})", oss_error_label(&error)))?; + if uploaded.content_length != expected_length + || uploaded.content_type.as_deref() != Some("image/png") + { + return Err("OSS 上传后对象元数据与输入不一致".to_string()); + } + println!("[4/8] 私有源图上传与 HEAD 校验通过"); + + let payload = build_worker_payload(mode, request_id, expected_object_key); + assert_unauthorized(http_client, worker_endpoint, request_id, &payload).await?; + println!("[5/8] 无 Authorization 请求按契约返回 401 JSON"); + + let payload_bytes = + serde_json::to_vec(&payload).map_err(|_| "序列化带鉴权 worker 请求失败".to_string())?; + let response = http_client + .post(worker_endpoint.clone()) + .bearer_auth(internal_token) + .header("X-Request-Id", request_id) + .header(header::CONTENT_TYPE, "application/json") + .body(payload_bytes) + .send() + .await + .map_err(|error| transport_error("带鉴权 worker 请求", &error))?; + let status = response.status(); + if status != StatusCode::OK { + return Err(worker_status_error(response).await); + } + let response_request_id = response + .headers() + .get("X-Request-Id") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + if response_request_id != request_id { + return Err("worker 成功响应的 X-Request-Id 不匹配".to_string()); + } + let content_type = response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(normalize_content_type) + .ok_or_else(|| "worker 成功响应缺少 Content-Type".to_string())?; + if !matches!( + content_type.as_str(), + "image/png" | "image/jpeg" | "image/webp" + ) { + return Err("worker 成功响应 Content-Type 不是受支持图片".to_string()); + } + let output = response + .bytes() + .await + .map_err(|error| transport_error("读取 worker 图片响应", &error))?; + if !has_image_magic(&output, &content_type) { + return Err("worker 图片响应为空或 MIME 与魔数不一致".to_string()); + } + println!( + "[6/8] 真实 BgFilter 调用成功;status=200;contentType={content_type};bytes={}", + output.len() + ); + Ok(()) +} + +async fn check_worker_readiness( + http_client: &reqwest::Client, + worker_endpoint: &Url, +) -> SmokeResult<()> { + let mut readiness_url = worker_endpoint.clone(); + readiness_url.set_path("/readyz"); + let response = http_client + .get(readiness_url) + .send() + .await + .map_err(|error| transport_error("worker readiness", &error))?; + if response.status() != StatusCode::OK { + return Err(format!( + "worker readiness 未就绪(status={})", + response.status().as_u16() + )); + } + Ok(()) +} + +async fn assert_unauthorized( + http_client: &reqwest::Client, + worker_endpoint: &Url, + request_id: &str, + payload: &Value, +) -> SmokeResult<()> { + let payload_bytes = + serde_json::to_vec(payload).map_err(|_| "序列化无鉴权 worker 请求失败".to_string())?; + let response = http_client + .post(worker_endpoint.clone()) + .header("X-Request-Id", request_id) + .header(header::CONTENT_TYPE, "application/json") + .body(payload_bytes) + .send() + .await + .map_err(|error| transport_error("无鉴权 worker 请求", &error))?; + if response.status() != StatusCode::UNAUTHORIZED { + return Err(format!( + "无鉴权 worker 请求未返回 401(status={})", + response.status().as_u16() + )); + } + let response_request_id = response + .headers() + .get("X-Request-Id") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + if response_request_id != request_id { + return Err("无鉴权 worker 响应的 X-Request-Id 不匹配".to_string()); + } + let is_json = response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(normalize_content_type) + .is_some_and(|value| value == "application/json"); + if !is_json { + return Err("无鉴权 worker 请求未返回 application/json".to_string()); + } + let body = response + .bytes() + .await + .map_err(|error| transport_error("读取无鉴权响应", &error))?; + let payload = serde_json::from_slice::(&body) + .map_err(|_| "无鉴权 worker 响应不是有效 JSON".to_string())?; + if payload.pointer("/error/code").and_then(Value::as_str) != Some("unauthorized") + || payload + .pointer("/error/attemptsStarted") + .and_then(Value::as_u64) + != Some(0) + || payload.pointer("/error/retryable").and_then(Value::as_bool) != Some(false) + { + return Err("无鉴权 worker 响应不符合 unauthorized 错误契约".to_string()); + } + Ok(()) +} + +async fn assert_source_absent( + http_client: &reqwest::Client, + oss_client: &OssClient, + object_key: &str, +) -> SmokeResult<()> { + match oss_client + .head_object( + http_client, + OssHeadObjectRequest { + object_key: object_key.to_string(), + }, + ) + .await + { + Err(OssError::ObjectNotFound(_)) => Ok(()), + Ok(_) => { + Err("临时 OSS objectKey 已存在;为避免误删,smoke 已中止且不会上传或删除".to_string()) + } + Err(error) => Err(format!( + "PUT 前 OSS HEAD 未明确返回 404({});为避免误删,smoke 已中止且不会上传或删除", + oss_error_label(&error) + )), + } +} + +async fn cleanup_source( + http_client: &reqwest::Client, + oss_client: &OssClient, + oss_config: &OssConfig, + object_key: &str, +) -> SmokeResult<()> { + let status = delete_object_v4(http_client, oss_config, object_key).await?; + if !status.is_success() { + return Err(format!("OSS DELETE 未成功(status={})", status.as_u16())); + } + println!("[7/8] OSS DELETE 返回 2xx,正在确认对象不存在"); + match oss_client + .head_object( + http_client, + OssHeadObjectRequest { + object_key: object_key.to_string(), + }, + ) + .await + { + Err(OssError::ObjectNotFound(_)) => Ok(()), + Ok(_) => Err("OSS DELETE 后对象仍可被 HEAD".to_string()), + Err(error) => Err(format!( + "OSS DELETE 后 HEAD 未返回 404({})", + oss_error_label(&error) + )), + } +} + +fn build_worker_payload(mode: SmokeMode, request_id: &str, object_key: &str) -> Value { + match mode { + SmokeMode::Flat => json!({ + "requestId": request_id, + "sourceObjectKey": object_key, + "backgroundMode": "flat", + "screenColor": "#00ff00", + "segModel": "birefnet", + "crossCheck": true, + "requestBudgetMs": REQUEST_BUDGET_MS, + }), + SmokeMode::Complex => json!({ + "requestId": request_id, + "sourceObjectKey": object_key, + "backgroundMode": "complex", + "segModel": "birefnet", + "crossCheck": false, + "requestBudgetMs": REQUEST_BUDGET_MS, + }), + } +} + +fn build_oss_config(local_env: &HashMap) -> SmokeResult { + OssConfig::new( + required_env(local_env, "ALIYUN_OSS_BUCKET")?, + required_env(local_env, "ALIYUN_OSS_ENDPOINT")?, + required_env(local_env, "ALIYUN_OSS_ACCESS_KEY_ID")?, + required_env(local_env, "ALIYUN_OSS_ACCESS_KEY_SECRET")?, + DEFAULT_READ_EXPIRE_SECONDS, + DEFAULT_POST_EXPIRE_SECONDS, + DEFAULT_POST_MAX_SIZE_BYTES, + DEFAULT_SUCCESS_ACTION_STATUS, + ) + .map_err(|error| format!("OSS 配置无效({})", oss_error_label(&error))) +} + +fn parse_cli() -> SmokeResult { + let mut options = CliOptions::default(); + let mut args = env::args().skip(1); + while let Some(argument) = args.next() { + let value = match argument.as_str() { + "--worker-url" | "--input" => args + .next() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| format!("{argument} 缺少参数值"))?, + "--help" | "-h" => { + println!( + "用法:bgfilter_worker_live_smoke [--worker-url ] [--input ]" + ); + std::process::exit(0); + } + _ => return Err("不支持的命令行参数;请使用 --help 查看用法".to_string()), + }; + match argument.as_str() { + "--worker-url" => options.worker_url = Some(value), + "--input" => options.input = Some(PathBuf::from(value)), + _ => unreachable!(), + } + } + Ok(options) +} + +fn build_worker_endpoint(raw: &str) -> SmokeResult { + let mut url = Url::parse(raw.trim()).map_err(|_| "worker URL 无效".to_string())?; + let loopback = matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "::1")); + if url.scheme() != "http" + || !loopback + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || !matches!(url.path(), "" | "/") + { + return Err("worker URL 必须是无凭据、无路径参数的 loopback HTTP 地址".to_string()); + } + url.set_path(INTERNAL_PATH); + Ok(url) +} + +fn repository_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..") +} + +fn load_local_env(repo_root: &Path) -> SmokeResult> { + let shell = env::vars() + .filter(|(key, _)| key != INTERNAL_TOKEN_ENV) + .collect::>(); + let protected = shell + .iter() + .filter(|(_, value)| !value.trim().is_empty()) + .map(|(key, _)| key.clone()) + .collect::>(); + let mut merged = shell; + for file_name in [".env", ".env.local", ".env.secrets.local"] { + let path = repo_root.join(file_name); + if !path.is_file() { + continue; + } + let contents = + fs::read_to_string(&path).map_err(|_| format!("无法读取本地配置文件 {file_name}"))?; + for raw_line in contents.lines() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, raw_value)) = line.split_once('=') else { + continue; + }; + if key == INTERNAL_TOKEN_ENV || !valid_env_key(key) || protected.contains(key) { + continue; + } + merged.insert( + key.to_string(), + trim_env_quotes(raw_value.trim()).to_string(), + ); + } + } + Ok(merged) +} + +fn valid_env_key(key: &str) -> bool { + let mut chars = key.chars(); + chars + .next() + .is_some_and(|value| value == '_' || value.is_ascii_alphabetic()) + && chars.all(|value| value == '_' || value.is_ascii_alphanumeric()) +} + +fn trim_env_quotes(value: &str) -> &str { + if value.len() >= 2 + && ((value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\''))) + { + &value[1..value.len() - 1] + } else { + value + } +} + +fn non_empty_env(local_env: &HashMap, name: &str) -> Option { + local_env + .get(name) + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn required_process_env(name: &str) -> SmokeResult { + env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("缺少必需进程环境变量 {name}")) +} + +fn required_env(local_env: &HashMap, name: &str) -> SmokeResult { + non_empty_env(local_env, name).ok_or_else(|| format!("缺少必需环境变量 {name}")) +} + +fn new_request_id() -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + let counter = REQUEST_COUNTER.fetch_add(1, Ordering::Relaxed); + let mut hasher = Sha256::new(); + hasher.update(now.as_secs().to_le_bytes()); + hasher.update(now.subsec_nanos().to_le_bytes()); + hasher.update(std::process::id().to_le_bytes()); + hasher.update(counter.to_le_bytes()); + let digest = hasher.finalize(); + let mut bytes = [0_u8; 16]; + bytes.copy_from_slice(&digest[..16]); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + format!( + "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", + bytes[0], + bytes[1], + bytes[2], + bytes[3], + bytes[4], + bytes[5], + bytes[6], + bytes[7], + bytes[8], + bytes[9], + bytes[10], + bytes[11], + bytes[12], + bytes[13], + bytes[14], + bytes[15], + ) +} + +fn normalize_content_type(value: &str) -> String { + value + .split(';') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase() +} + +fn has_image_magic(bytes: &[u8], content_type: &str) -> bool { + match content_type { + "image/png" => bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]), + "image/jpeg" => bytes.starts_with(&[0xff, 0xd8, 0xff]), + "image/webp" => bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP", + _ => false, + } +} + +async fn worker_status_error(response: reqwest::Response) -> String { + let status = response.status().as_u16(); + let code = response + .bytes() + .await + .ok() + .and_then(|body| serde_json::from_slice::(&body).ok()) + .and_then(|payload| { + payload + .pointer("/error/code") + .and_then(Value::as_str) + .filter(|value| { + !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + }) + .map(str::to_string) + }) + .unwrap_or_else(|| "unknown".to_string()); + format!("worker 返回非 200 状态(status={status}, code={code})") +} + +fn transport_error(operation: &str, error: &reqwest::Error) -> String { + let category = if error.is_timeout() { + "timeout" + } else if error.is_connect() { + "connect" + } else { + "transport" + }; + format!("{operation} 失败({category})") +} + +fn oss_error_label(error: &OssError) -> &'static str { + match error { + OssError::InvalidConfig(_) => "invalid_config", + OssError::InvalidRequest(_) => "invalid_request", + OssError::ObjectNotFound(_) => "object_not_found", + OssError::Request(_) => "request", + OssError::SerializePolicy(_) => "serialize_policy", + OssError::Sign(_) => "sign", + } +} + +async fn delete_object_v4( + client: &reqwest::Client, + config: &OssConfig, + object_key: &str, +) -> SmokeResult { + let signed_at = OffsetDateTime::now_utc(); + let signature_date = build_v4_signature_date(signed_at); + let signature_scope = build_v4_signature_scope(config.endpoint(), signed_at)?; + let canonical_uri = build_v4_canonical_uri(config.bucket(), object_key); + let host = format!("{}.{}", config.bucket(), config.endpoint()); + let signed_headers = BTreeMap::from([ + ("host".to_string(), host), + ( + "x-oss-content-sha256".to_string(), + OSS_UNSIGNED_PAYLOAD.to_string(), + ), + ("x-oss-date".to_string(), signature_date.clone()), + ]); + let canonical_headers = build_v4_canonical_headers(&signed_headers); + let additional_headers = "host"; + let canonical_request = format!( + "DELETE\n{canonical_uri}\n\n{canonical_headers}\n{additional_headers}\n{OSS_UNSIGNED_PAYLOAD}" + ); + let string_to_sign = format!( + "{OSS_V4_ALGORITHM}\n{signature_date}\n{signature_scope}\n{}", + sha256_hex(canonical_request.as_bytes()) + ); + let signature = sign_v4_content( + config.access_key_secret(), + &signature_scope, + &string_to_sign, + )?; + let target_url = build_object_url(config.bucket(), config.endpoint(), object_key)?; + let response = client + .request(Method::DELETE, target_url) + .header("x-oss-content-sha256", OSS_UNSIGNED_PAYLOAD) + .header("x-oss-date", signature_date) + .header( + header::AUTHORIZATION, + format!( + "{OSS_V4_ALGORITHM} Credential={}/{},AdditionalHeaders={additional_headers},Signature={signature}", + config.access_key_id(), + signature_scope, + ), + ) + .send() + .await + .map_err(|error| transport_error("OSS DELETE", &error))?; + Ok(response.status()) +} + +fn build_object_url(bucket: &str, endpoint: &str, object_key: &str) -> SmokeResult { + Url::parse(&format!("https://{bucket}.{endpoint}/")) + .and_then(|url| url.join(object_key.trim_start_matches('/'))) + .map_err(|_| "构造 OSS DELETE URL 失败".to_string()) +} + +fn build_v4_signature_scope(endpoint: &str, signed_at: OffsetDateTime) -> SmokeResult { + let date = format_v4_signature_scope_date(signed_at); + let region = endpoint + .trim() + .trim_start_matches("https://") + .trim_start_matches("http://") + .split('.') + .next() + .and_then(|segment| segment.strip_prefix("oss-")) + .filter(|region| !region.is_empty()) + .ok_or_else(|| "OSS endpoint 无法解析 V4 region".to_string())?; + Ok(format!("{date}/{region}/{OSS_V4_SERVICE}/{OSS_V4_REQUEST}")) +} + +fn build_v4_signature_date(signed_at: OffsetDateTime) -> String { + format!( + "{}T{:02}{:02}{:02}Z", + format_v4_signature_scope_date(signed_at), + signed_at.hour(), + signed_at.minute(), + signed_at.second() + ) +} + +fn format_v4_signature_scope_date(signed_at: OffsetDateTime) -> String { + format!( + "{:04}{:02}{:02}", + signed_at.year(), + signed_at.month() as u8, + signed_at.day() + ) +} + +fn build_v4_canonical_uri(bucket: &str, object_key: &str) -> String { + format!( + "/{}/{}", + encode_url_query_value(bucket), + encode_url_path(object_key.trim_start_matches('/')) + ) +} + +fn build_v4_canonical_headers(headers: &BTreeMap) -> String { + headers + .iter() + .map(|(key, value)| format!("{}:{}\n", key.to_ascii_lowercase(), value.trim())) + .collect::() +} + +fn sign_v4_content( + access_key_secret: &str, + signature_scope: &str, + content: &str, +) -> SmokeResult { + let mut scope = signature_scope.split('/'); + let date = scope + .next() + .ok_or_else(|| "OSS V4 scope 缺少日期".to_string())?; + let region = scope + .next() + .ok_or_else(|| "OSS V4 scope 缺少 region".to_string())?; + let service = scope + .next() + .ok_or_else(|| "OSS V4 scope 缺少 service".to_string())?; + let request = scope + .next() + .ok_or_else(|| "OSS V4 scope 缺少 request".to_string())?; + let date_key = hmac_sha256_raw(format!("aliyun_v4{access_key_secret}").as_bytes(), date)?; + let region_key = hmac_sha256_raw(&date_key, region)?; + let service_key = hmac_sha256_raw(®ion_key, service)?; + let signing_key = hmac_sha256_raw(&service_key, request)?; + Ok(hex_sha256_hmac(&signing_key, content.as_bytes())) +} + +fn hmac_sha256_raw(key: &[u8], content: &str) -> SmokeResult> { + let mut signer = + HmacSha256::new_from_slice(key).map_err(|_| "初始化 OSS HMAC-SHA256 失败".to_string())?; + signer.update(content.as_bytes()); + Ok(signer.finalize().into_bytes().to_vec()) +} + +fn hex_sha256_hmac(key: &[u8], content: &[u8]) -> String { + let mut signer = HmacSha256::new_from_slice(key).expect("HMAC-SHA256 accepts any key size"); + signer.update(content); + hex_lower(&signer.finalize().into_bytes()) +} + +fn sha256_hex(content: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(content); + hex_lower(&hasher.finalize()) +} + +fn hex_lower(bytes: &[u8]) -> String { + bytes + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() +} + +fn encode_url_path(path: &str) -> String { + path.split('/') + .map(encode_url_query_value) + .collect::>() + .join("/") +} + +fn encode_url_query_value(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + encoded.push(byte as char); + } + _ => { + use std::fmt::Write as _; + let _ = write!(&mut encoded, "%{byte:02X}"); + } + } + } + encoded +} -- 2.52.0 From 27f84fc6a7f8c722769a340de0fbab022ad530f2 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 22 Jul 2026 04:24:11 +0000 Subject: [PATCH 10/27] =?UTF-8?q?=E5=AE=8C=E5=96=84=20BgFilter=20=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E5=AE=A1=E8=AE=A1=E4=B8=8E=E6=9D=83=E5=A8=81=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充已发出 provider 请求的失败审计判定,并由 shutdown tracker 排空异步审计任务。 统一父流程、BgFilter worker、重试、超时、fallback 与结果持久化的架构和编辑器文档口径。 修正本地 dev 进程角色、live smoke 端口、OTEL identity、journal 与端口路由 skill。 明确当前容器拓扑不包含 BgFilter worker,仅覆盖非 BgFilter 路径和 unsupported queue smoke。 --- .env.example | 3 +- .../SKILL.md | 6 +- deploy/container/README.md | 4 +- .../shared-memory/decision-log.md | 8 +- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 6 +- ...架构】BgFilter受限资源调度方案-2026-07-21.md | 29 ++++---- ...端架构】外部生成Worker化方案-2026-06-03.md | 4 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 4 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 22 ++++-- ...辑器】画板UI设计图生成入口设计-2026-06-17.md | 4 +- ...辑器】画板图标素材生成入口设计-2026-06-15.md | 4 +- ...辑器】画板角色形象生成入口设计-2026-06-15.md | 12 +-- .../crates/api-server/src/bgfilter_worker.rs | 73 +++++++++++++++++-- 13 files changed, 128 insertions(+), 51 deletions(-) diff --git a/.env.example b/.env.example index ccd41510e..6e6a81d78 100644 --- a/.env.example +++ b/.env.example @@ -145,7 +145,8 @@ ALIYUN_OSS_SUCCESS_ACTION_STATUS="200" # BgFilter 受限资源 worker。父 api-server / external-generation-worker 与唯一的 # `GENARRATIVE_PROCESS_ROLE=bgfilter-worker` 进程必须使用同一个内部 Token。 -# 本地需要真实联调 BgFilter 时,在第二个终端启动专用进程;不要让 `all` 角色兼任它。 +# `npm run dev` 与 `npm run dev:api-server` 都会自动带起并验活唯一 worker,不要再开第二个终端重复启动。 +# 只有需要脱离父 API 单独验证 worker 时才运行 `npm run dev:bgfilter-worker`;不要让 `all` 角色兼任它。 GENARRATIVE_BGFILTER_WORKER_HOST="127.0.0.1" GENARRATIVE_BGFILTER_WORKER_PORT="8083" GENARRATIVE_BGFILTER_WORKER_BASE_URL="http://127.0.0.1:8083" diff --git a/.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md b/.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md index 18b097648..2562ed0f6 100644 --- a/.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md +++ b/.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md @@ -78,7 +78,7 @@ Linux 多用户并发开发时,`GENARRATIVE_DEV_PORT_RANGE` 或 `--port-range` - `scripts/dev-stack-port-utils.mjs` - `scripts/dev.mjs` - `scripts/dev-utils.mjs` - - `docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md` + - `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` - `docs/project-memory/shared-memory/pitfalls.md` 2. 优先改公共端口工具,不要把端口探测逻辑复制到多个脚本。 3. 修改 `scripts/dev.mjs` 时确认变量顺序:先解析参数和端口,再构造 `SPACETIME_SERVER` / `RUST_SERVER_TARGET`,最后启动对应 service。 @@ -109,7 +109,7 @@ node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:0 ap - `[dev] spacetime: http://...:` - 主站和后台 Vite 启动端口与日志一致。 -完整启动属于长驻进程。需要 smoke 时用 background 方式启动,并另开命令检查 `/healthz`、`/v1/ping` 和页面端口;不要等待 `npm run dev` 自然退出。 +完整启动属于长驻进程。需要 smoke 时用 background 方式启动,并另开命令检查 api-server `/healthz`、BgFilter worker `/readyz`、SpacetimeDB `/v1/ping` 和两个页面端口;不要等待 `npm run dev` 自然退出。检查地址必须取 `.app/dev-stack.json` 或启动日志中的实际端口,不能假定 worker 一定停在 `8083`。 ## 常见坑 @@ -128,6 +128,6 @@ node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:0 ap - [ ] `npm run dev` 的 SpacetimeDB、publish、api-server、主站 Vite、后台 Vite 都使用实际端口。 - [ ] BgFilter worker 在 api-server 前 ready,父子共享实际 base URL / Token,Rust watch 只触发一次组合重启。 - [ ] `npm run dev:web` 在主站端口不可用时能切换到可用端口。 -- [ ] 文档同步更新 `docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md`。 +- [ ] 文档同步更新 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 - [ ] 长期踩坑同步更新 `docs/project-memory/shared-memory/pitfalls.md`。 - [ ] 修改中文文件后运行 `npm run check:encoding`。 diff --git a/deploy/container/README.md b/deploy/container/README.md index 9c7f304d6..f337e5629 100644 --- a/deploy/container/README.md +++ b/deploy/container/README.md @@ -1,6 +1,6 @@ # Genarrative 容器化压测与隔离部署方案 -本目录只服务本机或预发的容器化模拟压测,不替换当前生产 `systemd + Nginx + Jenkins` 发布路径。生产服务器仍以 `deploy/systemd/`、`deploy/nginx/`、`scripts/jenkins-*.sh` 和 `scripts/deploy/production-api-deploy.sh` 为准。 +本目录只服务本机或预发的容器化模拟压测,不替换当前生产 `systemd + Nginx + Jenkins` 发布路径。当前 compose 不包含独立 `bgfilter-worker`,因此不是完整 BgFilter 预发拓扑,也不覆盖会触发 BgFilter 的现役任务;这里只验证非 BgFilter 路径,或使用 unsupported job 检查队列 claim / fail 回写和 API / worker 进程隔离。生产服务器仍以 `deploy/systemd/`、`deploy/nginx/`、`scripts/jenkins-*.sh` 和 `scripts/deploy/production-api-deploy.sh` 为准。 ## 拓扑 @@ -15,7 +15,7 @@ Docker Compose 当前容器模拟参数按 `genarrative-release` 服务器采样值收口为 2 vCPU / 2 GiB RAM / 4096 soft nofile / 768 worker_connections,并已在 compose 里落实到 `spacetimedb cpus=1.0 mem_limit=896m`、`api-server cpus=2.0 mem_limit=1g`、`external-generation-worker cpus=2.0 mem_limit=1g`、`nginx cpus=0.5 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`。SpacetimeDB 同时设置 `--page_pool_max_size=402653184`,给 reducer、订阅与运行时保留更多非 page pool 内存。 容器 `api-server` 默认 `GENARRATIVE_API_WORKER_THREADS=4`,用于让 Tokio 在 2 vCPU 配额内有更多 I/O 调度 worker;该值不会突破 compose 里的 `cpus=2.0` CPU 上限。 -容器默认 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,用于验证 `api-server -> external_generation_job -> external-generation-worker` 链路;如只想本地同步排查 provider/OSS/SpacetimeDB 写回,可在本机 env 临时改为 `inline`,但该模式不会覆盖 worker 动态扩缩容验证。 +容器默认 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,用于验证不经过 BgFilter 的 `api-server -> external_generation_job -> external-generation-worker` 链路;会触发 BgFilter 的任务不属于当前 compose 验收范围。如只想本地同步排查非 BgFilter provider / OSS / SpacetimeDB 写回,可在本机 env 临时改为 `inline`,但该模式不会覆盖 worker 动态扩缩容验证。 Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。 生产服务器若启用 Collector,则由 `deploy/systemd/otelcol-contrib.service` 和 `deploy/otelcol/genarrative-debug.yaml` 托管,不走容器镜像。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 949a8ef04..74e1ee90c 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -23,7 +23,7 @@ - 超时边界:父 job 总预算仍为普通 `900s` / 长任务 `1800s`,并保留现有 `60s` 终态写回窗口;`180s` 仅是子 worker 单次真实 provider attempt 的默认上限,父侧据此派生 `2 × attempt timeout + 1s response window` 的逻辑调用上限,再按父绝对 deadline 和 flat fallback reserve 截短。内部 RPC 总预算包含 admission 后的 semaphore 等待、最多两次 attempt、结果校验和二进制返回。动画删除旧的 `2000ms × frame_count` 单次 timeout 增量,父侧不得在内部 timeout / 断连后重试整次 RPC。 - 故障边界:首版不新增 `bgfilter_task_group`、`bgfilter_request_task`、raw OSS、checkpoint、continuation、数据库 capacity slot、共享熔断或 QPS token bucket。父或子进程崩溃、RPC 丢失时不查询、不恢复结果;父 job 沿用现有 lease / `max_attempts=1` 失败退款语义。动画首版保持所有已提交帧 collect / drain,不增加跨帧取消组。 - 部署边界:专用进程首版仍复用完整 `AppState`,因此 systemd unit 先加载共享 `/etc/genarrative/api-server.env`,再加载 `/etc/genarrative/bgfilter-worker.env` 覆盖 worker 独占参数;共享 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 必须在父子进程保持同一有效值,flat 熔断 threshold / cooldown 只归子 worker。发布切换前校验父子使用同一个非空、非符号链接、`root:genarrative 0440` 的内部 Token 文件;拒绝 `external-generation-worker.env` 覆盖出不同的内部 URL、Token 文件、connect timeout、provider timeout、OSS bucket 或 endpoint(同 bucket 的独立 AK 允许),并要求父 base URL、子 `HOST / PORT` 与 readiness URL 指向同一 loopback endpoint。worker unit 使用 `TimeoutStopSec=900` 覆盖最大 `600s` 请求预算的优雅排空,运行期巡检同时检查唯一 worker unit active 与 loopback readiness。本地 `npm run dev` 同样启动独立子进程并解析第五个 dev 端口,`ProcessRole::All` 不内嵌 listener。 -- 影响范围:后续实现涉及 `api-server` 内部 HTTP client、专用 `bgfilter-worker` listener / process role、并发与超时配置、部署和运维观测;不修改 SpacetimeDB schema、父 job schema、用户任务 DTO、任务列表或收费归属。 +- 影响范围:已实施范围包括 `api-server` 内部 HTTP client、专用 `bgfilter-worker` listener / process role、并发与超时配置、部署和运维观测;未修改 SpacetimeDB schema、父 job schema、用户任务 DTO、任务列表或收费归属,当前待生产压测后启用。 - 验证方式:`48` 帧并发进入父 future 时,健康唯一子 worker 进程持有的 BgFilter HTTP future 峰值不得超过生产显式配置的 `N`,且 `queued + running` 不超过 `Q`;覆盖 flat / complex 降级矩阵、内部 deadline、断连后已启动请求排空、动画全帧 drain、External v1 / inline 旁路扫描、二进制大小 / MIME / 尺寸门禁、单实例部署、DDD、编码和 diff 门禁。 - 关联文档:`docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 @@ -125,6 +125,8 @@ ## 2026-07-15 BgFilter 输入改用私有 OSS 短期签名 URL +> 后续更正(2026-07-21):复用 object key、通过 `image_url` 提交且不传 `file` 的协议语义保留,但 600 秒 OSS GET URL 的签发和 BgFilter provider multipart 调用已迁入唯一 `bgfilter-worker`。父流程只向内部 worker 发送一次 object key、参数和剩余预算,不签发 BgFilter URL,也不重试整次内部 RPC。下文保留作历史记录。 + - 背景:角色形象、图标图集、UI 素材图集、角色动作抽取帧和手动去背景在调用 BgFilter 前都已有私有 OSS object key;继续由 api-server 下载或保留图片并作为 multipart `file` 再上传,会重复传输图片字节并占用 API 进程网络与内存。 - 决策:上述抠图链路统一复用 object key,签发 600 秒 OSS GET URL,并通过 BgFilter multipart 的 `image_url` 字段提交;请求中不再携带 `file`。签名 URL 只交给 BgFilter,不写日志或持久化。2026-07-17 起,生成原图和动作帧上传后不再保留图片字节;进入“阿里云通用抠图 → 本地键色”兜底链时按阶段从私有 OSS 重新下载。 - 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/api-server/src/character_animation_assets.rs`、相关测试与文档;不改变 BgFilter endpoint、鉴权、`screen_color`、`seg_model`、输出校验、熔断规则、阿里云上传协议或降级顺序。 @@ -140,6 +142,8 @@ ## 2026-07-15 角色动作 BgFilter 请求超时按帧数扩展 +> 后续更正(2026-07-21):本条按帧数增加 `2000ms × frame_count`、形成 `244000 / 260000 / 276000ms` 单 attempt timeout 的决策,已被 2026-07-21「BgFilter 首版采用单实例同步内部 HTTP 与父流程原地等待」取代。当前角色动作的每次真实 provider attempt 与其它 BgFilter 路径一样使用默认 `180000ms` 上限,不再按帧数扩展;排队、等待 `N`、最多两次顺序 attempt、校验与响应统一受父流程派生的内部 RPC 剩余预算约束。下文保留作历史记录。 + - 背景:角色动作全部序列帧会并发进入 BgFilter,而服务端可能在自身进程内排队;固定 `180000ms` 会把排队时间和单帧推理共用同一预算,靠后的请求可能在服务仍正常处理时被 api-server 提前取消。 - 决策:保留 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 作为统一基准值。只有角色动作逐帧 BgFilter 在共享 Client 的 RequestBuilder 上把每一次 HTTP attempt 覆盖为“基准值 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧为 `244000 / 260000 / 276000ms`;角色形象单图、图标、UI 和手动去背景不增加帧预算。该 timeout 覆盖请求发起到响应体读取完成;首次失败后的重试重新获得同样的 request deadline,整批并发策略、失败排空语义和 worker long-job 总预算不变。 - 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/api-server/src/character_animation_assets.rs`、后端架构、开发运维和图片画布专题文档。 @@ -148,6 +152,8 @@ ## 2026-07-15 手动复杂去背景复用 BgFilter 单次重试 +> 后续更正(2026-07-21):首次失败后再尝试一次、即同一次 complex 逻辑调用最多两次顺序 provider attempt 的语义保留,但重试所有权已迁入唯一 `bgfilter-worker`。父 `external-generation-worker` 只发送一次内部 HTTP RPC,不重试整次 RPC;两次 provider attempt 都失败时,子 worker 把最终类型化错误返回父流程,complex 仍不接入 flat 的阿里云 / 本地 fallback。下文所称“worker 重试”按此边界理解。 + - 背景:图片画布手动去背景已经改用 BgFilter `background_mode=complex`,但 worker 仍只发送一次上游请求,短暂网络抖动会直接让任务失败。 - 决策:手动去背景的 complex 请求复用现有 `EDITOR_BGFILTER_RETRY_COUNT=1`,首次请求失败后立即重试一次,两次都失败仍返回最终错误;本次不把手动 complex 接入标准纯色背景链路的阿里云 / 本地兜底,也不改变 flat 路径的熔断状态。 - 影响范围:图片画布手动去背景 worker、BgFilter complex 请求日志和 api-server 定向测试。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index f7796b6e7..7a78d16a3 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -92,8 +92,8 @@ - `POST /api/editor/assets`:批量或单个创建账号级素材,登录态上传必须写入 OSS / asset object 引用和 `/` 轻量路径,不允许把 Data URL / signed URL 写入素材库。 - `PATCH /api/editor/assets/{assetId}`:重命名素材或移动素材到文件夹。 - `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。 -- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片。带 `model / aspectRatio / imageSize` 的用户生成必须把当前 K 档对应的真实像素直接传给 provider,前端占位与该请求尺寸使用同一映射;不得先请求固定 1K 再放大为 2K。普通图片的 provider 回图先留在内存,尺寸变换成功后只上传变换结果,变换失败则只上传 provider 原图,主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`;api-server 先保存带纯色背景源图,再调用 BgFilter 并传入 `screen_color=`、`seg_model=`,透明处理成功时生成透明 PNG,最终失败时按前述多产物降级规则以原图主结果和通用 `warning` 收口。角色、图标图集和 UI 图集的透明处理正常成功但返回尺寸与 provider 原图不同时,只重采样透明图的 alpha 蒙版并应用回 provider 原图的原始分辨率 RGB,不放大低分辨率后处理成品。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`,并继续按固定交付像素处理。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits;`nanobanana2` 的 `512 / 1024 / 2K` 是标量清晰度档位,后端保留 provider 输出几何尺寸,不按 `宽x高` 解析。从既有图层重新打开生成器且没有仍存活的对话框快照时,前端按该图层真实 `originalWidth / originalHeight` 恢复比例和清晰度,不得回落到新建面板的 1K 默认值。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成完成后在响应中返回实际产物的 project / resource / asset 快照。 -- `POST /api/editor/images/background-removals`:接收当前图片的 `objectKey`、`resourceId` 或 `assetId` 候选引用,登录态和稳定引用入口校验通过后创建外部生成任务,响应只返回 `queueState`。worker 才负责引用解析和归属校验,不下载原图;调用共享 BgFilter HTTP client 前签发 600 秒 OSS URL,multipart 固定为 `image_url + background_mode=complex + seg_model=birefnet + cross_check=off`,不包含 `file` 或 `screen_color`,首次失败立即重试 `1` 次,两次都失败返回最终错误。请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`,有 `canvasCompletion` 时完成后按生成占位写入结果图层,否则沿用旧的目标图层替换路径。令牌只在服务端通过 `GENARRATIVE_EDITOR_BGFILTER_TOKEN` 注入,未配置时兼容回退旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。 +- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片。带 `model / aspectRatio / imageSize` 的用户生成必须把当前 K 档对应的真实像素直接传给 provider,前端占位与该请求尺寸使用同一映射;不得先请求固定 1K 再放大为 2K。普通图片的 provider 回图先留在内存,尺寸变换成功后只上传变换结果,变换失败则只上传 provider 原图,主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`;父流程先保存带纯色背景源图,随后只以 object key 向唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC;子 worker 在每次真实 provider attempt 前签发短期 OSS URL,并向 BgFilter 传入 `screen_color=`、`seg_model=`。父流程不直连 BgFilter、不签发该 URL,也不重试整次内部 RPC;透明处理成功时生成透明 PNG,最终失败时按前述多产物降级规则以原图主结果和通用 `warning` 收口。角色、图标图集和 UI 图集的透明处理正常成功但返回尺寸与 provider 原图不同时,只重采样透明图的 alpha 蒙版并应用回 provider 原图的原始分辨率 RGB,不放大低分辨率后处理成品。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`,并继续按固定交付像素处理。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits;`nanobanana2` 的 `512 / 1024 / 2K` 是标量清晰度档位,后端保留 provider 输出几何尺寸,不按 `宽x高` 解析。从既有图层重新打开生成器且没有仍存活的对话框快照时,前端按该图层真实 `originalWidth / originalHeight` 恢复比例和清晰度,不得回落到新建面板的 1K 默认值。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成完成后在响应中返回实际产物的 project / resource / asset 快照。 +- `POST /api/editor/images/background-removals`:接收当前图片的 `objectKey`、`resourceId` 或 `assetId` 候选引用,登录态和稳定引用入口校验通过后创建外部生成任务,响应只返回 `queueState`。父 `external-generation-worker` 负责把候选引用解析为已登记、已校验当前账号归属的私有 OSS object key,只向唯一 `bgfilter-worker` 发起一次内部 HTTP RPC,传递 object key、剩余预算以及固定的 `background_mode=complex + seg_model=birefnet + cross_check=off`;父侧不下载原图、不签发 URL,也不发送 `file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 OSS URL,以 admission `Q` 和 provider 并发 `N` 限流,并对同一次逻辑调用最多执行两次顺序 provider attempt;成功图片以内部 HTTP 二进制 body 返回父流程,父侧不重试整次内部 RPC。complex 任意最终失败都直接使父任务失败,不进入阿里云或本地键色 fallback。请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`;成功后仍由父流程完成最终 OSS / project resource 持久化,有 `canvasCompletion` 时按生成占位写入结果图层,否则沿用旧的目标图层替换路径。provider 令牌只在子 worker 服务端通过 `GENARRATIVE_EDITOR_BGFILTER_TOKEN` 注入,未配置时兼容回退旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`;父子内部调用另使用独立内部 Token。 - `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet;api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet 并尝试拆分。请求支持 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但拆分失败时保留整张透明图并返回 `sliceWarning`。响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`。 - `POST /api/editor/ui-designs/assets/extractions`:前端把红色框选轮廓绘入本地临时图后,先将该图上传 OSS 并确认 asset object,再以返回的 `objectKey` 作为参考图入队;Data URL / Blob URL 只允许停留在上传前的浏览器临时态。接口固定 `gpt-image-2` 和自动决策纯色背景素材提取提示词生成素材 spritesheet;api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet 并按连通域尝试拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `screenColor`、`segModel`、`aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但拆分失败时保留整张透明图并返回 `sliceWarning`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`;前端按后端快照落画布,不补造缺失产物。 - `POST /api/editor/images/edits`:按提示词和当前图片的已登记 `objectKey` / `resourceId` 修改图片,返回新的生成图片元数据;图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`,并随用户当前选择提交 `model / aspectRatio / imageSize / size`。api-server 必须先归一模型再选择 VectorEngine 协议:`nanobanana2` 调用 `/v1beta/models/{model}:generateContent` 并把原图作为 `inline_data`、比例和清晰度写入 `generationConfig.imageConfig`;`gpt-image-2` 调用 `/v1/images/edits` multipart。gpt-image-2 路径在 provider 边界把目标尺寸和所有 multipart 参考图临时补齐到 16 的倍数,回图后在内存恢复业务目标尺寸;nanobanana2 路径保留 provider 按比例和清晰度返回的几何尺寸。成功时只上传最终结果,尺寸恢复失败时只上传 provider 原图;无论是否发生尺寸恢复都只创建一个 project resource / 账号素材,不显示重复“原始输出”。16 对齐尺寸不得泄漏到正常完成的最终响应、资源或图层 Resolution;变换失败降级时以实际 provider 原图尺寸为准。本地红框标记图必须先上传再提交 objectKey;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。 @@ -137,7 +137,7 @@ - 发送消息后,面板先展示本地用户消息和请求等待态,再应用普通 JSON 响应中的 `deltaMessages`;客户端取消等待只终止本次 transport 等待,不把已经确认入队的外部生成任务改成停止态。 - Agent 工具任务完成并懒回填后,消息内缩略图只作纯预览,不显示名称也不点击聚焦图层;前端同时重新读取工程快照和素材库。对话入口触发生成时不创建“即将生成”画布占位,生成完成后由后端 `canvasCompletion` 落新图层。规划或工具失败时消息内必须保留可回读的失败状态和错误气泡,不能只弹一次性 toast 或返回瞬时 `errorMessage`。 - 画布 Agent 会话刷新后能从后端恢复会话标题、消息、附件和生成记录;前端不得根据本地临时状态伪造会话持久化结果。 -- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`,由 api-server 通过共享 BgFilter `background_mode=complex` 链路去背景并持久化结果;有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词。透明处理及拆分正常完成时,透明 spritesheet 和拆分素材都按后端快照保留为画布图层;透明处理失败时仅原图作为主结果,既不要求透明图也不要求切片;透明图成功但拆分失败时保留整张透明图并展示拆分告警。三种完成结果都以后端项目快照为准。 +- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`;父流程解析并校验私有 OSS object key 后只调用一次唯一内部 `bgfilter-worker` 的 complex 链路,子 worker 负责签发 600 秒 URL、`N / Q` 限流和最多两次顺序 provider attempt,complex 失败不接入 fallback,成功二进制返回后仍由父流程完成最终持久化。有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词。透明处理及拆分正常完成时,透明 spritesheet 和拆分素材都按后端快照保留为画布图层;透明处理失败时仅原图作为主结果,既不要求透明图也不要求切片;透明图成功但拆分失败时保留整张透明图并展示拆分告警。三种完成结果都以后端项目快照为准。 - 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。 - 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,优先复用当前图层已有 `objectKey` / `resourceId` / `sourceAssetId`;尚未登记且没有稳定引用的浏览器本地图片或普通 public 图片路径都必须先上传并取得 objectKey。前端不得再把正式对象下载成 `data:image/*;base64,...` 后提交,也不得把 Data URL / Blob URL 写入外部生成持久任务 JSON;后端收到引用后统一做 owner 归属校验并签名读取。 - 快速编辑不保留额外参考图入口;点击修改时只把原图或红框序号标注图作为 `/api/editor/images/edits` 的 `sourceImageSrc` 提交给后端。 diff --git a/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md b/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md index 73aff03bf..e945b82ad 100644 --- a/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md +++ b/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md @@ -179,7 +179,7 @@ Content-Type: image/png - `circuit_open`:flat 熔断已打开,未发送 provider 请求; - `deadline_exceeded`:排队、provider 或响应阶段预算耗尽,使用 `phase = queue | provider | response`; - `overloaded`:`queued + running` 已达到 `Q`; -- `cancelled`:调用方已取消且尚未开始新的 provider attempt; +- `cancelled`:保留错误码,首版子 worker 不产生。首版没有显式取消信号通道,单纯 TCP 断连后 handler future 被 drop、也无法再返回响应;该码为第二阶段 group cancellation 预留,父侧已按“不启动 fallback”实现映射; - `invalid_request`:内部契约不合法; - `unauthorized`:内部 Token 缺失或不匹配; - `invalid_result`:provider 回图超限、MIME / 魔数不匹配或无法安全解码; @@ -244,13 +244,13 @@ inline / External v1 没有 queue job deadline 时,内部 RPC 仍必须有界 ### 5.3 动画旧增量 -当前动画把单次 BgFilter timeout 设为: +旧实现曾把动画单次 BgFilter timeout 设为: ```text 180s + 2000ms × frame_count ``` -该增量原本用于容纳 32 / 40 / 48 个请求直接进入 BgFilter 后的服务端排队。新版已把排队前移到内部 worker,必须删除该增量;所有真实 provider attempt 统一使用 `180s` 上限,动画尾部请求能等待多久由各自内部 RPC 剩余预算决定。 +该增量原本用于容纳 32 / 40 / 48 个请求直接进入 BgFilter 后的服务端排队。当前实现已把排队前移到内部 worker并删除该增量;所有真实 provider attempt 统一使用 `180s` 上限,动画尾部请求能等待多久由各自内部 RPC 剩余预算决定。 ## 6. 并发、排队与熔断 @@ -320,7 +320,7 @@ flat 熔断的 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD` 与 `GENA - 父侧不得自动重试整次内部 HTTP。断连时结果未知,重试可能让一次逻辑调用从最多两次 provider attempt 扩大为四次,并可能突破瞬时并发预期。 - 尚在等待 permit 的请求到达自身 deadline 后必须取消,不再发送 provider 请求;运行时若能可靠观察客户端断连,也可提前取消,但正确性不能只依赖断连事件。 - 已经开始的 provider attempt 必须继续读取到完成或本次 attempt timeout,并持有 permit;可观察到的 handler / client drop 只丢弃最终结果,不能让已启动请求变成无人管理的本地 future。 -- deadline 或显式 cancellation signal 已被子 worker 观察到后,不再开始第二次 attempt。单纯 TCP 断连只能 best-effort 触发 cancellation;Axum / Hyper 不保证立刻通知 handler,因此不能承诺所有断连都阻止二试。 +- deadline 已被子 worker 观察到后,不再开始第二次 attempt。首版没有显式 cancellation signal 通道;单纯 TCP 断连只能 best-effort 阻止二试(handler future 被 drop 后自然不再开始新 attempt),Axum / Hyper 不保证立刻通知 handler,因此不能承诺所有断连都阻止二试,`requestBudgetMs` deadline 是最终可靠的停止条件。 实现时,已启动 provider attempt 应由持有 permit 的独立 task 管理;request handler 可观察到的 Drop / cancellation signal 只影响“是否继续重试和是否返回结果”,不直接丢弃已经开始的 provider future。`requestBudgetMs` deadline 是最终可靠的停止条件。 @@ -343,7 +343,7 @@ flat 熔断的 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD` 与 `GENA | --- | --- | --- | | flat 单图 / 动画帧 | 图片二进制 | 继续现有 Alpha / 尺寸恢复、finalizer 和最终持久化 | | flat,父业务预算仍有效 | `provider_exhausted / circuit_open / deadline_exceeded / overloaded / invalid_result / internal_error` 或内部断连 | 记录对应故障后进入现有“阿里云通用抠图 → 本地键色”;这些内部错误本身不都计入熔断 | -| flat | `cancelled`,或父 job cancellation / 绝对 deadline 已生效 | 立即向上退出,不再启动阿里云或本地 fallback | +| flat | `cancelled`(保留码,首版子 worker 不产生),或父 job cancellation / 绝对 deadline 已生效 | 立即向上退出,不再启动阿里云或本地 fallback | | flat | `invalid_request / unauthorized` | 作为内部契约或部署配置错误失败,不 fallback、不计入 BgFilter 熔断 | | complex 手动去背景 | 图片二进制 | 父流程继续最终 OSS、资源和画布写回 | | complex 手动去背景 | 任意非成功或断连 | 父流程直接失败;不得接 flat fallback,不得修改 flat 熔断 | @@ -395,7 +395,7 @@ BgFilter 成功二进制不是一份新的业务资产: 日志只写 `requestId`、父 job / request correlation、mode、attempt、排队耗时、provider 耗时、结果码和安全 object key;不得记录请求/响应图片 body。 -当前 flat 的每次 provider 失败审计必须迁到子 worker,保留“第一次失败、第二次成功”也可观察的事实;内部 admission、鉴权和本地配置错误不伪装成 BgFilter provider 失败。 +当前 flat 的每次 provider 失败审计必须迁到子 worker,保留“第一次失败、第二次成功”也可观察的事实。审计口径以“该次 attempt 是否已发出 provider HTTP”为界:已发出的失败一律写入 `external_api_call_failure`,包括被剩余预算截短后发生的 timeout 与 response 阶段超时(它们不计入熔断,但必须可审计);未发出的失败(预算不足未启动、签名失败)以及内部 admission、鉴权和本地配置错误不伪装成 BgFilter provider 失败。子 worker 进程角色不共享 api / extgen 的落盘 tracking outbox,失败审计由异步任务直写 SpacetimeDB,并纳入 shutdown tracker,优雅退出前排空;进程被强杀时可能丢失,属首版接受行为。 ## 10. 实施与部署计划 @@ -407,7 +407,7 @@ BgFilter 成功二进制不是一份新的业务资产: 4. 增加父侧共享内部 HTTP client。该 client 不自动重试;对 `2xx` 读取并返回受限图片字节,对非 `2xx` 只解析有界类型化 JSON 错误;把父剩余预算显式转换为较短的 `requestBudgetMs` 和略长的 client timeout,并保证两者都早于父绝对 deadline。 5. 用内部 client 替换两个集中调用边界: - flat:`remove_editor_generated_screen_background_with_bgfilter_with_request_timeout`; - - complex:`request_editor_background_removal_image_with_retry`。 + - complex:`request_editor_background_removal_image_with_bgfilter_worker`。 6. 从父侧移除 BgFilter provider retry 和 flat 熔断实现;保留 flat fallback、complex 失败、Alpha / 尺寸恢复和所有最终持久化。 7. 删除动画 `2000ms × frame_count` 单 attempt timeout 增量;保留现有所有帧 collect / drain。 8. 扫描 queue、inline、External v1 的角色、图标、UI、动画和手动 complex 路径,确认没有 direct BgFilter HTTP 旁路。 @@ -446,7 +446,8 @@ BgFilter 成功二进制不是一份新的业务资产: - 第一次失败后预算不足时不开始第二次;父侧从不重试整次内部 RPC。 - 父业务预算仍有效时,flat 两次失败、熔断、overload、内部 RPC deadline 或断连仍走“阿里云 → 本地”;complex 任意失败直接失败且不读写熔断。 - flat 熔断按真实失败 attempt 计数;由剩余业务预算截短的 timeout 不计入。已获准调用可完成第二次,后续排队请求快速 `circuit_open`。 -- `cancelled`、父 cancellation / 绝对 deadline、`invalid_request` 和 `unauthorized` 不启动 flat fallback;其它 flat 错误只在父业务预算仍有效时进入 fallback。 +- `cancelled`(仅验证父侧映射,保留码首版不产生)、父 cancellation / 绝对 deadline、`invalid_request` 和 `unauthorized` 不启动 flat fallback;其它 flat 错误只在父业务预算仍有效时进入 fallback。 +- 已发出的 provider attempt 失败(含预算截短 timeout 与 response 阶段超时)全部落 `external_api_call_failure`;未发出与纯内部失败不落。审计任务由 shutdown tracker 排空后进程才退出。 - 客户端断连时,等待 permit 的请求最终由 deadline 收口;已开始 provider attempt 持有 permit 并排空。明确 cancellation 已被观察到后不再开始第二次,单纯 TCP 断连只作 best-effort 测试,不作为硬保证。 - 动画全部已提交帧继续 collect / drain,根因按现有稳定帧序号收口;首版不存在未实现的 group cancellation 承诺。 - 成功 body 为原始图片字节而非 Base64、JSON 或结果 object key;父侧 client 把该有界字节缓冲直接交给现有后处理,子 worker 不执行 raw OSS PUT。父、子两侧都拒绝空 body、MIME / 魔数不一致、chunked 超 `32 MiB` 和超过 `8192 × 8192` 的图片。 @@ -473,15 +474,15 @@ npm run bgfilter-worker:load-smoke npm run bgfilter-worker:fault-smoke ``` -真实 OSS + BgFilter 契约冒烟不属于默认门禁,会访问真实服务并产生调用成本。执行前必须在当前进程环境中以不回显方式注入与 loopback worker 相同的一次性 `GENARRATIVE_BGFILTER_INTERNAL_TOKEN`,不得把 token 写进命令行、仓库 env 文件或日志;OSS / BgFilter 配置只放本地私密环境。分别设置 `GENARRATIVE_BGFILTER_SMOKE_MODE=flat` 与 `complex` 后运行: +真实 OSS + BgFilter 契约冒烟不属于默认门禁,会访问真实服务并产生调用成本。执行前必须在当前进程环境中以不回显方式注入与 loopback worker 相同的一次性 `GENARRATIVE_BGFILTER_INTERNAL_TOKEN`,不得把 token 写进命令行、仓库 env 文件或日志;OSS / BgFilter 配置只放本地私密环境。分别设置 `GENARRATIVE_BGFILTER_SMOKE_MODE=flat` 与 `complex` 后,标准 `8083` worker 使用以下命令: ```bash -cargo run -p platform-oss --example bgfilter_worker_live_smoke --manifest-path server-rs/Cargo.toml +cargo run -p platform-oss --example bgfilter_worker_live_smoke --manifest-path server-rs/Cargo.toml -- --worker-url http://127.0.0.1:8083 ``` -该 harness 固定使用 `generated-character-drafts/bgfilter-smoke//source.png`:PUT 前必须先确认 HEAD=404,随后验证私有上传、无鉴权 401 精确错误契约、真实 `200 image/png`,最后要求 DELETE 2xx 且 HEAD=404。正常失败也会尝试清理;若进程崩溃或被强杀,必须按输出的 object key 人工复核。它验证真实 OSS / BgFilter 边界,不替代完整父流程验收;父 flat 的 fallback 本地证据仍由真实阿里云 `segment_smoke` 与父路由单测组合提供,`mock worker 失败 → 父 flat → 真实阿里云`、完整动画写回及计费 / lease / 退款链留在 staging 验收。 +示例程序省略 `--worker-url` 时默认访问 `http://127.0.0.1:18083`,只适用于把 worker 显式启动在该隔离端口的场景,不是标准 dev / 生产端口。该 harness 固定使用 `generated-character-drafts/bgfilter-smoke//source.png`:PUT 前必须先确认 HEAD=404,随后验证私有上传、无鉴权 401 精确错误契约、真实 `200 image/png`,最后要求 DELETE 2xx 且 HEAD=404。正常失败也会尝试清理;若进程崩溃或被强杀,必须按输出的 object key 人工复核。它验证真实 OSS / BgFilter 边界,不替代完整父流程验收;父 flat 的 fallback 本地证据仍由真实阿里云 `segment_smoke` 与父路由单测组合提供,`mock worker 失败 → 父 flat → 真实阿里云`、完整动画写回及计费 / lease / 退款链留在 staging 验收。 -实现后按范围运行: +当前实现按范围运行: ```bash cargo test -p api-server bgfilter --manifest-path server-rs/Cargo.toml @@ -494,11 +495,11 @@ git diff --check 不需要运行 `npm run spacetime:generate` 或 `npm run check:spacetime-schema`,因为本计划明确不修改 SpacetimeDB。 -## 12. 实施前需冻结的参数 +## 12. 生产启用前需冻结的参数 架构边界已固定:首版单实例、无 QPS、无数据库任务表、无 checkpoint / continuation、输出直接走内部二进制响应。 -编码前只需冻结两个容量值: +代码与部署接线已经完成;生产压测后、正式启用前需冻结两个容量值: - 生产 `GENARRATIVE_BGFILTER_WORKER_CONCURRENCY = N`。 - 生产 `GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS = Q`。若要求一个满帧动画不因自身 admission 被拒绝,`Q` 至少为 `48`;候选 `128` 可容纳两个满帧动画并留少量余量,但明确不能覆盖 controller 理论最大 `768` 次同时提交,超出部分会按 mode fallback 或失败。最终值以可接受的 overload 行为和内存压测为准。 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index a7fb40fcb..15ae95e99 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -2,7 +2,7 @@ > 2026-07-18 退役覆盖:旧创作模板 job 类型、玩法写回和玩法恢复链路均已退出现役 worker。当前 worker 只领取 `source_module = editor-canvas` 的任务;本文涉及拼图、跳一跳、拼消消、敲木鱼等玩法的内容仅作为历史设计记录,历史队列行不得被领取或改写。 -> 2026-07-21 待实施专题:BgFilter 作为受限内部资源,将成为“单用户动作一个外部生成 job”的受控内部例外。用户可见层仍只有父 `external_generation_job`;父 future 保持原 lease 和 attempt,在当前调用栈内同步请求唯一 `bgfilter-worker` 的内部 HTTP,成功图片字节直接返回父流程。首版不新增 SpacetimeDB 子任务表、父 checkpoint / continuation 或 raw 中间结果 OSS。完整边界见 [`BgFilter 受限资源调度方案(同步内部 HTTP 原地等待版)`](./【后端架构】BgFilter受限资源调度方案-2026-07-21.md)。 +> 2026-07-21 已实施、待生产压测专题:BgFilter 作为受限内部资源,仍遵守“单用户动作一个外部生成 job”;用户可见层与调度层都只有父 `external_generation_job`。父 future 保持原 lease 和 attempt,在当前调用栈内同步请求唯一 `bgfilter-worker` 的内部 HTTP,成功图片字节直接返回父流程。首版不新增 SpacetimeDB 子任务表、父 checkpoint / continuation 或 raw 中间结果 OSS。完整边界见 [`BgFilter 受限资源调度方案(同步内部 HTTP 原地等待版)`](./【后端架构】BgFilter受限资源调度方案-2026-07-21.md)。 更新时间:`2026-07-21` @@ -194,7 +194,7 @@ controller 配置: - `editor_image_generation`:普通图片、生成规范、角色形象、UI 设计图、宣发素材和图片快速编辑。 - `editor_image_edit`:图片编辑 / 修改结果。 -- `editor_background_removal`:手动去除任意图片背景,worker 使用 BgFilter complex 模式、首次失败后重试 `1` 次,并把执行阶段标记为 `processing`。 +- `editor_background_removal`:手动去除任意图片背景,父 `external-generation-worker` 只发送一次内部 HTTP RPC 并把执行阶段标记为 `processing`;唯一 `bgfilter-worker` 使用 BgFilter complex 模式,对同一次逻辑调用最多执行两次顺序 provider attempt,两次都失败时把类型化错误返回父流程。 - `editor_icon_spritesheet_generation`:图标素材 spritesheet 生成和拆分。 - `editor_ui_design_asset_extraction`:UI 设计图红框素材提取。 - `editor_character_animation_generation`:角色动作视频和帧素材生成。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 7ed4a88c9..ece2e6722 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -244,7 +244,7 @@ npm run check:server-rs-ddd - 抠图输入以私有 OSS 作为内存生命周期边界:生成原图和角色动作抽取帧上传时消费图片字节所有权,上传完成后不保留原图缓冲;手动去背景直接解析并校验已有 OSS object key,不下载原图。BgFilter 必须为 object key 签发 600 秒 GET URL 并通过 multipart `image_url` 提交,不用 `file` 重传;flat 链路进入阿里云 fallback 时由 `platform-matting` URL 接口单独下载并上传 `AuthorizeFileUpload` 临时对象,在推理前释放下载缓冲,继续 fallback 到本地键色时再单独下载一次原图,本地产出后释放本次原图下载缓冲。签名 URL 不得写入日志、审计或持久化。 - 角色动作抠图输入像素边界:仅图片画布角色动作链路在 FFmpeg 抽帧后、源帧上传 OSS 前,把帧解码为 RGB8,并按最终 `frameWidth × frameHeight` 的 contain 比例使用 `Triangle` 只缩放到内容尺寸;该阶段不得创建最终目标尺寸画布、不得引入 Alpha 通道,也不得插入任何 padding。BgFilter、阿里云通用抠图和本地键色降级共享这个无补边源帧 object key。抠图返回后才统一转为 RGBA8,按相同比例居中放入最终目标尺寸画布,并用 `RGBA(0,0,0,0)` 补齐透明 padding。以 `560×752 → 323×480` 为例,抠图输入固定为无 Alpha、无补边的 `323×434 RGB8 PNG`,最终输出为上下各 `23px` 透明补边的 `323×480 RGBA8 PNG`。旧 `/api/assets/character-animation/*` 动作发布链路继续保留原有帧 finalizer,不适用该输入规则。抽帧解码后若携带 Alpha 通道,必须先把像素按白底合成为不透明再转 RGB8,禁止直接丢弃 Alpha——全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入杂色边缘;共享 FFmpeg 抽帧命令保持不固定 `-pix_fmt`,白底合成只属于该链路的 BgFilter 输入准备阶段。 - 阿里云通用抠图的非上海地域输入不得使用 `viapiutils/GetOssStsToken`、固定 `viapi-customer-temp` 或 OSS V1 PUT。`platform-matting` 必须按官方新版 SDK Advance 协议调用 `AuthorizeFileUpload`,使用动态返回的单对象 Policy 执行 multipart POST,再把临时上海 OSS URL 交给 `SegmentCommonImage`;输入归一化、结果下载与原尺寸 Alpha 回贴继续留在同一适配器内。该协议仍上传图片字节,不等同于阿里云服务端直接抓取任意公网 URL,也不改变上层 BgFilter → 阿里云 → 本地降级顺序。 -- 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 与角色形象生成、图标 spritesheet 生成、UI 设计图素材提取、角色动作抽帧后的透明化统一通过唯一 loopback `bgfilter-worker` 调用 BgFilter provider。provider 配置继续使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN` 和 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`,默认 base URL 为 `http://58.87.105.82/bgfilter`,默认单次 provider attempt 上限为 `180000ms`;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 provider token 的兼容回退别名,原手动去背景专用 base URL / timeout 配置已经删除。父流程先把候选 `objectKey`、`resourceId` 或 `assetId` 解析为当前 owner 已登记的私有 OSS object key;BFF 入队前统一拒绝 `data:` / `blob:`,底层 resolver 在解析引用前再次拒绝内联媒体并完成登记状态与 owner 校验。父流程只通过一次内部 HTTP RPC 传递 object key、剩余预算与模式参数,不传图片字节或签名 URL,并同步等待子 worker 返回的受限图片二进制 body。子 worker 在每次真实 provider attempt 前签发短期 OSS URL,承担 admission `Q`、provider 并发 `N`、严格最多两次顺序 attempt、结果校验和 flat 进程级熔断;熔断继续使用 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 和 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 默认值,但只由子 worker 读写。手动去背景固定使用 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传 `file` 或 `screen_color`;complex 任意失败直接返回父流程失败,不接 flat fallback,也不读写 flat 熔断。标准纯色背景四条链路固定使用 `background_mode=flat`、`screen_color=`、`seg_model=` 和 `cross_check=`,其中角色形象生成和角色动作逐帧去背传 `cross_check=on`,图标 spritesheet 生成和 UI 设计图素材提取传 `cross_check=off`。前端用户路径不展示抠图模型、模式或 cross-check,固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`;这些参数只属于后端内部供应商策略,不进入前端或外部 OpenAPI。父侧不重试整次内部 RPC;flat 两次 provider attempt 失败、熔断、overload、内部 deadline 或断连后,只要父业务预算仍有效,父流程才继续“阿里云通用抠图 → 本地 `editor_green_screen` 键色扣除”,熔断期不得直接退化到本地兜底。角色动作视频生成的背景色已与生图链路统一:`screenColor=auto` 时由视觉 LLM(`gpt-5-mini`,Responses 协议、low 推理档)读源角色图自动决策,并经硬过滤器剔除与前景 / 皮肤撞色的候选,手动 hex 则尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色;抽帧后每帧先上传私有 OSS 并释放原帧缓冲,再以 object key 固定使用 `seg_model=birefnet`、`cross_check=on` 进入上述三段式链路。阿里云通用抠图配置为 `GENARRATIVE_ALIYUN_MATTING_ENABLED`、`GENARRATIVE_ALIYUN_MATTING_ENDPOINT`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET` 和 `GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS`;未配置专用 AK/SK 时可复用 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`,默认 endpoint 为 `imageseg.cn-shanghai.aliyuncs.com`。标准纯色背景链路中,子 worker 已发出的 BgFilter provider 失败和父侧阿里云抠图链路已开始后的失败(包括源 OSS GET 成功后的解码、尺寸校验和归一化失败)都写入 `external_api_call_failure` 审计;真正开始外部调用前的本地预检不写该审计,并在 `failureStage` 中保留 `source_decode`、`source_validate` 等阶段。成功图片字节返回后,最终 Alpha / 尺寸恢复、OSS / asset object、画布写回、计费和父任务终态仍全部由父流程负责。 +- 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 与角色形象生成、图标 spritesheet 生成、UI 设计图素材提取、角色动作抽帧后的透明化统一通过唯一 loopback `bgfilter-worker` 调用 BgFilter provider。provider 配置继续使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN` 和 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`,默认 base URL 为 `http://58.87.105.82/bgfilter`,默认单次 provider attempt 上限为 `180000ms`;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 provider token 的兼容回退别名,原手动去背景专用 base URL / timeout 配置已经删除。父流程先把候选 `objectKey`、`resourceId` 或 `assetId` 解析为当前 owner 已登记的私有 OSS object key;BFF 入队前统一拒绝 `data:` / `blob:`,底层 resolver 在解析引用前再次拒绝内联媒体并完成登记状态与 owner 校验。父流程只通过一次内部 HTTP RPC 传递 object key、剩余预算与模式参数,不传图片字节或签名 URL,并同步等待子 worker 返回的受限图片二进制 body。子 worker 在每次真实 provider attempt 前签发短期 OSS URL,承担 admission `Q`、provider 并发 `N`、严格最多两次顺序 attempt、结果校验和 flat 进程级熔断;熔断继续使用 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 和 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 默认值,但只由子 worker 读写。手动去背景固定使用 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传 `file` 或 `screen_color`;complex 任意失败直接返回父流程失败,不接 flat fallback,也不读写 flat 熔断。标准纯色背景四条链路固定使用 `background_mode=flat`、`screen_color=`、`seg_model=` 和 `cross_check=`,其中角色形象生成和角色动作逐帧去背传 `cross_check=on`,图标 spritesheet 生成和 UI 设计图素材提取传 `cross_check=off`。前端用户路径不展示抠图模型、模式或 cross-check,固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`;这些参数只属于后端内部供应商策略,不进入前端或外部 OpenAPI。父侧不重试整次内部 RPC;flat 两次 provider attempt 失败、熔断、overload、内部 deadline 或断连后,只要父业务预算仍有效,父流程才继续“阿里云通用抠图 → 本地 `editor_green_screen` 键色扣除”,熔断期不得直接退化到本地兜底。角色动作视频生成的背景色已与生图链路统一:`screenColor=auto` 时由视觉 LLM(`gpt-5-mini`,Responses 协议、low 推理档)读源角色图自动决策,并经硬过滤器剔除与前景 / 皮肤撞色的候选,手动 hex 则尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色;抽帧后每帧先上传私有 OSS 并释放原帧缓冲,再以 object key 固定使用 `seg_model=birefnet`、`cross_check=on` 进入上述三段式链路。阿里云通用抠图配置为 `GENARRATIVE_ALIYUN_MATTING_ENABLED`、`GENARRATIVE_ALIYUN_MATTING_ENDPOINT`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET` 和 `GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS`;未配置专用 AK/SK 时可复用 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`,默认 endpoint 为 `imageseg.cn-shanghai.aliyuncs.com`。标准纯色背景链路中,子 worker 已发出的 BgFilter provider 失败(含被剩余预算截短后发生的 timeout 与 response 阶段超时,这类失败不计入 flat 熔断但必须落审计)和父侧阿里云抠图链路已开始后的失败(包括源 OSS GET 成功后的解码、尺寸校验和归一化失败)都写入 `external_api_call_failure` 审计;真正开始外部调用前的本地预检不写该审计,并在 `failureStage` 中保留 `source_decode`、`source_validate` 等阶段。成功图片字节返回后,最终 Alpha / 尺寸恢复、OSS / asset object、画布写回、计费和父任务终态仍全部由父流程负责。 - BgFilter 连接复用、超时与动作帧流水线:`AppState` 分别复用父侧内部 worker HTTP Client 和子 worker 专用 BgFilter provider HTTP Client;父侧对一次逻辑调用只发送一次内部 RPC,不自动重试,子 worker 在同一个 `N` permit 内严格最多执行两次顺序 provider attempt。唯一子 worker 使用 `GENARRATIVE_BGFILTER_WORKER_CONCURRENCY=N` 限制真实 provider 在途数,并用 `GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS=Q` 限制 queued + running + egress admission。`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 默认 `180000ms`,只限制单次真实 provider attempt;角色动作不再增加 `2000ms × 本次实际帧数`,`32 / 40 / 48` 帧都使用相同的 `180000ms` 单 attempt 上限。父侧继续管理整项 worker long-job / request 绝对预算,并为每个内部 RPC 派生覆盖 admission、等待 `N`、最多两次 provider attempt、结果校验和响应传输的剩余预算;排队计入该预算,每次 attempt 前重新签发短期 OSS URL,剩余时间不足时不开始新的 attempt。角色动作继续以 `buffer_unordered(frame_count.max(1))` 将全部单帧逻辑调用加入无序在途集合;返回结果携带原始帧序并在最终 collect / drain 全部已提交 Future 后排序,任一帧最终失败时必须先排空全部已启动 Future,再让整个动作任务失败退款,不能发布缺帧动画。单帧按“绿幕源图 owned 上传 OSS 并释放原帧 → 以 object key 调内部 worker / 按 object key 由父侧降级 → 父侧处理透明帧并落 OSS”流水化。角色动画源帧 PUT、透明帧 PUT 和最终帧 HEAD 仍统一复用 `AppState` 内初始化一次的 OSS HTTP Client(连接池参数为 connect 30 秒、request 60 秒、idle 300 秒、每 host 8 个 idle 连接、TCP keepalive 60 秒),并受进程级 8 路 OSS semaphore 限制;BgFilter provider 的 `N` 不占该 OSS permit,阿里云和本地处理既不占 OSS permit,也不受 `N / Q` 限制。每个 OSS 网络 attempt 单独获取 permit,退避期间释放;PUT/HEAD 动画帧请求最多 3 次(250ms、500ms 退避),只重试无 HTTP 响应的传输错误、timeout、OSS PutObject 的 `400 + RequestTimeout`、PUT `400` 错误体读取失败(未解析出 `Code`,按 timeout/transport 归类)、408、429 和 5xx。动作帧 PUT 只在 400 响应中有界读取最多 16 KiB OSS 错误 XML,并保留 `Code` 与响应头优先的 `x-oss-request-id`;错误体读取超时/断流时保留已读字节,已解析出的 `Code` 优先生效,未解析出 `Code` 则按 timeout/transport 归类重试;除 `RequestTimeout` 与该错误体读取失败情形外的其他 400、401/403/404、配置、URL/签名和空请求体错误不重试。最终帧 HEAD 失败只重试 HEAD,不重复 PUT。 - 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。 @@ -253,7 +253,7 @@ npm run check:server-rs-ddd - Hyper3D / Rodin:只保留后端安全代理和旧数据兼容;Rodin 提交、状态、下载和响应解析归属 `platform-hyper3d`,`api-server/src/hyper3d_generation.rs` 只做路由、配置和错误 envelope 映射;新 Match3D 草稿和批量新增不再生成 GLB。 - 音频:视觉小说专用音频路由保留;VectorEngine Suno/Vidu provider 协议、任务提交/查询、音频 URL 提取、下载、MIME/extension 归一和 OSS put 请求准备归属 `platform-audio`。`api-server/src/vector_engine_audio_generation.rs` 只做路由、配置、计费、asset object confirm、entity binding 和错误 envelope 映射;拼图、抓大鹅和敲木鱼提示词生成音效入口暂时关闭,通用 `/api/creation/audio/*` 对这些目标返回 `410 Gone`。敲木鱼创作只接收上传 / 录音音频资产;前端选择或录音阶段只在浏览器本地处理待提交音频,统一限制裁切后最长 1 秒、裁掉前后声音过小片段,并用浏览器端近似响度算法平衡到 `-15 LKFS` 后做峰值保护。点击生成时才直传 OSS 并确认 `asset_object`,创作 JSON 只提交轻量 `WoodenFishAudioAsset`,不得继续上传 Data URL 音频;未提供时由 `api-server` 写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。 - OSS:私有 generated path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`。请求参数的安全语义不能混用:`legacyPublicPath` 是历史公开作品兼容口,只允许 `platform_oss::LEGACY_PUBLIC_PREFIXES` 中的 curated 前缀匿名换签;`objectKey` 是正式对象引用,绝不能复用该前缀旁路,必须查询 `asset_object` 并校验配置 bucket、精确 key、`PublicRead` 或当前 owner。External OpenAPI 的 `/api/external/v1/assets/read-url` 还必须有 `editor:asset` scope,并始终以 API Key 绑定的 `owner_user_id` 执行同一 owner 校验;后台跨账号预览只能走管理员鉴权后的 `/admin/api/assets/read-url`。`/api/assets/read-bytes` 与主站 read-url 共用完全相同的授权,默认仍应由浏览器使用 signed URL 直读,bytes 只作跨域字节读取 fallback。前端如果收到同一 OSS bucket 的完整 `https://*.oss-*.aliyuncs.com/generated-*` 地址,也必须先归一为 legacy path 后走同一换签链路,避免裸连私有 bucket 403 或绕过签名缓存。OSS 签名、读签名、HEAD 和 PUT 的结构化日志由 `platform-oss` 输出,排查资产写入 / 确认失败时优先按 `operation`、`object_key` / `key_prefix`、`status_class`、`error_kind` 和 `elapsed_ms` 下钻。新上传 generated 私有对象默认写入 `Cache-Control: public, max-age=31536000, immutable`;旧对象若缺该头,只能依赖 `ETag` / `Last-Modified` 协商缓存,应通过 OSS 元数据刷新或 CDN 配置补齐,不要恢复 api-server 静态代理。`editor-agent/` 前缀只用于服务端内部读写画布 Agent 会话消息文档,不属于浏览器直传 legacy public prefix;`/api/assets/direct-upload-tickets` 必须拒绝 `legacyPrefix=editor-agent`,内部读取只允许 `editor-agent/{conversationId}.json` 形态。 -- 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。VectorEngine 图片 provider 在 `platform-image` 内输出结构化日志和 `PlatformImageFailureAudit`,覆盖 `request_send`、`response_body`、`upstream_status`、`response_parse`、`missing_image` 和 `image_download` 阶段;编辑器 `screenColor=auto` 的 gpt-5-mini 背景色决策同样必须审计每次已发出的 LLM 调用失败,包括传输 / 超时、上游拒绝、响应体解析、空响应和返回候选外颜色;即使随后降级默认背景色并继续主流程也不得只记 warning。`api-server` 将这些失败映射成 `external_api_call_failure`,`scope_kind = module`、`scope_id = provider`、`module_key = external-api`。metadata 固定包含 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt,以及在调用方可获得上下文时补充的 `userId`(触发者)和 `profileId`(草稿 / 作品 / 场景作用域)。图片生成入口应优先把 owner user id 和 profile id 透传到失败审计,不要只保留 provider 级聚合,否则很难按“谁触发、哪个作品触发”定位问题。入库优先复用 tracking outbox,outbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB;不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。 +- 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。VectorEngine 图片 provider 在 `platform-image` 内输出结构化日志和 `PlatformImageFailureAudit`,覆盖 `request_send`、`response_body`、`upstream_status`、`response_parse`、`missing_image` 和 `image_download` 阶段;编辑器 `screenColor=auto` 的 gpt-5-mini 背景色决策同样必须审计每次已发出的 LLM 调用失败,包括传输 / 超时、上游拒绝、响应体解析、空响应和返回候选外颜色;即使随后降级默认背景色并继续主流程也不得只记 warning。`api-server` 将这些失败映射成 `external_api_call_failure`,`scope_kind = module`、`scope_id = provider`、`module_key = external-api`。metadata 固定包含 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt,以及在调用方可获得上下文时补充的 `userId`(触发者)和 `profileId`(草稿 / 作品 / 场景作用域)。图片生成入口应优先把 owner user id 和 profile id 透传到失败审计,不要只保留 provider 级聚合,否则很难按“谁触发、哪个作品触发”定位问题。入库优先复用 tracking outbox,outbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB;不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。唯一例外是 `bgfilter-worker` 进程角色:它不共享 api / extgen 的落盘 outbox 路径(避免多进程并发操作同一目录),provider 失败审计由 shutdown tracker 跟踪的异步任务直写 SpacetimeDB,优雅退出前排空,进程被强杀时可能丢失。 - 外部生成运行记录:所有外部生成编排的完成态统一写入 `tracking_event`,`event_key = external_generation_run`,`scope_kind = module`,`scope_id = provider`,`module_key = external-generation`。metadata 固定包含 `runId`、`provider`、`operation`、`requestLabel`、`requestPayload`、`status`、`success`、`failureReason`、`providerRequestId`、`resultPayload`、`startedAtMicros`、`completedAtMicros` 和 `durationMs`。这类记录只用于运行审计和排障,不再走 `ai_task` 旧表。 ## SpacetimeDB 表目录 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 347a96980..0ab180baa 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -1,6 +1,6 @@ # 本地开发验证与生产运维 -更新时间:`2026-07-21` +更新时间:`2026-07-22` ## 标准开发流程 @@ -62,7 +62,7 @@ Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter- Windows 本地如果已在 `%LOCALAPPDATA%\Genarrative\ffmpeg\bin` 安装 FFmpeg,`npm run dev` / `npm run dev:api-server` 会自动把该目录加入本次 `api-server` 子进程 `Path`,并注入 `CHARACTER_ANIMATION_FFMPEG_PATH` / `CHARACTER_ANIMATION_FFPROBE_PATH` 的绝对路径。这样即使外层终端或长期运行的 dev 进程是在安装 FFmpeg 之前启动,角色动画抽帧也不会继续因为 `ffmpeg: program not found` 失败;若手动配置了上述环境变量或 `GENARRATIVE_CHARACTER_ANIMATION_*` 前缀变量,显式配置优先。 -开发态 `npm run dev` 与 `npm run dev:api-server` 会默认注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true` 和 `GENARRATIVE_PROCESS_ROLE=all`,因此密码登录在本地开发环境可直接注册未知手机号账号,且本地 `api-server` 会同时监听 HTTP 并消费外部生成队列;显式设置 `GENARRATIVE_PROCESS_ROLE` 时保留显式值。`all` 不内嵌 BgFilter worker;启动器总是先启动并验活独立 `GENARRATIVE_PROCESS_ROLE=bgfilter-worker` 进程,再启动父 API,并向两者注入同一个内部 base URL / Token。Linux 本地默认 `all` 角色启动前,dev 脚本会停止当前仓库、同一个 SpacetimeDB server / database 下遗留的 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程,避免旧 worker 二进制继续抢同一条队列并在业务写回时制造 procedure 超时;显式拆分 `api` / `external-generation-worker` 做生产式验证时不会触发这项清理。生产环境仍按 `api-server` 配置默认关闭密码自动注册,并由独立 worker 进程消费队列。 +开发态 `npm run dev` 与 `npm run dev:api-server` 都会注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,因此密码登录在本地开发环境可直接注册未知手机号账号。完整 `npm run dev` 会强制父 API 使用 `GENARRATIVE_PROCESS_ROLE=all`,忽略外层显式角色,确保本地 `api-server` 同时监听 HTTP 并消费外部生成队列;只有单模块 `npm run dev:api-server` 会保留显式 `GENARRATIVE_PROCESS_ROLE`,未设置时默认为 `all`。`all` 不内嵌 BgFilter worker;启动器总是先启动并验活独立 `GENARRATIVE_PROCESS_ROLE=bgfilter-worker` 进程,再启动父 API,并向两者注入同一个内部 base URL / Token。Linux 本地默认 `all` 角色启动前,dev 脚本会停止当前仓库、同一个 SpacetimeDB server / database 下遗留的 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程,避免旧 worker 二进制继续抢同一条队列并在业务写回时制造 procedure 超时;显式拆分 `api` / `external-generation-worker` 做生产式验证时不会触发这项清理。生产环境仍按 `api-server` 配置默认关闭密码自动注册,并由独立 worker 进程消费队列。 本地排查外部内容生成 worker 队列时,默认同一 Rust 进程同时监听 HTTP 并消费 `external_generation_job` 队列;更接近生产的验证应分别启动 `api`、`external-generation-worker` 和 `external-generation-controller`。生产默认 `GENARRATIVE_PROCESS_ROLE=api`,外部生成任务由独立 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程消费;生产与容器扩缩容验证保持 `queue`。当前 worker 只领取 `source_module = editor-canvas` 的图片画布任务,包括 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation`。旧玩法历史任务即使仍为 pending / running 也不领取、不改状态;显式把本地进程角色设为 `api` 且没有 worker 时,现役编辑器生成请求只返回 queued/running,不会兜底执行外部 provider。 @@ -617,7 +617,7 @@ worker 被硬杀或断电后,lease 过期任务只有尚未耗尽 `max_attempt - Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`,upstream keepalive 为 64;通用 API 使用 `genarrative_api_rps`,后台 API 使用 `genarrative_admin_rps`。通用 `/api` location 保留 `client_max_body_size 64m` 作为编辑器图片、视频和文档请求的反代兜底,真实大小仍由路由与业务校验负责。若线上出现 `413 Request Entity Too Large` 且 access log 中 `request_time=0.000`、`upstream_status=-`,说明请求在 Nginx 层被拦截,先核对 release 模板与实际媒体大小。`limit_conn_status 429` 和 `limit_req_status 429` 必须在 HTTP 与 HTTPS server 中同时生效。 - 旧作品列表 K6 脚本、gallery 专属限流分组和对应容量结论已经退役;源码只作历史记录,不得作为当前发布门禁。新的容量验收必须针对现役编辑器、项目和素材 API 单独建立数据、负载与指标口径。 -容器化隔离部署方案单独放在 `deploy/container/`,用于本机或预发模拟 Linux release + Nginx + OTLP Collector 拓扑,不替换当前生产 `systemd + Nginx + Jenkins` 发布路径。当前容器模拟参数按 `genarrative-release` 采样值收口为 2 vCPU / 2 GiB RAM / `nofile=4096` / `worker_connections=768`,并在 compose 里落实到 `spacetimedb cpus=1.0 mem_limit=896m`、`api-server cpus=2.0 mem_limit=1g`、`external-generation-worker cpus=2.0 mem_limit=1g`、`nginx cpus=0.5 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`。容器 `api-server` 默认 `GENARRATIVE_API_WORKER_THREADS=4`,只增加 Tokio worker 调度并发,不突破 `api-server cpus=2.0` 的 CPU 配额;容器默认 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,可用 `npm run container:up -- --scale external-generation-worker=N external-generation-worker` 验证现役外部生成 worker 动态扩缩容,`inline` 模式不参与该验证: +容器化隔离部署方案单独放在 `deploy/container/`,用于本机或预发模拟现有的 Linux release + Nginx + OTLP Collector 非 BgFilter 拓扑,不替换当前生产 `systemd + Nginx + Jenkins` 发布路径。当前 compose 没有 `bgfilter-worker`,不构成完整 BgFilter 预发拓扑,也不覆盖任何会触发 BgFilter 的现役任务;它只用于非 BgFilter 路径,或通过下述 unsupported job smoke 验证外部生成队列的 claim / fail 回写和 API-only 更新。当前容器模拟参数按 `genarrative-release` 采样值收口为 2 vCPU / 2 GiB RAM / `nofile=4096` / `worker_connections=768`,并在 compose 里落实到 `spacetimedb cpus=1.0 mem_limit=896m`、`api-server cpus=2.0 mem_limit=1g`、`external-generation-worker cpus=2.0 mem_limit=1g`、`nginx cpus=0.5 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`。容器 `api-server` 默认 `GENARRATIVE_API_WORKER_THREADS=4`,只增加 Tokio worker 调度并发,不突破 `api-server cpus=2.0` 的 CPU 配额;容器默认 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,可用 `npm run container:up -- --scale external-generation-worker=N external-generation-worker` 验证不经过 BgFilter 的外部生成 worker 动态扩缩容,`inline` 模式不参与该验证: ```bash npm run container:init @@ -627,21 +627,27 @@ npm run container:up npm run container:down ``` -容器方案默认暴露 `http://127.0.0.1:18080`,`api-server` 在容器内监听 `0.0.0.0:8082`,Nginx 通过 `api-server:8082` upstream 反代 `/api/` 和 `/admin/api/`。SpacetimeDB 也纳入 compose,容器内由 `spacetimedb:3101` 提供服务,宿主机通过 `http://127.0.0.1:13101` 进行模块发布;Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。生产 provision 侧现在由目标 dev / release agent 自己准备 `provision-tools/otelcol-contrib`,并安装本机 `otelcol-contrib.service`,真实库名、token 和外部服务密钥只写本地 `deploy/container/api-server.env`,不提交 Git。旧 gallery K6 profile 已退役;完整现役拓扑、端口和 OTLP debug exporter 使用方法见 `deploy/container/README.md`。 +容器方案默认暴露 `http://127.0.0.1:18080`,`api-server` 在容器内监听 `0.0.0.0:8082`,Nginx 通过 `api-server:8082` upstream 反代 `/api/` 和 `/admin/api/`。SpacetimeDB 也纳入 compose,容器内由 `spacetimedb:3101` 提供服务,宿主机通过 `http://127.0.0.1:13101` 进行模块发布;Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。生产 provision 侧现在由目标 dev / release agent 自己准备 `provision-tools/otelcol-contrib`,并安装本机 `otelcol-contrib.service`,真实库名、token 和外部服务密钥只写本地 `deploy/container/api-server.env`,不提交 Git。旧 gallery K6 profile 已退役;当前容器拓扑(明确不含 BgFilter worker)、端口和 OTLP debug exporter 使用方法见 `deploy/container/README.md`。 `npm run container:config` 默认只做 quiet 校验,避免把本地 env 中的 token 展开到终端;确需排查完整 compose 时再传 `-- --print`。 -隔离验证 worker 队列和 API-only 更新时使用 `npm run container:worker-smoke -- smoke`。该命令不复用 `deploy/container/api-server.env`,会在 `deploy/container/worker-smoke/` 生成本机专用 env 与端口 state,并使用 unsupported job 验证 worker claim / fail 回写,不需要真实外部生成密钥;本机 crates.io 网络不稳时使用 `--local-binary`,由容器内 Cargo 复用本机 Cargo 缓存构建,并把产物放进 Debian bookworm smoke runtime。 +隔离验证 worker 队列和 API-only 更新时使用 `npm run container:worker-smoke -- smoke`。该命令不复用 `deploy/container/api-server.env`,会在 `deploy/container/worker-smoke/` 生成本机专用 env 与端口 state,并且只使用 unsupported job 验证 worker claim / fail 回写,不覆盖 BgFilter 成功、失败或 fallback 链路,也不需要真实外部生成密钥;本机 crates.io 网络不稳时使用 `--local-binary`,由容器内 Cargo 复用本机 Cargo 缓存构建,并把产物放进 Debian bookworm smoke runtime。 独立 BgFilter worker 的本机全进程验证先运行 `cargo build -p api-server --manifest-path server-rs/Cargo.toml`,再依次运行 `npm run bgfilter-worker:smoke-test`、`npm run bgfilter-worker:load-smoke` 和 `npm run bgfilter-worker:fault-smoke`。三条命令只使用动态 loopback 端口、假 OSS 签名配置和本地 mock provider;不会读取仓库 `.env*` 或请求真实 BgFilter / OSS。自定义或 WSL binary 通过 `GENARRATIVE_BGFILTER_SMOKE_BINARY` 指定。当前 fault 范围包含 overload、queue deadline、两类 HTTP 状态顺序重试结果,以及 provider 成功响应 body 中途 reset 后第二次 attempt 串行成功;慢读、大响应、父侧客户端断连与 SIGTERM 排空另行验证。 -需要复核真实 OSS + BgFilter 契约时,先启动只监听 loopback 的 worker,并在 worker 与 smoke 的当前进程环境中以不回显方式注入同一个一次性 `GENARRATIVE_BGFILTER_INTERNAL_TOKEN`;token 不得写入命令参数、仓库 env 文件或日志。OSS / BgFilter 凭据继续只放本地私密环境。分别设置 `GENARRATIVE_BGFILTER_SMOKE_MODE=flat` 和 `complex`,运行 `cargo run -p platform-oss --example bgfilter_worker_live_smoke --manifest-path server-rs/Cargo.toml`。该命令会访问真实服务并产生调用成本;成功标准是 PUT 前 HEAD=404、私有上传成功、无鉴权请求返回精确 401 JSON、带鉴权请求返回 `200 image/png`、DELETE 2xx 且最终 HEAD=404。对象只写入 `generated-character-drafts/bgfilter-smoke//source.png`;正常失败会继续清理,进程崩溃或被强杀时需按输出 object key 人工复核。此 smoke 不经过用户 job、计费或父 flat fallback;完整 `mock worker 失败 → 父 flat → 真实阿里云` 和动画写回链在 staging 验收。 +需要复核真实 OSS + BgFilter 契约时,先启动只监听 loopback 的 worker,并在 worker 与 smoke 的当前进程环境中以不回显方式注入同一个一次性 `GENARRATIVE_BGFILTER_INTERNAL_TOKEN`;token 不得写入命令参数、仓库 env 文件或日志。OSS / BgFilter 凭据继续只放本地私密环境。使用标准 worker 端口 `8083` 时,确认 `http://127.0.0.1:8083/readyz` 成功,再分别设置 `GENARRATIVE_BGFILTER_SMOKE_MODE=flat` 和 `complex`,并显式传入 worker 地址: + +```bash +cargo run -p platform-oss --example bgfilter_worker_live_smoke --manifest-path server-rs/Cargo.toml -- --worker-url http://127.0.0.1:8083 +``` + +示例程序省略 `--worker-url` 时默认访问 `http://127.0.0.1:18083`;该默认值只适合把 worker 显式启动在自定义隔离端口的场景,不是标准 dev / 生产端口。该命令会访问真实服务并产生调用成本;成功标准是 PUT 前 HEAD=404、私有上传成功、无鉴权请求返回精确 401 JSON、带鉴权请求返回 `200 image/png`、DELETE 2xx 且最终 HEAD=404。对象只写入 `generated-character-drafts/bgfilter-smoke//source.png`;正常失败会继续清理,进程崩溃或被强杀时需按输出 object key 人工复核。此 smoke 不经过用户 job、计费或父 flat fallback;完整 `mock worker 失败 → 父 flat → 真实阿里云` 和动画写回链在 staging 验收。 OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日志与 Nginx 文件日志仍保留: - 生产与容器 `api-server` env 模板默认 `GENARRATIVE_OTEL_ENABLED=true`;压测、排障或短期要关闭 OTLP 时,必须显式设置 `GENARRATIVE_OTEL_ENABLED=false`。 - Collector 使用官方 `otelcol-contrib`,安装与启用仍由 `ENABLE_OTELCOL` / provision 控制,只监听 `127.0.0.1:4317/4318`;本地用 `npm run otel:debug` 启动 debug exporter,用 `npm run otel:rider` 转发到 Rider,再接 Jaeger、Tempo、Prometheus、Grafana 或托管平台。 -- api-server 发送 OTLP HTTP 时,生产模板使用 `OTEL_SERVICE_NAME=genarrative-api`、`OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318`,容器模板使用 `OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4318`。 +- api-server 发送 OTLP HTTP 时,生产模板使用 `OTEL_SERVICE_NAME=genarrative-api`、`OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318`,容器模板使用 `OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4318`。生产 `genarrative-bgfilter-worker.service` 强制使用独立的 `OTEL_SERVICE_NAME=genarrative-bgfilter-worker`,并从 worker env 读取同一 Collector 的 endpoint,避免父 API 与受限资源 worker 的遥测混在同一 service identity 下。 - `OTEL_EXPORTER_OTLP_ENDPOINT` 必须指向 Collector 的 HTTP base endpoint;不要填 gRPC `4317`,也不要直接填 Rider 端口,Rider 由 Collector 通过 `RIDER_OTLP_GRPC_ENDPOINT` 转发。 -- 应用日志仍通过 `journalctl -u genarrative-api.service` 查看,Nginx 日志仍写文件;日志等级继续用 `GENARRATIVE_API_LOG` / `RUST_LOG` 控制,例如 `info,tower_http=info,spacetime_client=info`。 +- 应用日志按进程查看:父 API 使用 `journalctl -u genarrative-api.service`,独立 BgFilter worker 使用 `journalctl -u genarrative-bgfilter-worker.service`;Nginx 日志仍写文件。日志等级继续用 `GENARRATIVE_API_LOG` / `RUST_LOG` 控制,例如 `info,tower_http=info,spacetime_client=info`。 - debug exporter / Rider 转发都会同时接收 traces、metrics 和 logs。 - api-server 会随 metrics 发送进程级指标:`process.memory.usage`、`process.memory.virtual`、`process.cpu.time`、`genarrative.process.cpu.usage_percent`、`process.thread.count`、`genarrative.process.memory.private`;Windows 额外发送 `process.windows.handle.count`,Linux 额外发送 `process.unix.file_descriptor.count`。这些指标只描述当前进程,不携带请求、用户或作品 label。 - HTTP 运行态补充发送 `genarrative.http.server.response_bodies.in_flight` 与 `genarrative.http.server.request_permits.available`,后者带低基数 `pool=default|gallery|detail|admin` label,用于区分业务 handler / 背压 permit 是否仍被占用;拼图广场热点缓存补充发送 `genarrative.puzzle_gallery.cache.*` 指标,记录 fresh hit、stale hit、未命中、后台刷新开始 / 失败、重建耗时和预序列化 data JSON 字节数。 diff --git a/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md b/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md index 31a91bead..25df18350 100644 --- a/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md +++ b/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md @@ -28,7 +28,7 @@ - 支持自定义画面比例和大小尺寸。 - 模型固定为 `gpt-image-2`,模型展示对齐角色规范面板底部固定模型样式,不响应点击、不弹出模型切换菜单;历史草稿如果残留其他模型,提交时也必须强制改为 `gpt-image-2`。 - 默认画面比例为 `16:9`,默认大小为 `1K`。 -- UI 素材提取面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`。后端先在 12 个候选色中自动决策具体 hex,最多重试 3 次,失败兜底 `#CFEFFF`;后端调用 BgFilter 时只把解析后的具体 hex 作为 `screen_color` 传入。后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。 +- UI 素材提取面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`。后端先在 12 个候选色中自动决策具体 hex,最多重试 3 次,失败兜底 `#CFEFFF`;父流程请求唯一 loopback `bgfilter-worker` 时只把解析后的具体 hex 作为 `screenColor` 参数传入。后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。 ## 提示词契约 @@ -61,7 +61,7 @@ 仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用后端自动决策出的抠图背景色。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。 ``` -- 后端收到 spritesheet 后先把带解析后纯色背景的源图 owned 上传私有 OSS(消费图片字节所有权,上传完成后释放原图缓冲,不克隆保留),写入项目资源和账号素材库;随后只持 object key。每次 BgFilter attempt 重新签发 600 秒 GET URL,multipart 固定传 `image_url`、`screen_color=`、`seg_model=`、`background_mode=flat` 和 `cross_check=off`,不包含 `file`,默认 `segModel=birefnet`。BgFilter 主路径不重新下载原图;首次失败后立即重试 `1` 次(重试同样重新换签),第二次仍失败进入“阿里云通用抠图(按签名 URL 单独下载)→ 本地键色(再按 object key 独立下载一次原图并在产出后释放)”降级链。透明背景处理正常成功时,透明 spritesheet 同样先进入 OSS、项目资源和账号素材库,再复用图标素材的连通域拆分能力;调用方未指定素材文件夹时落默认“项目”文件夹。透明背景处理最终失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建透明图集,也不继续拆分。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。 +- 父流程收到 spritesheet 后先把带解析后纯色背景的源图 owned 上传私有 OSS(消费图片字节所有权,上传完成后释放原图缓冲,不克隆保留),写入项目资源和账号素材库;随后只持 object key,并仅向同机唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC,请求中的源图只以 object key 传递,并附带 BgFilter 参数、剩余预算和有界审计关联,父流程不签发 BgFilter URL、不直连 provider,也不重试整次内部 RPC。子 worker 在 `Q` admission 和 `Semaphore(N)` 约束下执行这次逻辑调用,每次 provider attempt 前重新签发 600 秒 GET URL,multipart 固定传 `image_url`、`screen_color=`、`seg_model=`、`background_mode=flat` 和 `cross_check=off`,不包含 `file`,并在内部 RPC 总预算内最多执行两次顺序 attempt,默认 `segModel=birefnet`。成功时,子 worker 通过内部 HTTP 二进制 body 把经过校验的图片字节直接返回父流程,不持久化中间结果;BgFilter 最终失败且父业务预算仍有效时,由父流程进入“阿里云通用抠图(按签名 URL 单独下载)→ 本地键色(再按 object key 独立下载一次原图并在产出后释放)”降级链。透明背景处理正常成功时,父流程把透明 spritesheet 写入 OSS、项目资源和账号素材库,再复用图标素材的连通域拆分能力;调用方未指定素材文件夹时落默认“项目”文件夹。BgFilter 与父侧 fallback 最终均失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建透明图集,也不继续拆分。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。最终透明结果及拆分切片的 OSS / 资源 / 画布持久化仍全部由父流程负责。 - UI 素材自动拆分只在透明图集成功后执行,与图标图集一致,属于 best-effort 附加动作。未知素材数量时按从上到下、从左到右自动命名为 `素材 1`、`素材 2`;识别或切片持久化失败仍返回整张透明图集和 `sliceWarning`,前端显示非阻断 warning toast,用户可手动重试。`sliceWarning` 与透明背景最终失败使用的通用 `warning` 互斥,前者只表示透明图集成功但自动拆分失败,`sliceWarning.reason` 原始契约保持不变。 - 正常透明化成功时,前端先把透明 spritesheet 作为 `assetKind: "icon-spritesheet"` 图集图层放在 UI 设计图右侧,再把拆分成功的独立素材作为 `assetKind: "icon"` 图标图层继续放到画布;透明背景处理最终失败时只消费后端快照中的 provider 原图。透明图集图层提供 `拆分图集` 工具栏按钮,可使用相同连通域规则重新拆分。 diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index 9e1fc8946..b5f104b74 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -59,8 +59,8 @@ ## 去背与保存 -- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入私有 OSS,并在上传完成后释放原图缓冲,再签发 600 秒 GET URL 调用 BgFilter 透明化;BgFilter multipart 固定传 `image_url`、`screen_color=`、`seg_model=`、`background_mode=flat` 和 `cross_check=off`,不包含 `file`。前端用户路径固定提交 `screenColor=auto` 与默认 `segModel=birefnet`,后端仍识别内部保留的 `anime-seg`,但这些内部参数不对用户可见。 -- 透明背景处理正常成功时,带背景原图和去背后的透明 spritesheet 都先写入 OSS、项目资源和账号素材库,再按 alpha 连通域和素材描述顺序执行附加拆分;若 BgFilter 返回较小图集,只把 alpha 蒙版重采样到 provider 原图尺寸并应用回原始高分辨率 RGB,不放大低分辨率后处理成品。画布完成快照同时写入透明主图与右侧 provider 原图(二者均已登记为 project resource / 账号素材),`generatedLayerId` 仍锚定透明主图;成功拆出的切片从 provider 原图右侧继续排列。调用方未指定素材文件夹时统一落默认“项目”文件夹。每个成功切片单独写入 OSS、项目资源和账号素材库,`sourceResourceId` 指向透明图集资源。透明背景处理最终失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建透明图集,也不继续拆分,`iconImageSrcs=[]`。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。 +- 父流程收到 spritesheet 后先把带解析后纯色背景的源图写入私有 OSS,并在上传完成后释放原图缓冲;随后只持 object key,并仅向同机唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC,请求中的源图只以 object key 传递,并附带 BgFilter 参数、剩余预算和有界审计关联,父流程不签发 BgFilter URL、不直连 provider,也不重试整次内部 RPC。子 worker 在 `Q` admission 和 `Semaphore(N)` 约束下执行这次逻辑调用,每次 provider attempt 前重新签发 600 秒 GET URL,multipart 固定传 `image_url`、`screen_color=`、`seg_model=`、`background_mode=flat` 和 `cross_check=off`,不包含 `file`,并在内部 RPC 总预算内最多执行两次顺序 attempt。前端用户路径固定提交 `screenColor=auto` 与默认 `segModel=birefnet`,后端仍识别内部保留的 `anime-seg`,但这些内部参数不对用户可见。成功时,子 worker 通过内部 HTTP 二进制 body 把经过校验的图片字节直接返回父流程,不持久化中间结果;BgFilter 最终失败且父业务预算仍有效时,由父流程进入“阿里云通用抠图(按签名 URL 单独下载)→ 本地键色(再按 object key 独立下载一次原图并在产出后释放)”降级链。 +- 透明背景处理正常成功时,父流程把带背景原图和去背后的透明 spritesheet 写入 OSS、项目资源和账号素材库,再按 alpha 连通域和素材描述顺序执行附加拆分;若 BgFilter 返回较小图集,只把 alpha 蒙版重采样到 provider 原图尺寸并应用回原始高分辨率 RGB,不放大低分辨率后处理成品。画布完成快照同时写入透明主图与右侧 provider 原图(二者均已登记为 project resource / 账号素材),`generatedLayerId` 仍锚定透明主图;成功拆出的切片从 provider 原图右侧继续排列。调用方未指定素材文件夹时统一落默认“项目”文件夹。每个成功切片单独写入 OSS、项目资源和账号素材库,`sourceResourceId` 指向透明图集资源。BgFilter 与父侧 fallback 最终均失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建透明图集,也不继续拆分,`iconImageSrcs=[]`。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。最终透明结果及切片的 OSS / 资源 / 画布持久化仍全部由父流程负责。 - 自动拆分只在透明图集成功后执行,属于 best-effort 附加动作,不参与图集生成的成功判定。连通域识别或切片持久化失败时,接口仍返回并回填整张透明图集,`iconImageSrcs=[]`,并通过 `sliceWarning.code/reason` 暴露非阻断原因;`sliceWarning` 与透明背景最终失败使用的通用 `warning` 互斥,前者只表示透明图集成功但自动拆分失败,`sliceWarning.reason` 原始契约保持不变。前端在 inline、worker 队列完成和刷新恢复三条路径统一显示对应 warning toast,用户可在图集工具栏手动重试。 - 响应通过 `iconImageSrcs` 返回成功切片素材;自动生成使用用户输入的素材描述命名,UI 设计提取和手动拆分按从上到下、从左到右自动命名为 `素材 N`。 - 手动拆分调用 `POST /api/editor/icon-spritesheets/slices`,只允许读取当前用户项目中的 `icon-spritesheet` 资源,不调用图片生成 provider,不扣除泥点。输入限制为单边最多 `4096` 像素、总像素最多 `2048×2048`,单次最多持久化 `64` 个切片;超限在任何切片写入前拒绝。 diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index edd32af5b..42997ba58 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -54,7 +54,7 @@ - 请求同时提交 `model`、`screenColor`、`segModel`、`aspectRatio` 和 `imageSize`: - `model` 支持 `gemini-3.1-flash-image-preview`(UI 显示 `nanobanana2`)和 `gpt-image-2`,默认 `nanobanana2`。 - 用户在角色或图标素材面板中切换过模型后,下一次打开这两类面板继续使用上次模型。 - - 前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,不从生成器快照或输入快照恢复旧手动背景色 / 抠图模型。后端在组装 prompt 前把 `auto` 自动决策为具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`;调用 BgFilter 时只把解析后的具体 hex 作为 `screen_color` 传入。后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。 + - 前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,不从生成器快照或输入快照恢复旧手动背景色 / 抠图模型。后端在组装 prompt 前把 `auto` 自动决策为具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`;父流程请求唯一 loopback `bgfilter-worker` 时只把解析后的具体 hex 作为 `screenColor` 参数传入。后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。 - 比例按 `x:y` 展示;大小按 `0.5K / 1K / 2K` 展示。 - 尺寸选项来源以 VectorEngine 接入文档为准: - `nanobanana2`:比例 `1:1 / 4:3 / 3:2 / 2:3 / 9:16 / 16:9`;大小 `0.5K / 1K / 2K`。后端走 `/v1beta/models/{model}:generateContent`,把比例写入 `generationConfig.imageConfig.aspectRatio`,把大小写入 `generationConfig.imageConfig.imageSize`;其中 `0.5K` 按文档传 `"512"`。 @@ -67,7 +67,7 @@ 角色设定:<用户输入的角色设定> ``` -- 角色图生成完成后,编辑器后端必须先把带自动决策纯色背景的源图 owned 上传私有 OSS(消费图片字节所有权,上传完成后释放原图缓冲),再对 object key 签发 600 秒 GET URL 调用共享 BgFilter 服务透明化。BgFilter 主路径不重新下载原图;每次 HTTP attempt 重新换签,multipart 字段包含 `image_url`、`screen_color=`、`seg_model=`、`background_mode=flat` 和 `cross_check=on`,不包含 `file`,用户路径默认并只提交 `seg_model=birefnet`;`flat` 明确表示单一纯色背景抠图模式,`birefnet` 是 BgFilter 管线内部后端。首次请求失败后立即重试 `1` 次,第二次仍失败进入“阿里云通用抠图(按签名 URL 单独下载)→ 本地键色(再按 object key 独立下载一次原图并在产出后释放)”降级链。角色图 prompt 按 `screenColor` 写入颜色名称、hex 和 RGB。该流程不再调用 RPG / 资产工坊的角色主图专用 `character_visual_assets` 后处理,也不复用手动去背景的 `background_mode=complex` 路径。透明背景处理正常成功时,输出透明背景 PNG,随后写入 OSS 私有对象并确认 `asset_object`;接口回包返回 `imageSrc: "/"`、`objectKey`、`assetObjectId` 及资源快照,画布同时写入透明主结果和 provider 原图,生成器 `generatedLayerId` 锚定透明主结果,provider 原图作为第二个图层放在其右侧。三段透明背景处理最终仍失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建不存在的透明处理图;通用 `warning.code/reason` 携带完整降级原因。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。前端创建图层和画板资源记录时必须保存最终回包对应的媒体引用。 +- 角色图生成完成后,编辑器父流程必须先把带自动决策纯色背景的源图 owned 上传私有 OSS(消费图片字节所有权,上传完成后释放原图缓冲),随后只持 object key,并仅向同机唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC;请求中的源图只以 object key 传递,并附带 BgFilter 参数、剩余预算和有界审计关联,父流程不签发 BgFilter URL、不直连 provider,也不重试整次内部 RPC。子 worker 在 `Q` admission 和 `Semaphore(N)` 约束下执行这次逻辑调用,每次 provider attempt 前重新签发 600 秒 GET URL,multipart 字段包含 `image_url`、`screen_color=`、`seg_model=`、`background_mode=flat` 和 `cross_check=on`,不包含 `file`,并在内部 RPC 总预算内最多执行两次顺序 attempt;用户路径默认并只提交 `seg_model=birefnet`,`flat` 明确表示单一纯色背景抠图模式,`birefnet` 是 BgFilter 管线内部后端。成功时,子 worker 通过内部 HTTP 二进制 body 把经过校验的图片字节直接返回父流程,不持久化中间结果;BgFilter 最终失败且父业务预算仍有效时,由父流程进入“阿里云通用抠图(按签名 URL 单独下载)→ 本地键色(再按 object key 独立下载一次原图并在产出后释放)”降级链。角色图 prompt 按 `screenColor` 写入颜色名称、hex 和 RGB。该流程不再调用 RPG / 资产工坊的角色主图专用 `character_visual_assets` 后处理,也不复用手动去背景的 `background_mode=complex` 路径。透明背景处理正常成功时,父流程输出透明背景 PNG,随后写入 OSS 私有对象并确认 `asset_object`;接口回包返回 `imageSrc: "/"`、`objectKey`、`assetObjectId` 及资源快照,画布同时写入透明主结果和 provider 原图,生成器 `generatedLayerId` 锚定透明主结果,provider 原图作为第二个图层放在其右侧。BgFilter、阿里云与本地键色最终均失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建不存在的透明处理图;通用 `warning.code/reason` 携带完整降级原因。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。最终透明结果的 OSS / 资源 / 画布持久化仍全部由父流程负责;前端创建图层和画板资源记录时必须保存最终回包对应的媒体引用。 - 对 `assetKind: "character"` 的角色图层执行 `重绘` 时,前端仍使用原图作为参考图,但请求 `kind` 必须传 `character`,让后端继续套用上述角色提示词限定、角色图后处理和角色资产持久化;透明背景正常成功与最终失败保留 provider 原图的收口规则和角色新生成一致。普通图片图层重绘仍保持 `kind: "quick-edit"`。 ## 生成规范参考图 @@ -116,7 +116,7 @@ - 角色生成提交统一走 `/api/editor/images/generations`,按 `角色规范 -> 常规参考图` 顺序传 `referenceImageSrcs`,并写入 `assetKind: "character"`。 - 角色图层重绘同样走 `/api/editor/images/generations` 的 `kind: "character"` 分支,原图作为参考图提交,生成结果继续保留 `assetKind: "character"`。 - 角色和图标素材生成已接入 `nanobanana2` / `gpt-image-2` 模型切换、上次模型记忆,以及按模型归一的比例 / 大小尺寸;`nanobanana2` 使用原生 `generateContent` 的 `imageConfig.aspectRatio/imageSize`,`gpt-image-2` 使用文档列出的 `size` 字符串。 -- 角色生成后端已按固定 prompt 骨架补入 `角色设定` 和自动决策纯色抠图背景,并在生成成功后先保存纯色背景源图,再通过 BgFilter 按用户路径默认 `segModel=birefnet` 执行透明化;透明化成功时把处理图写入 `generated-character-drafts/editor/character-images//image.png` 路径下的 OSS 私有对象,并把透明主结果与其右侧 provider 原图一起写入画布,`generatedLayerId` 仍锚定透明主结果;最终失败时则只保留并返回已经持久化的 provider 原图和通用 warning。若 BgFilter 返回较小图片,只允许把其 alpha 蒙版重采样到 provider 原图尺寸并应用回原始高分辨率 RGB,不得放大低分辨率透明成品。最终回包的 `objectKey` / `assetObjectId` 会随画板资源记录保存。 +- 角色生成后端已按固定 prompt 骨架补入 `角色设定` 和自动决策纯色抠图背景,并在生成成功后先保存纯色背景源图,再由父流程通过唯一 loopback `bgfilter-worker` 的 flat 内部 HTTP 调用执行透明化;子 worker 返回成功二进制后,父流程把处理图写入 `generated-character-drafts/editor/character-images//image.png` 路径下的 OSS 私有对象,并把透明主结果与其右侧 provider 原图一起写入画布,`generatedLayerId` 仍锚定透明主结果;BgFilter 与父侧 fallback 最终均失败时则只保留并返回已经持久化的 provider 原图和通用 warning。若 BgFilter 返回较小图片,只允许把其 alpha 蒙版重采样到 provider 原图尺寸并应用回原始高分辨率 RGB,不得放大低分辨率透明成品。最终回包的 `objectKey` / `assetObjectId` 会随画板资源记录保存。 - `Esc` 只退出角色规范画布点选状态,不关闭角色生成面板。 - 已补充回归测试覆盖角色形象生成、点选退出、角色动画入口隔离和快速编辑入口。 - 本次验证命令: @@ -166,9 +166,9 @@ - 抽帧采样必须按目标帧数预留视频尾部安全步长,例如 `32帧·4秒` 最后一帧采 `3.875s`,避免 FFmpeg 在尾点附近返回成功但输出 `0` 帧。 - 图片画布角色动作的 FFmpeg 原始帧在上传 OSS 前必须转为 RGB8,并按最终帧宽高的 contain 比例使用 `Triangle` 只缩放到内容尺寸;不得提前创建最终目标尺寸 RGBA 画布,不得引入 Alpha 通道或透明 padding。以 `560×752` 原始帧、`323×480` 最终目标为例,上传给抠图链路的源帧必须是 `323×434 RGB8 PNG`,没有上下补边。抽帧解码后若携带 Alpha 通道,必须先把像素按白底合成为不透明再转 RGB8,禁止直接丢弃 Alpha——全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入杂色边缘;共享 FFmpeg 抽帧命令保持不固定 `-pix_fmt`,白底合成只属于该链路的 BgFilter 输入准备阶段。 - 后端先计算整批精确采样时刻,再用单个 FFmpeg filter graph 统一解码预览视频并输出 `32 / 40 / 48` 张源帧;不得为每帧重新启动 FFmpeg、重复解码同一视频,也不得用会改变现有尾帧安全时刻的粗粒度 `fps` 抽帧替代。批量命令成功后必须逐一确认全部目标帧文件存在,缺少任一帧都按整批失败处理并保留缺帧编号、目标时刻和输出路径诊断。 -- 每帧绿幕源图字节由上传 owned 消费(`frame.bytes` 移入 put,上传完成后释放原帧缓冲,不克隆保留);后续只持 object key。每次 BgFilter attempt 重新签发 600 秒 GET URL,multipart 仅传 `image_url`(加 `background_mode=flat`、`seg_model=birefnet`、`cross_check=on` 与同一次生成已选定的 `screenColor`),不传 `file`。BgFilter 主路径不重新下载原帧;失败后走 `阿里云通用抠图(按签名 URL 单独下载)→ 本地 editor_green_screen(再按 object key 独立下载一次并在产出后释放)`。BgFilter 每一次 HTTP attempt 的 timeout 使用“`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 基准值 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧分别为 `244000 / 260000 / 276000ms`;首次失败后重试 `1` 次。 -- BgFilter、阿里云或本地键色返回透明结果后,后端继续通过现有最终帧 finalizer 转为 RGBA8,按宽高比居中放入最终目标尺寸,并使用 `RGBA(0,0,0,0)` 补边。上述样例最终输出必须为 `323×480 RGBA8 PNG`,顶部和底部各 `23px` 透明 padding,内容区域完整保留抠图结果。 -- 全部 `32 / 40 / 48` 帧以覆盖本次所有帧的无序在途集合连续发射,允许乱序完成并最终按 `frameIndex` 排序;任一帧最终失败时先排空全部已启动 Future,再让整项任务失败退款,不发布缺帧动画。 +- 每帧绿幕源图字节由上传 owned 消费(`frame.bytes` 移入 put,上传完成后释放原帧缓冲,不克隆保留);后续父流程只持 object key,并为每帧向唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC,不直接签发 BgFilter URL、不直连 provider,也不重试整次内部 RPC。子 worker 在 `Q` admission 和 `Semaphore(N)` 约束下调度每帧逻辑调用,每次 provider attempt 前重新签发 600 秒 GET URL,multipart 仅传 `image_url`(加 `background_mode=flat`、`seg_model=birefnet`、`cross_check=on` 与同一次生成已选定的 `screenColor`),不传 `file`。每个真实 provider attempt 的上限统一为 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=180000ms`,不再按帧数增加;`32 / 40 / 48` 帧使用相同的单 attempt 上限,排队、等待 `N`、最多两次顺序 attempt、结果校验和响应构造都由该帧内部 RPC 的总预算覆盖。成功图片由子 worker 以内部 HTTP 二进制 body 返回父流程,不在子侧落 OSS;BgFilter 最终失败且父业务预算仍有效时,由父流程进入 `阿里云通用抠图(按签名 URL 单独下载)→ 本地 editor_green_screen(再按 object key 独立下载一次并在产出后释放)`。 +- BgFilter、阿里云或本地键色返回透明结果后,父流程继续通过现有最终帧 finalizer 转为 RGBA8,按宽高比居中放入最终目标尺寸,并使用 `RGBA(0,0,0,0)` 补边;最终帧 OSS 与业务写回仍由父流程完成。上述样例最终输出必须为 `323×480 RGBA8 PNG`,顶部和底部各 `23px` 透明 padding,内容区域完整保留抠图结果。 +- 全部 `32 / 40 / 48` 帧以覆盖本次所有帧的无序在途集合向内部 worker 提交,允许乱序完成并最终按 `frameIndex` 排序;BgFilter provider 的实际在途请求受唯一 worker 的 `N / Q` 限制。任一帧最终失败时先排空全部已启动 Future,再让整项任务失败退款,不发布缺帧动画。 - 抽帧结果写入 OSS,并返回帧路径、帧尺寸、帧数、fps、预览视频路径、模型、价格和实际 prompt。 - 画板前端回填角色动作结果时,必须以 `frames[0].imageSrc` 创建 `mediaType: "image-sequence"`、`assetKind: "character-animation"` 图层,并把完整 `frames` 保存为图层 `imageSequenceFrames`;`previewVideoPath` 只保留为上游预览视频来源,不作为画布主媒体。 - 角色动作图层在画布中使用序列帧播放器循环展示透明 PNG 帧;刷新恢复时必须继续读取 `imageSequenceFrames`,不能回退到 `