改用OSS签名URL调用BgFilter

复用生成原图object key并签发十分钟读取地址

移除BgFilter请求中的图片文件重传并保留原有fallback顺序

补充签名URL脱敏测试及架构运维文档
This commit is contained in:
2026-07-15 06:25:25 +00:00
parent c6aa8a248a
commit 70fef2e753
4 changed files with 94 additions and 25 deletions
@@ -16,6 +16,13 @@
---
## 2026-07-15 BgFilter 输入改用私有 OSS 短期签名 URL
- 背景:角色形象、图标图集和 UI 素材图集在调用 BgFilter 前已经把带背景原图持久化到私有 OSS;继续由 api-server 把同一图片作为 multipart `file` 再上传一次,会重复传输图片字节并占用 API 进程网络带宽。
- 决策:上述生成后抠图链路统一复用已持久化原图的 object key,签发 600 秒 OSS GET URL,并通过 BgFilter multipart 的 `image_url` 字段提交;请求中不再携带 `file`。签名 URL 只交给 BgFilter,不写日志或持久化。BgFilter 失败或熔断打开后才使用已保留的图片字节进入原有“阿里云通用抠图 → 本地键色”兜底链。
- 影响范围:仅 `api-server/editor_project` 的 BgFilter 请求输入与相关文档;不改变 BgFilter endpoint、鉴权、`screen_color``seg_model`、输出校验、熔断规则、阿里云上传协议或降级顺序。
- 验证方式:定向测试必须断言 BgFilter 请求函数包含 `image_url` 与 600 秒 OSS 换签,不包含 multipart `file` 或源图字节读取;随后运行 `cargo check -p api-server --manifest-path server-rs/Cargo.toml`
## 2026-07-15 阿里云通用抠图上传切换到 AuthorizeFileUpload 正式链路
- 背景:`platform-matting` 原先通过 `viapiutils/GetOssStsToken` 获取临时 AK/SK,再向固定 `viapi-customer-temp` 共享桶执行 OSS V1 PUT。阿里云官方文档将该显式生成 URL 的共享临时桶通道标记为不保证 SLA、仅便于调试且不推荐生产使用;动作视频逐帧抠图会把这条风险放大到每任务 32 至 48 次。
@@ -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` 当成上游业务错误。
- 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 继续代理独立 BiRefNet 服务,配置为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL``GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN``GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS`。角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取的生成后纯色背景透明化改走独立 BgFilter 服务,配置为 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL``GENARRATIVE_EDITOR_BGFILTER_TOKEN``GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`,默认 base URL 为 `http://58.87.105.82/bgfilter`,默认请求超时为 `180000ms`BgFilter 当前为 CPU 推理,单次抠图较慢,必须留足超时),token 未配置时复用 BiRefNet token。BgFilter 请求必须显式传 `screen_color=<screenColor>``seg_model=<segModel>`;前端用户路径不展示抠图模型选择并固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,其中 `birefnet` 只表示 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。BgFilter 调用失败,或连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`(默认 `3`)并在 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`(默认 `300`)内打开熔断时,均跳过或结束 BgFilter 调用后复用同一兜底链:先调用阿里云通用抠图,阿里云失败才使用本地 `editor_green_screen` 键色扣除;熔断期不得直接退化到本地兜底。角色动作视频生成的背景色已与生图链路统一:`screenColor=auto` 时由视觉 LLM`gpt-5-mini`Responses 协议、low 推理档)读源角色图自动决策,并经硬过滤器剔除与前景 / 皮肤撞色的候选,手动 hex 则尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色。抽帧后逐帧优先走阿里云通用抠图,失败时降级本地 `editor_green_screen` 键色兜底(按生成时选定的背景色,而非固定 `#00FF00`)。阿里云通用抠图配置为 `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 与阿里云抠图失败都写入 `external_api_call_failure` 审计。
- BgFilter 处理已经持久化到私有 OSS 的带背景原图时,`api-server` 必须签发 600 秒 GET URL 并通过 multipart `image_url` 提交,不再下载对象或用 `file` 重传图片字节;签名 URL 不得写入日志、审计或持久化。只有 BgFilter 失败或熔断打开进入 fallback 后,才允许使用图片字节继续调用阿里云通用抠图或本地键色。
- 阿里云通用抠图的非上海地域输入不得使用 `viapiutils/GetOssStsToken`、固定 `viapi-customer-temp` 或 OSS V1 PUT。`platform-matting` 必须按官方新版 SDK Advance 协议调用 `AuthorizeFileUpload`,使用动态返回的单对象 Policy 执行 multipart POST,再把临时上海 OSS URL 交给 `SegmentCommonImage`;输入归一化、结果下载与原尺寸 Alpha 回贴继续留在同一适配器内。该协议仍上传图片字节,不等同于阿里云服务端直接抓取任意公网 URL,也不改变上层 BgFilter → 阿里云 → 本地降级顺序。
- 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。
@@ -67,6 +67,8 @@ lease 过期后不代表任务一定再次执行:claim transaction 只有在 `
阿里云通用抠图的非上海地域输入使用 `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``screen_color``seg_model`,不再提交 `file`,也不会在 BgFilter 调用前重新下载 OSS 对象。排障日志只应出现 object key 与签名有效期,不得记录带 `x-oss-*` 查询参数的完整 URL;BgFilter 失败或熔断打开后仍按既有顺序进入阿里云通用抠图和本地键色 fallback。
`我的` 页签或排障面板展示队列等待时,只读取 BFF 队列接口:`GET /api/runtime/external-generation/queue-overview` 查看当前用户可见队列概览,`GET /api/runtime/external-generation/jobs/{jobId}` 查看单 job 状态。生成页 / 进度页不承接队列概览,只展示当前玩法业务进度;队列接口只提供等待 / 运行 / 失败 / 完成状态补充,最终草稿、作品和结果页仍要轮询对应玩法 session/detail 接口收敛到 ready 或 failed;不要直接查询 `external_generation_job` private table,也不要把 worker 内部 payload 暴露到前端。
外部生成任务摘要投影与历史 payload 维护使用 `npm run spacetime:external-generation:maintain -- ...`,且只能由已授权 migration operator 的 SpacetimeDB CLI 登录态执行。脚本默认 dry-run、每次只处理一批,绝不自动循环全表;`--apply` 才写入。先发布包含 `external_generation_job_summary` 与 cursor 索引的 SpacetimeDB 模块,在维护模式内对事故时间以前的编辑器终态任务执行小批 dry-run,例如 `npm run spacetime:external-generation:maintain -- --database <database> --server-url <url> --limit 5 --completed-before-micros <micros>`;核对 `matched_count``before_bytes``after_bytes``inline_media_count` 后,保持本批输入 cursor 不变并追加 `--apply` 重跑同一批,即使最后一批 `has_more = false`,只要 dry-run 仍有 `matched_count` / `selected_count` 也必须 apply;只有 apply 成功后才使用它返回的 `next_cursor_job_id` 继续。B-tree cursor 的选择阶段最多反序列化 `limit + 1` 行,apply 会再按主键逐条读取选中行但不会同时保留整批 payload;如怀疑存在单行异常巨型历史 JSON,先用 `--limit 1`。payload 压缩硬限制 `source_module = editor-canvas`;终态压缩完成后,用 `--backfill-summaries` 先 dry-run、再 `--apply` 分批补齐仍缺失的活动任务或无内联媒体历史任务摘要,直到 `has_more = false`,最后再切换使用 summary procedure 的 api-server。Stdb 构建 artifact 和完整 release 包都必须包含 `scripts/spacetime-maintain-external-generation-jobs.mjs``scripts/spacetime-migration-common.mjs`。首次上线不得让 Full Build 从 Stdb 自动直落 API`STDB_API_ROLLOUT_MODE` 默认 fail-closed 为 `pause-after-stdb`,必须填写受限的 `STDB_API_ROLLOUT_APPROVERS`Stdb Publish 通过 `KEEP_MAINTENANCE_MODE` 保持维护文件并停止旧 API/controller/worker,暂停点最多等待 4 小时,完成上述维护并确认无后续批次后才由指定审批人放行 API。定时构建缺少审批人时必须在发布前失败,不能静默退回 `normal`;也可分开运行 Stdb publish、维护、API deploy 三个受控 Job。任一批次都不得处理 pending / running payload;不要用 runtime writer、bootstrap secret 或匿名 identity 代替 migration operator,也不要在未核对 dry-run 时直接 apply。
@@ -126,6 +126,7 @@ const EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND: &str = "editor_green_screen_
const EDITOR_PROVIDER_SOURCE_SLOT: &str = "provider_source";
const EDITOR_BGFILTER_DEFAULT_SEG_MODEL: &str = "birefnet";
const EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG: &str = "anime-seg";
const EDITOR_BGFILTER_IMAGE_URL_EXPIRE_SECONDS: u64 = 600;
const EDITOR_PUBLICATION_MATERIAL_ASSET_KIND: &str = "editor_publication_material";
const EDITOR_LEGACY_INLINE_IMAGE_ASSET_KIND: &str = "editor_legacy_inline_image";
@@ -1630,6 +1631,7 @@ pub(crate) async fn generate_editor_image_for_owner(
"character-image",
)
.await?;
let source_object_key = source_persisted.object_key.clone();
let source_record = persist_editor_provider_source_resource(
state,
source_persisted,
@@ -1667,6 +1669,7 @@ pub(crate) async fn generate_editor_image_for_owner(
};
let removal = remove_editor_generated_screen_background_with_bgfilter(
state,
source_object_key.as_str(),
&image,
screen_color.expect("character generation should have screen color"),
seg_model.expect("character generation should have BgFilter seg model"),
@@ -2755,7 +2758,8 @@ struct EditorScreenBackgroundRemovalOutput {
async fn remove_editor_generated_screen_background_with_bgfilter(
state: &AppState,
image: &DownloadedOpenAiImage,
source_object_key: &str,
fallback_image: &DownloadedOpenAiImage,
screen_color: EditorScreenBackgroundColor,
seg_model: &str,
audit: &crate::external_api_audit::ExternalApiAuditContext,
@@ -2770,12 +2774,18 @@ async fn remove_editor_generated_screen_background_with_bgfilter(
cooldown_ms = remaining.as_millis() as u64,
"editor_bgfilter_circuit_open_fallback_to_aliyun_matting"
);
return fallback_editor_screen_background_removal(state, image, screen_color, audit).await;
return fallback_editor_screen_background_removal(
state,
fallback_image,
screen_color,
audit,
)
.await;
}
match request_editor_generated_screen_background_with_bgfilter(
state,
image,
source_object_key,
screen_color,
seg_model,
)
@@ -2813,7 +2823,13 @@ async fn remove_editor_generated_screen_background_with_bgfilter(
crate::external_api_audit::matting_failure_audit_raw_excerpt(&error),
)
.await;
fallback_editor_screen_background_removal(state, image, screen_color, audit).await
fallback_editor_screen_background_removal(
state,
fallback_image,
screen_color,
audit,
)
.await
}
}
}
@@ -2926,28 +2942,34 @@ fn reset_editor_bgfilter_circuit_for_tests() {
async fn request_editor_generated_screen_background_with_bgfilter(
state: &AppState,
image: &DownloadedOpenAiImage,
source_object_key: &str,
screen_color: EditorScreenBackgroundColor,
seg_model: &str,
) -> Result<DownloadedOpenAiImage, AppError> {
let url = editor_bgfilter_endpoint(state)?;
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let signed = oss_client
.sign_get_object_url(OssSignedGetObjectUrlRequest {
object_key: source_object_key.to_string(),
expire_seconds: Some(EDITOR_BGFILTER_IMAGE_URL_EXPIRE_SECONDS),
})
.map_err(|error| map_oss_error(error, "aliyun-oss"))?;
let signed_image_url = signed.signed_url;
let call_id = format!("bgfilter-call-{}", current_utc_micros());
let request_started_at = Instant::now();
let timeout_ms = state.config.editor_bgfilter_request_timeout_ms.max(1);
let input_bytes = image.bytes.len();
let source_mime_type = image.mime_type.clone();
let source_file_name = format!(
"editor-generated-source.{}",
image.extension.trim_start_matches('.')
);
tracing::info!(
%call_id,
upstream_url = %url,
file_name = %source_file_name,
mime_type = %source_mime_type,
source_object_key,
image_url_expire_seconds = EDITOR_BGFILTER_IMAGE_URL_EXPIRE_SECONDS,
screen_color = screen_color.hex,
seg_model,
input_bytes,
timeout_ms,
"editor_bgfilter_request_start"
);
@@ -2960,17 +2982,8 @@ async fn request_editor_generated_screen_background_with_bgfilter(
"message": format!("创建 BgFilter HTTP 客户端失败:{error}"),
}))
})?;
let file_part = reqwest::multipart::Part::bytes(image.bytes.clone())
.file_name(source_file_name)
.mime_str(source_mime_type.as_str())
.map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "bgfilter",
"message": format!("图片 MIME 类型无效:{error}"),
}))
})?;
let form = reqwest::multipart::Form::new()
.part("file", file_part)
.text("image_url", signed_image_url.clone())
.text("screen_color", screen_color.hex.to_string())
.text("seg_model", seg_model.to_string());
let mut request = http_client.post(url.as_str()).multipart(form);
@@ -3000,6 +3013,7 @@ async fn request_editor_generated_screen_background_with_bgfilter(
.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(),
@@ -3071,7 +3085,6 @@ async fn request_editor_generated_screen_background_with_bgfilter(
seg_model,
upstream_screen_color = upstream_screen_color.as_deref().unwrap_or(""),
upstream_seg_model = upstream_seg_model.as_deref().unwrap_or(""),
input_bytes,
output_bytes = image.bytes.len(),
elapsed_ms = request_started_at.elapsed().as_millis() as u64,
upstream_elapsed_ms,
@@ -3244,6 +3257,17 @@ fn editor_bgfilter_endpoint(state: &AppState) -> Result<String, AppError> {
))
}
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);
@@ -3698,6 +3722,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
"spritesheet",
)
.await?;
let source_object_key = source_persisted.object_key.clone();
let source_record = persist_editor_provider_source_resource(
state,
source_persisted,
@@ -3729,6 +3754,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
};
let removal = remove_editor_generated_screen_background_with_bgfilter(
state,
source_object_key.as_str(),
&image,
screen_color,
seg_model,
@@ -4307,6 +4333,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
"spritesheet",
)
.await?;
let source_object_key = source_persisted.object_key.clone();
let source_record = persist_editor_provider_source_resource(
state,
source_persisted,
@@ -4338,6 +4365,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
};
let removal = remove_editor_generated_screen_background_with_bgfilter(
state,
source_object_key.as_str(),
&image,
screen_color,
seg_model,
@@ -7022,6 +7050,28 @@ mod tests {
);
}
#[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(
@@ -9405,12 +9455,21 @@ mod tests {
"async fn request_editor_background_removal_image",
&[
"editor_bgfilter_endpoint",
"sign_get_object_url",
"EDITOR_BGFILTER_IMAGE_URL_EXPIRE_SECONDS",
"\"image_url\"",
"editor_bgfilter_request_timeout_ms.max(1)",
"\"screen_color\"",
"\"seg_model\"",
"seg_model.to_string()",
],
);
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"],
);
assert_function_contains(
source,
"async fn request_editor_background_removal_image",