From c6aa8a248a09bb57b7cbdb3d5833964deb55f8ba Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 15 Jul 2026 05:22:34 +0000 Subject: [PATCH] =?UTF-8?q?=E5=88=87=E6=8D=A2=E9=98=BF=E9=87=8C=E4=BA=91?= =?UTF-8?q?=E9=80=9A=E7=94=A8=E6=8A=A0=E5=9B=BE=E6=AD=A3=E5=BC=8F=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改用 AuthorizeFileUpload 与 Policy POST 上传临时对象 移除 GetOssStsToken、OSS V1 和 SHA-1 依赖 补充真实链路冒烟示例、单元测试与架构运维文档 --- .../shared-memory/decision-log.md | 10 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 1 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 + server-rs/Cargo.lock | 3 - .../crates/api-server/src/aliyun_matting.rs | 5 +- server-rs/crates/platform-matting/Cargo.toml | 5 +- .../examples/segment_smoke.rs | 34 +- server-rs/crates/platform-matting/src/lib.rs | 404 +++++++++++------- 8 files changed, 291 insertions(+), 173 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 9ccb95532..f1c23e1bf 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-07-15 阿里云通用抠图上传切换到 AuthorizeFileUpload 正式链路 + +- 背景:`platform-matting` 原先通过 `viapiutils/GetOssStsToken` 获取临时 AK/SK,再向固定 `viapi-customer-temp` 共享桶执行 OSS V1 PUT。阿里云官方文档将该显式生成 URL 的共享临时桶通道标记为不保证 SLA、仅便于调试且不推荐生产使用;动作视频逐帧抠图会把这条风险放大到每任务 32 至 48 次。 +- 决策:非上海地域图片字节统一按新版官方 SDK `AdvanceRequest` 的实际协议处理:调用 `openplatform.aliyuncs.com` 的 `AuthorizeFileUpload` 获取单对象 Bucket、Endpoint、ObjectKey、Policy 与 Signature,使用 multipart Policy POST 上传,再把动态临时对象 URL 交给 `SegmentCommonImage`。移除 `GetOssStsToken`、固定 `viapi-customer-temp`、临时 AK/SK 下发和 OSS V1 SHA-1 签名;图片归一化、结果下载、原尺寸 Alpha 回贴和上层降级顺序保持不变。该链路仍会让图片字节经过执行任务的 api-server / worker 并上传临时 OSS,不把它描述成阿里云服务端直接抓取任意公网 URL。 +- 影响范围:`server-rs/crates/platform-matting`、阿里云抠图冒烟示例、后端架构与开发运维文档;不改变 api-server DTO、动作拆帧、BgFilter 或业务降级契约。 +- 验证方式:`cargo test -p platform-matting --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`,并用真实图片运行 `segment_smoke`,确认授权上传 host 来自动态上海 OSS 且 `SegmentCommonImage` 成功返回。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、阿里云“通用图像分割”与“文件 URL 处理”官方文档。 + ## 2026-07-14 后台账号采用 owner 引导账号与一级 Tab 实时授权 - 背景:后台此前只支持一组环境变量管理员,所有 `/admin/api/*` 共用统一 admin 门禁,无法给运营、审核等人员分配独立账号和页面范围。 @@ -2310,7 +2318,7 @@ ## 2026-05-07 server-rs Cargo 依赖集中到 workspace - 背景:`server-rs` 多 crate 已稳定成 DDD workspace,成员 `Cargo.toml` 中重复散写第三方版本和本地 path 依赖,升级 SpacetimeDB SDK、`serde`、`reqwest`、`tokio` 等依赖时容易漂移。 -- 决策:`server-rs/Cargo.toml` 的 `[workspace.dependencies]` 统一维护第三方依赖版本和 workspace 内部 crate path;成员 crate 默认使用 `{ workspace = true }`,只保留自身 feature、optional 或 target-specific 差异;OSS 与阿里云 OpenAPI 签名统一走 `sha2::Sha256` 对应的 V4/V3 口径。例外:`platform-matting` 向 VIAPI 官方临时 OSS 桶上传抠图输入时,因该临时桶接口要求 OSS V1 头签名,允许在该 crate 内部受限使用 `sha1` / `Hmac` 生成 V1 签名;该例外不得扩展到自有 OSS、通用阿里云 OpenAPI 或其它新链路。 +- 决策:`server-rs/Cargo.toml` 的 `[workspace.dependencies]` 统一维护第三方依赖版本和 workspace 内部 crate path;成员 crate 默认使用 `{ workspace = true }`,只保留自身 feature、optional 或 target-specific 差异;OSS 与阿里云 OpenAPI 签名统一走 `sha2::Sha256` 对应的 V4/V3 口径。2026-07-15 起,`platform-matting` 已移除 VIAPI 共享临时桶的 OSS V1 SHA-1 例外,改用 `AuthorizeFileUpload` 返回的 Policy POST 授权;后续不得恢复 crate 内 SHA-1 签名。 - 影响范围:`server-rs/Cargo.toml`、所有 `server-rs/crates/*/Cargo.toml`、`platform-oss`、`platform-auth`、后续新增 Rust crate 或新增 Rust 依赖的开发流程。 - 验证方式:修改 Cargo 配置后先执行 `cargo metadata --manifest-path server-rs\Cargo.toml --format-version 1 --no-deps`,再按影响范围执行 `cargo check`、DDD 边界检查和编码检查。 - 关联文档:`docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 7876582e1..77abc3a45 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -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=` 和 `seg_model=`;前端用户路径不展示抠图模型选择并固定提交默认 `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` 审计。 +- 阿里云通用抠图的非上海地域输入不得使用 `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。 - Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!` 随 `api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 11be34970..6eb4c8f96 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -65,6 +65,8 @@ lease 过期后不代表任务一定再次执行:claim transaction 只有在 ` 图片画布角色图、图标素材和 UI 素材提取在绿色 / 蓝色幕布去背景时优先调用 BgFilter;默认 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=180000`,连续失败达到 `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,再看 `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` 日志。 +阿里云通用抠图的非上海地域输入使用 `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。 + `我的` 页签或排障面板展示队列等待时,只读取 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 --server-url --limit 5 --completed-before-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。 diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index a010f230e..8c03ba844 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -4481,18 +4481,15 @@ dependencies = [ name = "platform-matting" version = "0.1.0" dependencies = [ - "base64 0.22.1", "dotenvy", "hex", "hmac", - "httpdate", "image", "platform-oss", "reqwest 0.12.28", "serde", "serde_json", "serde_urlencoded", - "sha1", "sha2", "time", "tokio", diff --git a/server-rs/crates/api-server/src/aliyun_matting.rs b/server-rs/crates/api-server/src/aliyun_matting.rs index d3c684d57..6094e6171 100644 --- a/server-rs/crates/api-server/src/aliyun_matting.rs +++ b/server-rs/crates/api-server/src/aliyun_matting.rs @@ -1,7 +1,6 @@ //! 阿里云通用抠图在 api-server 侧的适配层。 //! -//! 输入 URL 策略:抠图服务只认上海地域 OSS URL,而我们没有上海地域自有 OSS, -//! 统一由 platform-matting 上传 VIAPI 官方临时桶(1 天自动过期,无需清理)。 +//! 输入统一由 platform-matting 通过 AuthorizeFileUpload 单对象 Policy 上传动态临时 OSS。 use axum::http::StatusCode; use platform_matting::MattingError; @@ -108,7 +107,7 @@ mod tests { ), MattingError::InvalidRequest("解析待抠图图片失败:invalid png".to_string()), MattingError::InvalidConfig("endpoint 为空".to_string()), - MattingError::Sign("初始化 OSS V1 签名器失败".to_string()), + MattingError::Sign("构造 AuthorizeFileUpload 签名失败".to_string()), ] { let mapped = aliyun_matting_failure_to_app_error(&error, 3); diff --git a/server-rs/crates/platform-matting/Cargo.toml b/server-rs/crates/platform-matting/Cargo.toml index 87a387af0..eacc33dad 100644 --- a/server-rs/crates/platform-matting/Cargo.toml +++ b/server-rs/crates/platform-matting/Cargo.toml @@ -5,13 +5,10 @@ version.workspace = true license.workspace = true [dependencies] -base64 = { workspace = true } hmac = { workspace = true } hex = { workspace = true } -httpdate = { workspace = true } image = { workspace = true, features = ["png", "jpeg", "webp"] } -sha1 = { workspace = true } -reqwest = { workspace = true, features = ["json", "rustls-tls"] } +reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] } serde = { workspace = true } serde_json = { workspace = true } serde_urlencoded = { workspace = true } diff --git a/server-rs/crates/platform-matting/examples/segment_smoke.rs b/server-rs/crates/platform-matting/examples/segment_smoke.rs index c778b5f02..e6abd20e6 100644 --- a/server-rs/crates/platform-matting/examples/segment_smoke.rs +++ b/server-rs/crates/platform-matting/examples/segment_smoke.rs @@ -1,4 +1,4 @@ -//! 通用抠图冒烟验证:本地图片 → OSS → SegmentCommonImage → 下载结果。 +//! 通用抠图冒烟验证:本地图片 → AuthorizeFileUpload 临时对象 → SegmentCommonImage → 下载结果。 //! //! 运行(在 server-rs 目录下): //! cargo run -p platform-matting --example segment_smoke -- "C:\path\to\input.png" @@ -38,17 +38,16 @@ async fn main() { }); let input_bytes = std::fs::read(&input_path) .unwrap_or_else(|error| panic!("读取测试图片失败({input_path}):{error}")); - println!("[1/5] 已读取测试图片:{input_path}({} 字节)", input_bytes.len()); + println!( + "[1/5] 已读取测试图片:{input_path}({} 字节)", + input_bytes.len() + ); // SegmentCommonImage 要求分辨率低于 2000x2000,超限先等比缩小。 const MAX_EDGE: u32 = 1999; let decoded = image::load_from_memory(&input_bytes).expect("测试图片应可解码"); let input_bytes = if decoded.width() > MAX_EDGE || decoded.height() > MAX_EDGE { - let resized = decoded.resize( - MAX_EDGE, - MAX_EDGE, - image::imageops::FilterType::CatmullRom, - ); + let resized = decoded.resize(MAX_EDGE, MAX_EDGE, image::imageops::FilterType::CatmullRom); let mut buffer = std::io::Cursor::new(Vec::new()); resized .write_to(&mut buffer, image::ImageFormat::Png) @@ -68,11 +67,16 @@ async fn main() { let http_client = reqwest::Client::new(); // --- 调用通用抠图 --- - // key 优先级:VIAPI 专用 → 官方 SDK 标准命名(#IMAGE_CALL)→ 短信 key 兜底。 + // key 优先级与 api-server 配置保持一致:抠图专用 → 官方 SDK 标准命名。 let (matting_key_id, matting_key_secret) = [ - ("ALIYUN_IMAGESEG_ACCESS_KEY_ID", "ALIYUN_IMAGESEG_ACCESS_KEY_SECRET"), - ("ALIBABA_CLOUD_ACCESS_KEY_ID", "ALIBABA_CLOUD_ACCESS_KEY_SECRET"), - ("ALIYUN_SMS_ACCESS_KEY_ID", "ALIYUN_SMS_ACCESS_KEY_SECRET"), + ( + "GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID", + "GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET", + ), + ( + "ALIBABA_CLOUD_ACCESS_KEY_ID", + "ALIBABA_CLOUD_ACCESS_KEY_SECRET", + ), ] .iter() .find_map(|(id_name, secret_name)| { @@ -86,7 +90,7 @@ async fn main() { }) .expect("未找到可用的抠图 AccessKey 环境变量"); let matting_config = MattingConfig::new( - std::env::var("ALIYUN_IMAGESEG_ENDPOINT") + std::env::var("GENARRATIVE_ALIYUN_MATTING_ENDPOINT") .unwrap_or_else(|_| DEFAULT_IMAGESEG_ENDPOINT.to_string()), matting_key_id, matting_key_secret, @@ -94,12 +98,12 @@ async fn main() { .expect("抠图配置应有效"); let matting_client = MattingClient::new(matting_config).expect("抠图客户端应可构建"); - // 本地 OSS 在北京地域,抠图服务要求上海地域,走 VIAPI 官方临时桶上传。 + // 非上海地域输入按新版官方 SDK 的 AdvanceRequest 口径申请单对象 Policy 后上传。 let temp_url = matting_client .upload_temp_image(input_bytes, "segment-input.png", "image/png") .await - .expect("上传 VIAPI 临时桶应成功"); - println!("[2/5] 已上传 VIAPI 临时桶"); + .expect("上传 AuthorizeFileUpload 临时对象应成功"); + println!("[2/5] 已上传 AuthorizeFileUpload 临时对象"); println!("[3/5] 输入图 URL host:{}", host_of(&temp_url)); let result = matting_client diff --git a/server-rs/crates/platform-matting/src/lib.rs b/server-rs/crates/platform-matting/src/lib.rs index 0df252889..1cf2c7312 100644 --- a/server-rs/crates/platform-matting/src/lib.rs +++ b/server-rs/crates/platform-matting/src/lib.rs @@ -18,14 +18,12 @@ pub const DEFAULT_IMAGESEG_ENDPOINT: &str = "imageseg.cn-shanghai.aliyuncs.com"; const IMAGESEG_API_VERSION: &str = "2019-12-30"; const SEGMENT_COMMON_IMAGE_ACTION: &str = "SegmentCommonImage"; -// VIAPI 官方临时上传通道:抠图输入统一先传官方临时桶(1 天自动过期,无需清理; -// 全用户共享 QPS)。抠图服务只认上海地域 OSS URL,而我们没有上海地域自有 OSS。 -// 文档:https://help.aliyun.com/document_detail/155645.html -const VIAPI_UTILS_ENDPOINT: &str = "viapiutils.cn-shanghai.aliyuncs.com"; -const VIAPI_UTILS_VERSION: &str = "2020-04-01"; -const GET_OSS_STS_TOKEN_ACTION: &str = "GetOssStsToken"; -const VIAPI_TEMP_BUCKET: &str = "viapi-customer-temp"; -const VIAPI_TEMP_OSS_HOST: &str = "viapi-customer-temp.oss-cn-shanghai.aliyuncs.com"; +// VIAPI 新版官方 SDK 的 AdvanceRequest 上传通道:先向 Open Platform 申请单对象 +// Policy,再 multipart POST 到授权返回的上海地域临时 OSS。 +const OPEN_PLATFORM_ENDPOINT: &str = "openplatform.aliyuncs.com"; +const AUTHORIZE_FILE_UPLOAD_ACTION: &str = "AuthorizeFileUpload"; +const AUTHORIZE_FILE_UPLOAD_VERSION: &str = "2019-12-19"; +const IMAGESEG_PRODUCT: &str = "imageseg"; pub const DEFAULT_MATTING_REQUEST_TIMEOUT_MS: u64 = 30_000; @@ -382,7 +380,7 @@ impl MattingClient { /// 图片字节 → 通用抠图 → 原尺寸透明 PNG 字节。 /// - /// - 输入统一上传 VIAPI 官方临时桶(1 天自动过期,无需清理)后作为 ImageURL 送抠。 + /// - 输入统一经 AuthorizeFileUpload 单对象 Policy 上传临时 OSS 后作为 ImageURL 送抠。 /// - 分辨率守卫:任一边 >= 2000 时先等比缩小送抠,抠完只取结果 alpha 上采样回贴 /// 原图 RGB,保证输出与输入同尺寸、画质无损。 pub async fn segment_image_to_transparent_png( @@ -492,8 +490,8 @@ impl MattingClient { Ok(bytes) } - /// 把本地图片字节上传到 VIAPI 官方临时桶,返回可直接作为 ImageURL 的公网地址。 - /// 抠图输入的统一上传通道(我们没有上海地域自有 OSS,临时桶 1 天自动过期无需清理)。 + /// 按 VIAPI 新版官方 SDK 的 AdvanceRequest 协议,把本地图片字节上传到授权的 + /// 上海地域临时 OSS,返回可直接作为 ImageURL 的公网地址。 pub async fn upload_temp_image( &self, bytes: Vec, @@ -505,159 +503,93 @@ impl MattingClient { "上传内容不能为空".to_string(), )); } - let sts = self.get_oss_sts_token().await?; let file_name = file_name.trim().trim_matches('/'); if file_name.is_empty() { return Err(MattingError::InvalidRequest( "file_name 不能为空".to_string(), )); } - // 阿里云要求 ImageURL 不含中文/非 ASCII 字符;object 叶子名只保留 URL 安全的 ASCII 字符, - // 唯一性由前缀 uuid 保证。 - let safe_file_name: String = file_name - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { - c - } else { - '_' - } - }) - .collect(); - let object_key = format!( - "{}/{}/{}", - self.config.access_key_id, - uuid::Uuid::new_v4().simple(), - safe_file_name - ); - let date = httpdate::fmt_http_date(std::time::SystemTime::now()); - // OSS V1 头签名(带 STS security token)。 - let string_to_sign = format!( - "PUT\n\n{content_type}\n{date}\nx-oss-security-token:{}\n/{VIAPI_TEMP_BUCKET}/{object_key}", - sts.security_token - ); - let signature = hmac_sha1_base64(sts.access_key_secret.as_bytes(), string_to_sign.as_bytes())?; - let authorization = format!("OSS {}:{}", sts.access_key_id, signature); - let target_url = format!("https://{VIAPI_TEMP_OSS_HOST}/{object_key}"); + let authorized = self.authorize_file_upload().await?; + let upload_host = format!("{}.{}", authorized.bucket, authorized.endpoint); + let target_url = format!("https://{upload_host}"); + let file_part = reqwest::multipart::Part::bytes(bytes) + .file_name(file_name.to_string()) + .mime_str(content_type) + .map_err(|error| { + MattingError::InvalidRequest(format!("上传内容类型不合法:{error}")) + })?; + let form = reqwest::multipart::Form::new() + .text("OSSAccessKeyId", authorized.access_key_id.clone()) + .text("policy", authorized.encoded_policy.clone()) + .text("Signature", authorized.signature.clone()) + .text("key", authorized.object_key.clone()) + .text("success_action_status", "201") + .part("file", file_part); let response = self .client - .put(&target_url) - .header(reqwest::header::CONTENT_TYPE, content_type) - .header(reqwest::header::DATE, &date) - .header("x-oss-security-token", &sts.security_token) - .header(reqwest::header::AUTHORIZATION, &authorization) - .body(bytes) + .post(&target_url) + .multipart(form) .send() .await .map_err(|error| { MattingError::upstream_transport_error( - format!("上传 VIAPI 临时桶请求失败:{error}"), + format!("上传 AuthorizeFileUpload 临时对象请求失败:{error}"), error.is_timeout(), ) })?; let status = response.status(); if !status.is_success() { let body = response.text().await.unwrap_or_default(); - // OSS 签名类错误体会回显 StringToSign(含 x-oss-security-token 明文)、StringToSignBytes - // 和 SignatureProvided;脱敏后再记录,避免 STS 临时凭证进审计元数据与日志。 return Err(MattingError::upstream_http_error( format!( - "上传 VIAPI 临时桶失败(HTTP {}):{}", + "上传 AuthorizeFileUpload 临时对象失败(HTTP {}):{}", status.as_u16(), - sanitize_oss_upload_error_body(&body, &sts.security_token) + sanitize_policy_upload_error_body(&body, &authorized) ), status.as_u16(), )); } - Ok(target_url) + Ok(format!( + "https://{upload_host}/{}", + encode_oss_object_key(&authorized.object_key) + )) } - async fn get_oss_sts_token(&self) -> Result { - let mut form = BTreeMap::new(); - form.insert("Action".to_string(), GET_OSS_STS_TOKEN_ACTION.to_string()); - form.insert("Format".to_string(), "json".to_string()); - form.insert("Version".to_string(), VIAPI_UTILS_VERSION.to_string()); - let payload = build_aliyun_form_body(&form); - let headers = self.build_acs3_headers( - VIAPI_UTILS_ENDPOINT, - GET_OSS_STS_TOKEN_ACTION, - VIAPI_UTILS_VERSION, - &payload, + async fn authorize_file_upload(&self) -> Result { + let canonical_query = format!("Product={IMAGESEG_PRODUCT}"); + let headers = self.build_acs3_headers_for_request( + OPEN_PLATFORM_ENDPOINT, + AUTHORIZE_FILE_UPLOAD_ACTION, + AUTHORIZE_FILE_UPLOAD_VERSION, + "GET", + &canonical_query, + "", )?; let response = self .client - .post(format!("https://{VIAPI_UTILS_ENDPOINT}/")) + .get(format!( + "https://{OPEN_PLATFORM_ENDPOINT}/?{canonical_query}" + )) .headers(headers) - .header( - reqwest::header::CONTENT_TYPE, - "application/x-www-form-urlencoded", - ) - .body(payload) .send() .await .map_err(|error| { MattingError::upstream_transport_error( - format!("GetOssStsToken 请求失败:{error}"), + format!("AuthorizeFileUpload 请求失败:{error}"), error.is_timeout(), ) })?; let http_status = response.status(); let body_text = response.text().await.map_err(|error| { MattingError::upstream_transport_error( - format!("GetOssStsToken 响应读取失败:{error}"), + format!("AuthorizeFileUpload 响应读取失败:{error}"), error.is_timeout(), ) })?; - let body: serde_json::Value = serde_json::from_str(&body_text).map_err(|error| { - MattingError::upstream_response_error( - format!( - "GetOssStsToken 响应不是合法 JSON:{error};原始响应:{}", - truncate_for_log(&body_text) - ), - Some(http_status.as_u16()), - ) - })?; - if http_status != StatusCode::OK { - return Err(MattingError::upstream_http_error( - format!( - "GetOssStsToken 返回失败(HTTP {},Code={}):{}", - http_status.as_u16(), - body.get("Code").and_then(|value| value.as_str()).unwrap_or("unknown"), - body.get("Message") - .and_then(|value| value.as_str()) - .unwrap_or("unknown") - ), - http_status.as_u16(), - )); - } - let data = body.get("Data").ok_or_else(|| { - MattingError::upstream_response_error( - format!( - "GetOssStsToken 响应缺少 Data;原始响应:{}", - truncate_for_log(&body_text) - ), - Some(http_status.as_u16()), - ) - })?; - let read_field = |name: &str| -> Result { - data.get(name) - .and_then(|value| value.as_str()) - .map(|value| value.to_string()) - .ok_or_else(|| { - MattingError::upstream_response_error( - format!("GetOssStsToken 响应缺少 Data.{name}"), - Some(http_status.as_u16()), - ) - }) - }; - Ok(ViapiStsToken { - access_key_id: read_field("AccessKeyId")?, - access_key_secret: read_field("AccessKeySecret")?, - security_token: read_field("SecurityToken")?, - }) + parse_authorized_file_upload_response(&body_text, http_status) } fn build_signature_headers( @@ -674,6 +606,18 @@ impl MattingClient { action: &str, version: &str, payload: &str, + ) -> Result { + self.build_acs3_headers_for_request(endpoint, action, version, "POST", "", payload) + } + + fn build_acs3_headers_for_request( + &self, + endpoint: &str, + action: &str, + version: &str, + method: &str, + canonical_query: &str, + payload: &str, ) -> Result { let date = current_aliyun_timestamp(); let nonce = uuid::Uuid::new_v4().simple().to_string(); @@ -685,8 +629,7 @@ impl MattingClient { let signed_headers = "host;x-acs-action;x-acs-content-sha256;x-acs-date;x-acs-signature-nonce;x-acs-version"; let canonical_request = format!( - "POST\n/\n\n{}\n{}\n{}", - canonical_headers, signed_headers, payload_hash + "{method}\n/\n{canonical_query}\n{canonical_headers}\n{signed_headers}\n{payload_hash}" ); let string_to_sign = format!( "ACS3-HMAC-SHA256\n{}", @@ -808,18 +751,77 @@ fn decode_image_within_limits(bytes: &[u8]) -> Result Result { - use base64::Engine as _; - let mut signer = Hmac::::new_from_slice(key) - .map_err(|error| MattingError::Sign(format!("初始化 OSS V1 签名器失败:{error}")))?; - signer.update(content); - Ok(base64::engine::general_purpose::STANDARD.encode(signer.finalize().into_bytes())) +fn parse_authorized_file_upload_response( + body_text: &str, + http_status: StatusCode, +) -> Result { + // 成功响应包含 Policy 与 Signature;解析错误中不能回显原始正文。 + let body: serde_json::Value = serde_json::from_str(body_text).map_err(|error| { + MattingError::upstream_response_error( + format!("AuthorizeFileUpload 响应不是合法 JSON:{error}"), + Some(http_status.as_u16()), + ) + })?; + if http_status != StatusCode::OK { + return Err(MattingError::upstream_http_error( + format!( + "AuthorizeFileUpload 返回失败(HTTP {},Code={}):{}", + http_status.as_u16(), + body.get("Code") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"), + body.get("Message") + .and_then(|value| value.as_str()) + .unwrap_or("unknown") + ), + http_status.as_u16(), + )); + } + let read_field = |name: &str| -> Result { + body.get(name) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| { + MattingError::upstream_response_error( + format!("AuthorizeFileUpload 响应缺少 {name}"), + Some(http_status.as_u16()), + ) + }) + }; + let bucket = read_field("Bucket")? + .trim_matches('/') + .to_string(); + let endpoint = read_field("Endpoint")? + .trim_start_matches("https://") + .trim_start_matches("http://") + .trim_matches('/') + .to_string(); + if bucket.is_empty() || endpoint.is_empty() { + return Err(MattingError::upstream_response_error( + "AuthorizeFileUpload 响应中的 Bucket/Endpoint 不合法".to_string(), + Some(http_status.as_u16()), + )); + } + Ok(AuthorizedFileUpload { + bucket, + endpoint, + access_key_id: read_field("AccessKeyId")?, + encoded_policy: read_field("EncodedPolicy")?, + signature: read_field("Signature")?, + object_key: read_field("ObjectKey")?, + }) } fn describe_result_download_transport_error(error: &reqwest::Error) -> String { @@ -864,21 +866,38 @@ fn sanitize_result_download_error_message(message: String) -> String { message } -/// 脱敏 OSS 上传错误体:OSS 签名类错误会回显 StringToSign(含 `x-oss-security-token` 明文)、 -/// StringToSignBytes(其十六进制)和 SignatureProvided(签名串)。剥掉这三个元素,并把已知的 -/// STS 临时凭证在正文任何位置的出现替换为 `***`,保留 `` 等可诊断信息后再记录。 -fn sanitize_oss_upload_error_body(body: &str, security_token: &str) -> String { +/// 脱敏 Policy POST 上传错误体:OSS 签名类错误可能回显 StringToSign、Policy 或签名串。 +/// 剥掉已知签名元素,并替换本次授权材料,保留 `` 等可诊断信息。 +fn sanitize_policy_upload_error_body(body: &str, authorized: &AuthorizedFileUpload) -> String { let mut sanitized = body.to_string(); - for tag in ["StringToSign", "StringToSignBytes", "SignatureProvided"] { + for tag in [ + "StringToSign", + "StringToSignBytes", + "SignatureProvided", + "Policy", + ] { sanitized = redact_xml_element(&sanitized, tag); } - let security_token = security_token.trim(); - if !security_token.is_empty() { - sanitized = sanitized.replace(security_token, "***"); + for secret in [ + authorized.access_key_id.as_str(), + authorized.encoded_policy.as_str(), + authorized.signature.as_str(), + ] { + if !secret.is_empty() { + sanitized = sanitized.replace(secret, "***"); + } } truncate_for_log(&sanitized) } +fn encode_oss_object_key(object_key: &str) -> String { + object_key + .split('/') + .map(|segment| urlencoding_encode(segment).replace('+', "%20")) + .collect::>() + .join("/") +} + /// 把 `` 的内容替换成 `[redacted]`(tag 内容可跨行);只有开标签无闭标签时丢弃其后全部内容。 fn redact_xml_element(text: &str, tag: &str) -> String { let open = format!("<{tag}>"); @@ -1092,6 +1111,86 @@ mod tests { assert!(!date.contains('.'), "x-acs-date 不能带小数秒:{date}"); } + #[test] + fn authorize_file_upload_response_parses_policy_fields() { + let response = r#"{ + "RequestId":"request-id", + "Bucket":"viapi-customer-pop", + "Endpoint":"oss-cn-shanghai.aliyuncs.com", + "AccessKeyId":"temporary-key-id", + "EncodedPolicy":"encoded-policy", + "Signature":"policy-signature", + "ObjectKey":"imageseg/2026/07/input image.png" + }"#; + + let authorized = parse_authorized_file_upload_response(response, StatusCode::OK) + .expect("AuthorizeFileUpload response should parse"); + + assert_eq!(authorized.bucket, "viapi-customer-pop"); + assert_eq!(authorized.endpoint, "oss-cn-shanghai.aliyuncs.com"); + assert_eq!(authorized.access_key_id, "temporary-key-id"); + assert_eq!(authorized.encoded_policy, "encoded-policy"); + assert_eq!(authorized.signature, "policy-signature"); + assert_eq!(authorized.object_key, "imageseg/2026/07/input image.png"); + assert_eq!( + encode_oss_object_key(&authorized.object_key), + "imageseg/2026/07/input%20image.png" + ); + } + + #[test] + fn authorize_file_upload_response_does_not_echo_sensitive_body_on_parse_error() { + let sensitive_body = "not-json encoded-policy policy-signature temporary-key-id"; + + let error = parse_authorized_file_upload_response(sensitive_body, StatusCode::OK) + .expect_err("invalid response should fail"); + + assert!(error.message().contains("响应不是合法 JSON")); + assert!(!error.message().contains("encoded-policy")); + assert!(!error.message().contains("policy-signature")); + assert!(!error.message().contains("temporary-key-id")); + } + + #[test] + fn authorize_file_upload_headers_sign_get_query() { + let config = MattingConfig::new( + DEFAULT_IMAGESEG_ENDPOINT.to_string(), + "test-key-id".to_string(), + "test-key-secret".to_string(), + ) + .expect("config should build"); + let client = MattingClient::new(config).expect("client should build"); + let headers = client + .build_acs3_headers_for_request( + OPEN_PLATFORM_ENDPOINT, + AUTHORIZE_FILE_UPLOAD_ACTION, + AUTHORIZE_FILE_UPLOAD_VERSION, + "GET", + "Product=imageseg", + "", + ) + .expect("headers should build"); + + assert_eq!( + headers + .get("x-acs-action") + .and_then(|value| value.to_str().ok()), + Some(AUTHORIZE_FILE_UPLOAD_ACTION) + ); + assert_eq!( + headers + .get("x-acs-version") + .and_then(|value| value.to_str().ok()), + Some(AUTHORIZE_FILE_UPLOAD_VERSION) + ); + assert_eq!( + headers + .get("x-acs-content-sha256") + .and_then(|value| value.to_str().ok()), + Some(sha256_hex(b"").as_str()) + ); + } + #[test] fn result_download_error_message_redacts_signed_oss_url() { let message = sanitize_result_download_error_message( @@ -1117,24 +1216,35 @@ mod tests { } #[test] - fn oss_upload_error_body_redacts_signature_material_and_token() { - let token = "CAISabcSECRETtoken123"; + fn policy_upload_error_body_redacts_authorization_material() { + let authorized = AuthorizedFileUpload { + bucket: "viapi-customer-pop".to_string(), + endpoint: "oss-cn-shanghai.aliyuncs.com".to_string(), + access_key_id: "temporary-key-id".to_string(), + encoded_policy: "encoded-policy".to_string(), + signature: "policy-signature".to_string(), + object_key: "imageseg/input.png".to_string(), + }; let body = format!( "SignatureDoesNotMatch\ - mismatch for {token}\ - sigSECRET==\ + mismatch for {} and {}\ + {}\ + {}\ 50 55 54 0a\ - PUT\nx-oss-security-token:{token}\n/bucket/key" + POST\npolicy={}", + authorized.access_key_id, + authorized.signature, + authorized.encoded_policy, + authorized.signature, + authorized.encoded_policy ); - let sanitized = sanitize_oss_upload_error_body(&body, token); + let sanitized = sanitize_policy_upload_error_body(&body, &authorized); - // 敏感串全部消失:StringToSign 明文、其中的 token、十六进制、签名串、以及 Message 里的 token 明文。 - assert!(!sanitized.contains(token), "STS token 不能残留(含元素外的明文)"); - assert!(!sanitized.contains("x-oss-security-token:CAIS"), "StringToSign 明文不能残留"); - assert!(!sanitized.contains("sigSECRET"), "SignatureProvided 不能残留"); + assert!(!sanitized.contains(&authorized.access_key_id)); + assert!(!sanitized.contains(&authorized.encoded_policy)); + assert!(!sanitized.contains(&authorized.signature)); assert!(!sanitized.contains("50 55 54 0a"), "StringToSignBytes 不能残留"); - // 可诊断信息保留。 assert!(sanitized.contains("SignatureDoesNotMatch"), "OSS Code 应保留供诊断"); assert!(sanitized.contains("[redacted]"), "签名材料元素应被脱敏为 [redacted]"); }