修复生成图片OSS签名缓存链路
前端将完整阿里云OSS generated 地址归一为 legacy path 后走 read-url 换签。 platform-oss 为 generated 私有对象 PostObject 和 PutObject 写入 immutable Cache-Control。 补齐 shared-contracts 与 api-server 直传票据字段映射。 更新后端、运维文档和 Hermes 团队记忆,明确不使用服务端磁盘缓存兜底。
This commit is contained in:
@@ -16,6 +16,14 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-06-07 generated 图片读取坚持 OSS 源站与签名缓存链路
|
||||||
|
|
||||||
|
- 背景:生成图片如果以完整 OSS 私有 bucket URL 进入前端,浏览器会裸连 OSS 并遇到 403 或绕过现有 `/api/assets/read-url` 签名缓存;同时旧对象缺少 `Cache-Control` 时只能走 `ETag` / `Last-Modified` 协商缓存,容易被误解为需要 api-server 本地磁盘缓存。
|
||||||
|
- 决策:OSS 继续作为 generated 私有资产源站,api-server 只签发短期读 URL,不做本地磁盘静态资源兜底。前端收到同 bucket 的 `https://*.oss-*.aliyuncs.com/generated-*` 地址时,必须先归一为 legacy public path,再复用 `/api/assets/read-url` 和本地 signed URL 缓存。新上传 generated 私有对象默认写入 `Cache-Control: public, max-age=31536000, immutable`,缓存职责交给 OSS 对象头、浏览器 / WebView HTTP 缓存和后续 CDN。
|
||||||
|
- 影响范围:`src/services/assetReadUrlService.ts`、`server-rs/crates/platform-oss`、`shared-contracts` direct upload form fields、`api-server` assets DTO 映射、后端契约文档和开发运维排障口径。
|
||||||
|
- 验证方式:完整 OSS generated URL 应触发 `/api/assets/read-url?legacyPublicPath=...`,同一路径在签名有效期内复用本地 signed URL;`platform-oss` 的 `PostObject` policy / form fields 和 `PutObject` 请求头都应包含 immutable `Cache-Control`,且 `PutObject` V4 签名的 `AdditionalHeaders` 包含该普通请求头。
|
||||||
|
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`server-rs/crates/platform-oss/README.md`。
|
||||||
|
|
||||||
## 2026-06-06 小程序微信绑定展示使用原生昵称组件
|
## 2026-06-06 小程序微信绑定展示使用原生昵称组件
|
||||||
|
|
||||||
- 背景:账号信息面板需要显示“绑定的是哪个微信号”。微信小程序登录 `jscode2session` 不返回昵称或个人微信号,但小程序提供 `input type="nickname"` 原生昵称填写 / 选择能力,可在登录前收集微信昵称用于展示。
|
- 背景:账号信息面板需要显示“绑定的是哪个微信号”。微信小程序登录 `jscode2session` 不返回昵称或个人微信号,但小程序提供 `input type="nickname"` 原生昵称填写 / 选择能力,可在登录前收集微信昵称用于展示。
|
||||||
|
|||||||
@@ -15,6 +15,14 @@
|
|||||||
- 关联:相关文件、文档、提交或 Issue
|
- 关联:相关文件、文档、提交或 Issue
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## generated 图片重复下载不要改成服务端本地磁盘缓存
|
||||||
|
|
||||||
|
- 现象:同一张 OSS generated 图片每次展示都重新从 OSS 拉取,或者完整 OSS 私有 URL 裸请求返回 403。
|
||||||
|
- 原因:前端输入如果是 `https://*.oss-*.aliyuncs.com/generated-*`,会被当普通绝对 URL 直连,绕过 `/api/assets/read-url` 和 signed URL 本地缓存;旧 OSS 对象如果缺少 `Cache-Control`,浏览器只能依赖 `ETag` / `Last-Modified` 做 304 协商缓存,不会长期强缓存。
|
||||||
|
- 处理:完整 OSS generated URL 先归一成 `/generated-*` legacy public path,再走 `/api/assets/read-url` 换签;新上传 generated 私有对象由 `platform-oss` 在 `PostObject` form fields / policy 和服务端 `PutObject` 请求头中写入 `Cache-Control: public, max-age=31536000, immutable`。不要把 api-server 变成图片静态代理,也不要把 OSS 内容 fallback 到服务器磁盘。
|
||||||
|
- 验证:前端测试应看到完整 OSS generated URL 调用 `/api/assets/read-url?legacyPublicPath=...`;`cargo test -p platform-oss --manifest-path server-rs/Cargo.toml` 应覆盖 `Cache-Control` policy、form field、PutObject headers 和 V4 `AdditionalHeaders`;线上旧对象可用 `curl -I` 观察是否只有 `ETag` / `Last-Modified` 或已经补齐 `Cache-Control`。
|
||||||
|
- 关联:`src/services/assetReadUrlService.ts`、`server-rs/crates/platform-oss/src/lib.rs`、`server-rs/crates/platform-oss/README.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
## 小程序 H5 导航不能清掉宿主 query
|
## 小程序 H5 导航不能清掉宿主 query
|
||||||
|
|
||||||
- 现象:微信小程序首次进入 H5 后,点击需要登录的入口没有返回小程序原生授权页,而是弹出 Web 端登录窗口;充值渠道也可能被误判为普通网页环境。
|
- 现象:微信小程序首次进入 H5 后,点击需要登录的入口没有返回小程序原生授权页,而是弹出 Web 端登录窗口;充值渠道也可能被误判为普通网页环境。
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ npm run check:server-rs-ddd
|
|||||||
3. Adapter 输出应保留 legacy public path、object key、asset object id、MIME、extension、task id 和实际 prompt。
|
3. Adapter 输出应保留 legacy public path、object key、asset object id、MIME、extension、task id 和实际 prompt。
|
||||||
4. Adapter 不负责扣费、退款或钱包读取;计费仍由调用方显式包裹。
|
4. Adapter 不负责扣费、退款或钱包读取;计费仍由调用方显式包裹。
|
||||||
5. 图片 provider 协议不再放在玩法模块里实现。VectorEngine `gpt-image-2` 创建 / 编辑协议、URL / base64 图片解析、远端图片下载、请求超时 / 上游状态 / 响应解析 / 缺图 / 下载失败的结构化日志统一在 `server-rs/crates/platform-image/src/vector_engine/`;其中 `client.rs` 只保留 provider 调用编排,`transport.rs` 负责 HTTP client 与 reqwest 错误归一,`request.rs` 负责请求体和路径,`payload.rs` 负责响应 JSON 字段提取,`response.rs` 负责响应状态分流和图片结果归一。`api-server` 只负责配置校验、玩法 prompt 编排、OSS / asset object / binding 持久化、计费和外部 API 失败审计落库。
|
5. 图片 provider 协议不再放在玩法模块里实现。VectorEngine `gpt-image-2` 创建 / 编辑协议、URL / base64 图片解析、远端图片下载、请求超时 / 上游状态 / 响应解析 / 缺图 / 下载失败的结构化日志统一在 `server-rs/crates/platform-image/src/vector_engine/`;其中 `client.rs` 只保留 provider 调用编排,`transport.rs` 负责 HTTP client 与 reqwest 错误归一,`request.rs` 负责请求体和路径,`payload.rs` 负责响应 JSON 字段提取,`response.rs` 负责响应状态分流和图片结果归一。`api-server` 只负责配置校验、玩法 prompt 编排、OSS / asset object / binding 持久化、计费和外部 API 失败审计落库。
|
||||||
6. OSS 平台适配日志统一在 `server-rs/crates/platform-oss` 输出,覆盖 `sign_post_object`、`sign_get_object_url`、`head_object` 和 `put_object`。日志字段固定使用 `provider`、`operation`、`bucket`、`endpoint`、`object_key` / `key_prefix`、`access`、`content_type`、`content_length`、`status`、`status_class`、`error_kind` 和 `elapsed_ms`,只记录对象定位和排障信息;不得输出 AccessKey、policy、signature、Authorization header 或完整 signed URL。
|
6. OSS 平台适配日志统一在 `server-rs/crates/platform-oss` 输出,覆盖 `sign_post_object`、`sign_get_object_url`、`head_object` 和 `put_object`。日志字段固定使用 `provider`、`operation`、`bucket`、`endpoint`、`object_key` / `key_prefix`、`access`、`content_type`、`content_length`、`status`、`status_class`、`error_kind` 和 `elapsed_ms`,只记录对象定位和排障信息;不得输出 AccessKey、policy、signature、Authorization header 或完整 signed URL。generated 私有对象上传时必须由 OSS 对象头承载浏览器 / CDN 缓存策略,默认写入 `Cache-Control: public, max-age=31536000, immutable`,不得改成 api-server 本地磁盘静态资源兜底。
|
||||||
7. Puzzle、Match3D、音频、GLB、视频等复杂媒体可以复用 OSS + asset object + binding 的底层持久化能力,但玩法专属处理规则留在各自编排层,不塞进公共接口。
|
7. Puzzle、Match3D、音频、GLB、视频等复杂媒体可以复用 OSS + asset object + binding 的底层持久化能力,但玩法专属处理规则留在各自编排层,不塞进公共接口。
|
||||||
8. 拼图入口页与结果页新增关卡的本地参考图不走浏览器直传 OSS,前端读取为 Data URL 后随创作 action 提交,并在读取前限制 6MB、显示“图片≤6MB”。`api-server` 必须对 Data URL 实际字节数再次校验;历史图片才提交 `referenceImageAssetObjectId(s)`,后端校验 `asset_object` 的 bucket、kind、图片 MIME、大小和 owner 后签发只读 URL 给 VectorEngine 读取。
|
8. 拼图入口页与结果页新增关卡的本地参考图不走浏览器直传 OSS,前端读取为 Data URL 后随创作 action 提交,并在读取前限制 6MB、显示“图片≤6MB”。`api-server` 必须对 Data URL 实际字节数再次校验;历史图片才提交 `referenceImageAssetObjectId(s)`,后端校验 `asset_object` 的 bucket、kind、图片 MIME、大小和 owner 后签发只读 URL 给 VectorEngine 读取。
|
||||||
9. 系列素材图集实现真相源在 `server-rs/crates/platform-image/src/generated_asset_sheets/`:调用方必须传入 `grid_size` 作为 `n*n` 的 `n`,可选传入物品名称 prompt 模板和特殊设定 prompt;模块负责 sheet prompt 组装、按 `n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造和 sheet / item / special prompt 元数据持久化。`server-rs/crates/api-server/src/generated_asset_sheets.rs` 只保留 `AppState` / `AppError` 适配和兼容导出。玩法只负责规划 slot、调用具体生图 provider、计费、失败回写,以及把通用切片结果映射回自己的 DTO / 草稿 / runtime 字段。
|
9. 系列素材图集实现真相源在 `server-rs/crates/platform-image/src/generated_asset_sheets/`:调用方必须传入 `grid_size` 作为 `n*n` 的 `n`,可选传入物品名称 prompt 模板和特殊设定 prompt;模块负责 sheet prompt 组装、按 `n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造和 sheet / item / special prompt 元数据持久化。`server-rs/crates/api-server/src/generated_asset_sheets.rs` 只保留 `AppState` / `AppError` 适配和兼容导出。玩法只负责规划 slot、调用具体生图 provider、计费、失败回写,以及把通用切片结果映射回自己的 DTO / 草稿 / runtime 字段。
|
||||||
@@ -176,7 +176,7 @@ npm run check:server-rs-ddd
|
|||||||
- 敲木鱼敲击物和背景环境图:VectorEngine `/v1/images/edits`,模型固定 `gpt-image-2`。敲击物支持 multipart 多参考图,第一张固定为后端内嵌默认木鱼图,用户上传图只作为新主题参考;prompt 必须要求 `1:1` 真实透明 alpha PNG 并禁止黑底、白底、棋盘格和任何实底背景。当前敲击物上传 OSS 前不做服务端抠图后处理,避免误伤玉米等主体像素。背景环境图只使用第一步抠图完成后的透明敲击物图作为参考,prompt 必须要求中央主体预留区保持干净,中央 40% 区域禁止出现主题主体、主体局部特写、轮廓影子或重复元素,主题元素只能作为外围氛围,且必须显式声明不继承任何绿色底色、绿幕底色或纯绿色画布。
|
- 敲木鱼敲击物和背景环境图:VectorEngine `/v1/images/edits`,模型固定 `gpt-image-2`。敲击物支持 multipart 多参考图,第一张固定为后端内嵌默认木鱼图,用户上传图只作为新主题参考;prompt 必须要求 `1:1` 真实透明 alpha PNG 并禁止黑底、白底、棋盘格和任何实底背景。当前敲击物上传 OSS 前不做服务端抠图后处理,避免误伤玉米等主体像素。背景环境图只使用第一步抠图完成后的透明敲击物图作为参考,prompt 必须要求中央主体预留区保持干净,中央 40% 区域禁止出现主题主体、主体局部特写、轮廓影子或重复元素,主题元素只能作为外围氛围,且必须显式声明不继承任何绿色底色、绿幕底色或纯绿色画布。
|
||||||
- Hyper3D / Rodin:只保留后端安全代理和旧数据兼容;Rodin 提交、状态、下载和响应解析归属 `platform-hyper3d`,`api-server/src/hyper3d_generation.rs` 只做路由、配置和错误 envelope 映射;新 Match3D 草稿和批量新增不再生成 GLB。
|
- 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`。
|
- 音频:视觉小说专用音频路由保留;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 legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`。OSS 签名、读签名、HEAD 和 PUT 的结构化日志由 `platform-oss` 输出,排查资产写入 / 确认失败时优先按 `operation`、`object_key` / `key_prefix`、`status_class`、`error_kind` 和 `elapsed_ms` 下钻。
|
- OSS:私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`。前端如果收到同一 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 静态代理。
|
||||||
- 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。VectorEngine 图片 provider 在 `platform-image` 内输出结构化日志和 `PlatformImageFailureAudit`,覆盖 `request_send`、`response_body`、`upstream_status`、`response_parse`、`missing_image` 和 `image_download` 阶段;`api-server` 只把该 audit 映射成 `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` 阶段;`api-server` 只把该 audit 映射成 `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。
|
||||||
- 外部生成运行记录:所有外部生成编排的完成态统一写入 `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` 旧表。
|
- 外部生成运行记录:所有外部生成编排的完成态统一写入 `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` 旧表。
|
||||||
|
|
||||||
|
|||||||
@@ -300,7 +300,7 @@ OpenTelemetry 现阶段默认开启 OTLP 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。
|
- 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 字节数。
|
- 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 字节数。
|
||||||
- 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine `gpt-image-2` 图片生成 / 编辑失败由 `platform-image` provider 输出结构化日志字段,字段包括 provider、endpoint、failure_stage、status、source、source_chain、source_chain_depth、timeout、retryable、latency_ms、prompt_chars、reference_image_count、image_model、request_params 和 raw_excerpt;图片编辑请求参数日志还会带 reference_image_bytes_total,并在 request_params.referenceImages 中记录每个 multipart `image` part 的 fileName、mimeType 和 bytes,不记录 API key 或原始图片 bytes;`api-server` 再记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,并写入 `tracking_event`,`event_key = external_api_call_failure`、`module_key = external-api`、`scope_kind = module`、`scope_id = provider`。调用方能拿到身份上下文时,失败事件还会在行级 `user_id` / `owner_user_id` / `profile_id` 和 `metadata_json.userId` / `metadata_json.profileId` / `metadata_json.requestId` / `metadata_json.errorSource` 中记录触发者、草稿 / 作品作用域、请求标识和传输错误链。排障时先按 provider / failureStage 聚合,再下钻 userId / profileId,最后结合 request 日志、errorSource 和上游响应 excerpt 判断是限流、超时、解析失败还是未返回图片。
|
- 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine `gpt-image-2` 图片生成 / 编辑失败由 `platform-image` provider 输出结构化日志字段,字段包括 provider、endpoint、failure_stage、status、source、source_chain、source_chain_depth、timeout、retryable、latency_ms、prompt_chars、reference_image_count、image_model、request_params 和 raw_excerpt;图片编辑请求参数日志还会带 reference_image_bytes_total,并在 request_params.referenceImages 中记录每个 multipart `image` part 的 fileName、mimeType 和 bytes,不记录 API key 或原始图片 bytes;`api-server` 再记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,并写入 `tracking_event`,`event_key = external_api_call_failure`、`module_key = external-api`、`scope_kind = module`、`scope_id = provider`。调用方能拿到身份上下文时,失败事件还会在行级 `user_id` / `owner_user_id` / `profile_id` 和 `metadata_json.userId` / `metadata_json.profileId` / `metadata_json.requestId` / `metadata_json.errorSource` 中记录触发者、草稿 / 作品作用域、请求标识和传输错误链。排障时先按 provider / failureStage 聚合,再下钻 userId / profileId,最后结合 request 日志、errorSource 和上游响应 excerpt 判断是限流、超时、解析失败还是未返回图片。
|
||||||
- OSS 平台适配器也输出结构化日志,覆盖 `sign_post_object`、`sign_get_object_url`、`head_object` 和 `put_object`。排查资产签名、上传或确认失败时,先按 `provider=aliyun-oss` 与 `operation` 过滤,再看 `object_key` / `key_prefix`、`status`、`status_class`、`error_kind`、`content_length`、`content_type` 和 `elapsed_ms`;日志不得包含 AccessKey、policy、signature、Authorization header 或完整 signed URL。
|
- OSS 平台适配器也输出结构化日志,覆盖 `sign_post_object`、`sign_get_object_url`、`head_object` 和 `put_object`。排查资产签名、上传或确认失败时,先按 `provider=aliyun-oss` 与 `operation` 过滤,再看 `object_key` / `key_prefix`、`status`、`status_class`、`error_kind`、`content_length`、`content_type` 和 `elapsed_ms`;日志不得包含 AccessKey、policy、signature、Authorization header 或完整 signed URL。排查 generated 图片重复下载时,先确认前端输入是否为 `/generated-*` legacy path 或可归一化的 `https://*.oss-*.aliyuncs.com/generated-*`;正确链路应先调 `/api/assets/read-url`,再由浏览器请求 signed URL。新上传 generated 私有对象应带 `Cache-Control: public, max-age=31536000, immutable`;旧对象若只有 `ETag` / `Last-Modified`,浏览器会走 304 协商缓存而不是长期强缓存,可通过刷新 OSS 元数据或 CDN 配置补齐。
|
||||||
- SpacetimeDB 观测分为两类:procedure / reducer 调用继续用 `genarrative.spacetime.procedure.*`,订阅本地 cache 读使用 `genarrative.spacetime.read.*`。`read=list_puzzle_gallery` 表示拼图广场当前从 `puzzle_gallery_card_view` 本地 cache 读取,不再每个 HTTP 请求调用 `list_puzzle_gallery` procedure。
|
- SpacetimeDB 观测分为两类:procedure / reducer 调用继续用 `genarrative.spacetime.procedure.*`,订阅本地 cache 读使用 `genarrative.spacetime.read.*`。`read=list_puzzle_gallery` 表示拼图广场当前从 `puzzle_gallery_card_view` 本地 cache 读取,不再每个 HTTP 请求调用 `list_puzzle_gallery` procedure。
|
||||||
- 本地 Windows 直连压测的内存高水位要结合 K6 VU / 连接数解释。250 RPS 下过高 `PREALLOCATED_VUS` 可能让 300 个本地 Established 连接把 `api-server` private memory 瞬时推到 GB 级,且 `/healthz` 小响应也能复现;若压测结束后回落、`response_bodies.in_flight` 和背压 permit 未显示业务积压,应优先按连接 / 发送链路高水位处理,而不是判断为 SpacetimeDB 或 JSON 缓存泄漏。
|
- 本地 Windows 直连压测的内存高水位要结合 K6 VU / 连接数解释。250 RPS 下过高 `PREALLOCATED_VUS` 可能让 300 个本地 Established 连接把 `api-server` private memory 瞬时推到 GB 级,且 `/healthz` 小响应也能复现;若压测结束后回落、`response_bodies.in_flight` 和背压 permit 未显示业务积压,应优先按连接 / 发送链路高水位处理,而不是判断为 SpacetimeDB 或 JSON 缓存泄漏。
|
||||||
- Rider 的 Logs 面板只展示 log event 自身字段,不会自动展开父 span 的全部 attributes;请求完成日志会直接带 `request_id`、`http.request.method`、`http.route`、`url.scheme`、`url.path`、`http.response.status_code`、`status_class`、`latency_ms` 和 `slow_request`,完整链路继续到 Traces 面板按 trace/span 查看。
|
- Rider 的 Logs 面板只展示 log event 自身字段,不会自动展开父 span 的全部 attributes;请求完成日志会直接带 `request_id`、`http.request.method`、`http.route`、`url.scheme`、`url.path`、`http.response.status_code`、`status_class`、`latency_ms` 和 `slow_request`,完整链路继续到 Traces 面板按 trace/span 查看。
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ fn direct_upload_ticket_form_fields_from_oss(
|
|||||||
signature: value.signature,
|
signature: value.signature,
|
||||||
success_action_status: value.success_action_status,
|
success_action_status: value.success_action_status,
|
||||||
content_type: value.content_type,
|
content_type: value.content_type,
|
||||||
|
cache_control: value.cache_control,
|
||||||
metadata: value.metadata,
|
metadata: value.metadata,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
6. `x-oss-meta-*` 元数据归一化与大小限制校验
|
6. `x-oss-meta-*` 元数据归一化与大小限制校验
|
||||||
7. `content-type`、`content-length-range`、`success_action_status` policy 条件生成
|
7. `content-type`、`content-length-range`、`success_action_status` policy 条件生成
|
||||||
8. `PostObject` 签名、`GetObject` 读签名、`HEAD Object` 和 `PutObject` 的结构化日志
|
8. `PostObject` 签名、`GetObject` 读签名、`HEAD Object` 和 `PutObject` 的结构化日志
|
||||||
|
9. generated 私有对象上传默认写入 `Cache-Control: public, max-age=31536000, immutable`
|
||||||
|
|
||||||
当前仍未落地的内容:
|
当前仍未落地的内容:
|
||||||
|
|
||||||
@@ -38,6 +39,8 @@
|
|||||||
4. 读签名和 `HEAD Object` 的入参必须直接传 object_key,不要把 bucket 名拼进路径;例如 `generated-square-hole-assets/.../image.png` 才是正确入参,`xushi-dev/...` 这类前缀不属于 object_key。
|
4. 读签名和 `HEAD Object` 的入参必须直接传 object_key,不要把 bucket 名拼进路径;例如 `generated-square-hole-assets/.../image.png` 才是正确入参,`xushi-dev/...` 这类前缀不属于 object_key。
|
||||||
5. OSS V4 `x-oss-date` 必须固定为 `yyyyMMdd'T'HHmmss'Z'`,不能依赖 `time::Time::to_string()`;后者在小时小于 10 时可能输出非补零时间,导致签名格式错误。
|
5. OSS V4 `x-oss-date` 必须固定为 `yyyyMMdd'T'HHmmss'Z'`,不能依赖 `time::Time::to_string()`;后者在小时小于 10 时可能输出非补零时间,导致签名格式错误。
|
||||||
6. 结构化日志只记录 `provider`、`operation`、`bucket`、`endpoint`、`object_key` / `key_prefix`、`access`、`content_type`、`content_length`、`status`、`status_class`、`error_kind` 和 `elapsed_ms` 等排障字段;禁止输出 AccessKey、policy、signature、Authorization header 或完整 signed URL。
|
6. 结构化日志只记录 `provider`、`operation`、`bucket`、`endpoint`、`object_key` / `key_prefix`、`access`、`content_type`、`content_length`、`status`、`status_class`、`error_kind` 和 `elapsed_ms` 等排障字段;禁止输出 AccessKey、policy、signature、Authorization header 或完整 signed URL。
|
||||||
|
7. 完整 OSS URL 不能当作 object key 传入签名接口;前端收到 `https://*.oss-*.aliyuncs.com/generated-*` 时应先归一为 legacy public path,再通过 `/api/assets/read-url` 换取短期 signed URL。
|
||||||
|
8. generated 资源缓存的主路径是 OSS 对象头、浏览器 / WebView HTTP 缓存和后续 CDN,不允许改成 api-server 本地磁盘静态资源兜底。
|
||||||
|
|
||||||
## 3. 边界约束
|
## 3. 边界约束
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ pub const DEFAULT_READ_EXPIRE_SECONDS: u64 = 10 * 60;
|
|||||||
pub const DEFAULT_POST_MAX_SIZE_BYTES: u64 = 20 * 1024 * 1024;
|
pub const DEFAULT_POST_MAX_SIZE_BYTES: u64 = 20 * 1024 * 1024;
|
||||||
pub const DEFAULT_SUCCESS_ACTION_STATUS: u16 = 200;
|
pub const DEFAULT_SUCCESS_ACTION_STATUS: u16 = 200;
|
||||||
pub const DEFAULT_METADATA_TOTAL_BYTES_LIMIT: usize = 8 * 1024;
|
pub const DEFAULT_METADATA_TOTAL_BYTES_LIMIT: usize = 8 * 1024;
|
||||||
|
pub const DEFAULT_IMMUTABLE_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
|
||||||
const OSS_V4_ALGORITHM: &str = "OSS4-HMAC-SHA256";
|
const OSS_V4_ALGORITHM: &str = "OSS4-HMAC-SHA256";
|
||||||
const OSS_V4_REQUEST: &str = "aliyun_v4_request";
|
const OSS_V4_REQUEST: &str = "aliyun_v4_request";
|
||||||
const OSS_V4_SERVICE: &str = "oss";
|
const OSS_V4_SERVICE: &str = "oss";
|
||||||
@@ -199,6 +200,8 @@ pub struct OssPostObjectFormFields {
|
|||||||
pub success_action_status: String,
|
pub success_action_status: String,
|
||||||
#[serde(rename = "Content-Type", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "Content-Type", skip_serializing_if = "Option::is_none")]
|
||||||
pub content_type: Option<String>,
|
pub content_type: Option<String>,
|
||||||
|
#[serde(rename = "Cache-Control", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cache_control: Option<String>,
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub metadata: BTreeMap<String, String>,
|
pub metadata: BTreeMap<String, String>,
|
||||||
}
|
}
|
||||||
@@ -425,6 +428,7 @@ impl OssClient {
|
|||||||
let legacy_public_path = format!("/{}", object_key);
|
let legacy_public_path = format!("/{}", object_key);
|
||||||
let content_type = normalize_optional_value(request.content_type);
|
let content_type = normalize_optional_value(request.content_type);
|
||||||
let metadata = normalize_metadata(request.metadata)?;
|
let metadata = normalize_metadata(request.metadata)?;
|
||||||
|
let cache_control = Some(DEFAULT_IMMUTABLE_CACHE_CONTROL.to_string());
|
||||||
|
|
||||||
let expires_at = OffsetDateTime::now_utc()
|
let expires_at = OffsetDateTime::now_utc()
|
||||||
.checked_add(Duration::seconds(i64::try_from(expire_seconds).map_err(
|
.checked_add(Duration::seconds(i64::try_from(expire_seconds).map_err(
|
||||||
@@ -448,6 +452,7 @@ impl OssClient {
|
|||||||
max_size_bytes,
|
max_size_bytes,
|
||||||
success_action_status,
|
success_action_status,
|
||||||
content_type.as_deref(),
|
content_type.as_deref(),
|
||||||
|
cache_control.as_deref(),
|
||||||
&metadata,
|
&metadata,
|
||||||
&credential,
|
&credential,
|
||||||
&signature_date,
|
&signature_date,
|
||||||
@@ -485,6 +490,7 @@ impl OssClient {
|
|||||||
signature,
|
signature,
|
||||||
success_action_status: success_action_status.to_string(),
|
success_action_status: success_action_status.to_string(),
|
||||||
content_type,
|
content_type,
|
||||||
|
cache_control,
|
||||||
metadata,
|
metadata,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -788,7 +794,7 @@ impl OssClient {
|
|||||||
let file_name = sanitize_file_name(&request.file_name)?;
|
let file_name = sanitize_file_name(&request.file_name)?;
|
||||||
let object_key = build_object_key(request.prefix, &sanitized_segments, &file_name);
|
let object_key = build_object_key(request.prefix, &sanitized_segments, &file_name);
|
||||||
let content_type = normalize_optional_value(request.content_type);
|
let content_type = normalize_optional_value(request.content_type);
|
||||||
let metadata = normalize_metadata(request.metadata)?;
|
let headers = build_put_object_headers(request.metadata)?;
|
||||||
let target_url =
|
let target_url =
|
||||||
build_object_url(&self.config.bucket, &self.config.endpoint, &object_key).map_err(
|
build_object_url(&self.config.bucket, &self.config.endpoint, &object_key).map_err(
|
||||||
|error| OssError::Request(format!("构造 OSS 对象 URL 失败:{error}")),
|
|error| OssError::Request(format!("构造 OSS 对象 URL 失败:{error}")),
|
||||||
@@ -802,7 +808,7 @@ impl OssClient {
|
|||||||
Some(&object_key),
|
Some(&object_key),
|
||||||
target_url,
|
target_url,
|
||||||
content_type.as_deref(),
|
content_type.as_deref(),
|
||||||
&metadata,
|
&headers,
|
||||||
)?
|
)?
|
||||||
.header(reqwest::header::CONTENT_LENGTH, content_length)
|
.header(reqwest::header::CONTENT_LENGTH, content_length)
|
||||||
.body(request.body);
|
.body(request.body);
|
||||||
@@ -957,6 +963,7 @@ fn build_policy_json(
|
|||||||
max_size_bytes: u64,
|
max_size_bytes: u64,
|
||||||
success_action_status: u16,
|
success_action_status: u16,
|
||||||
content_type: Option<&str>,
|
content_type: Option<&str>,
|
||||||
|
cache_control: Option<&str>,
|
||||||
metadata: &BTreeMap<String, String>,
|
metadata: &BTreeMap<String, String>,
|
||||||
credential: &str,
|
credential: &str,
|
||||||
signature_date: &str,
|
signature_date: &str,
|
||||||
@@ -979,6 +986,10 @@ fn build_policy_json(
|
|||||||
conditions.push(json!(["eq", "$content-type", content_type]));
|
conditions.push(json!(["eq", "$content-type", content_type]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(cache_control) = cache_control {
|
||||||
|
conditions.push(json!(["eq", "$Cache-Control", cache_control]));
|
||||||
|
}
|
||||||
|
|
||||||
for (key, value) in metadata {
|
for (key, value) in metadata {
|
||||||
conditions.push(json!(["eq", format!("${key}"), value]));
|
conditions.push(json!(["eq", format!("${key}"), value]));
|
||||||
}
|
}
|
||||||
@@ -1089,6 +1100,18 @@ fn normalize_metadata(
|
|||||||
Ok(normalized)
|
Ok(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_put_object_headers(
|
||||||
|
metadata: BTreeMap<String, String>,
|
||||||
|
) -> Result<BTreeMap<String, String>, OssError> {
|
||||||
|
// 中文注释:生成资产 object key 含会话与 asset id,内容不可变,适合交给浏览器/CDN 长缓存。
|
||||||
|
let mut headers = BTreeMap::from([(
|
||||||
|
"Cache-Control".to_string(),
|
||||||
|
DEFAULT_IMMUTABLE_CACHE_CONTROL.to_string(),
|
||||||
|
)]);
|
||||||
|
headers.extend(normalize_metadata(metadata)?);
|
||||||
|
Ok(headers)
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_metadata_key(raw: &str) -> String {
|
fn normalize_metadata_key(raw: &str) -> String {
|
||||||
let stripped = raw
|
let stripped = raw
|
||||||
.trim()
|
.trim()
|
||||||
@@ -1283,13 +1306,13 @@ fn signed_request_builder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let canonical_headers = build_v4_canonical_headers(&signed_headers);
|
let canonical_headers = build_v4_canonical_headers(&signed_headers);
|
||||||
let additional_headers = "host";
|
let additional_headers = build_v4_additional_headers(&signed_headers);
|
||||||
let canonical_request = build_v4_canonical_request(
|
let canonical_request = build_v4_canonical_request(
|
||||||
method.as_str(),
|
method.as_str(),
|
||||||
&canonical_uri,
|
&canonical_uri,
|
||||||
"",
|
"",
|
||||||
&canonical_headers,
|
&canonical_headers,
|
||||||
additional_headers,
|
&additional_headers,
|
||||||
&body_sha256,
|
&body_sha256,
|
||||||
);
|
);
|
||||||
let string_to_sign =
|
let string_to_sign =
|
||||||
@@ -1468,6 +1491,16 @@ fn build_v4_canonical_headers(headers: &BTreeMap<String, String>) -> String {
|
|||||||
.collect::<String>()
|
.collect::<String>()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_v4_additional_headers(headers: &BTreeMap<String, String>) -> String {
|
||||||
|
let mut additional_headers = headers
|
||||||
|
.keys()
|
||||||
|
.map(|key| key.to_ascii_lowercase())
|
||||||
|
.filter(|key| key != "content-type" && key != "content-md5" && !key.starts_with("x-oss-"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
additional_headers.sort();
|
||||||
|
additional_headers.join(";")
|
||||||
|
}
|
||||||
|
|
||||||
fn build_canonical_query_string(params: &BTreeMap<String, String>) -> String {
|
fn build_canonical_query_string(params: &BTreeMap<String, String>) -> String {
|
||||||
params
|
params
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1713,6 +1746,14 @@ mod tests {
|
|||||||
policy["conditions"][7],
|
policy["conditions"][7],
|
||||||
json!(["eq", "$content-type", "image/png"])
|
json!(["eq", "$content-type", "image/png"])
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
policy["conditions"][8],
|
||||||
|
json!(["eq", "$Cache-Control", DEFAULT_IMMUTABLE_CACHE_CONTROL])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
response.form_fields.cache_control,
|
||||||
|
Some(DEFAULT_IMMUTABLE_CACHE_CONTROL.to_string())
|
||||||
|
);
|
||||||
assert_eq!(response.bucket, "genarrative-assets".to_string());
|
assert_eq!(response.bucket, "genarrative-assets".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1870,6 +1911,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn canonicalized_oss_headers_matches_sorted_v4_header_shape() {
|
fn canonicalized_oss_headers_matches_sorted_v4_header_shape() {
|
||||||
let headers = BTreeMap::from([
|
let headers = BTreeMap::from([
|
||||||
|
(
|
||||||
|
"Cache-Control".to_string(),
|
||||||
|
DEFAULT_IMMUTABLE_CACHE_CONTROL.to_string(),
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"x-oss-meta-source-job-id".to_string(),
|
"x-oss-meta-source-job-id".to_string(),
|
||||||
" job_001 ".to_string(),
|
" job_001 ".to_string(),
|
||||||
@@ -1882,7 +1927,47 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
build_v4_canonical_headers(&headers),
|
build_v4_canonical_headers(&headers),
|
||||||
"x-oss-meta-asset-kind:character_visual\nx-oss-meta-source-job-id:job_001\n"
|
"cache-control:public, max-age=31536000, immutable\nx-oss-meta-asset-kind:character_visual\nx-oss-meta-source-job-id:job_001\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn additional_headers_include_plain_headers_and_skip_oss_managed_headers() {
|
||||||
|
let headers = BTreeMap::from([
|
||||||
|
(
|
||||||
|
"host".to_string(),
|
||||||
|
"genarrative-assets.oss-cn-beijing.aliyuncs.com".to_string(),
|
||||||
|
),
|
||||||
|
("content-type".to_string(), "image/png".to_string()),
|
||||||
|
(
|
||||||
|
"Cache-Control".to_string(),
|
||||||
|
DEFAULT_IMMUTABLE_CACHE_CONTROL.to_string(),
|
||||||
|
),
|
||||||
|
("x-oss-date".to_string(), "20260507T120000Z".to_string()),
|
||||||
|
(
|
||||||
|
"x-oss-meta-asset-kind".to_string(),
|
||||||
|
"puzzle-cover".to_string(),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(build_v4_additional_headers(&headers), "cache-control;host");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn put_object_headers_include_immutable_cache_control_for_generated_assets() {
|
||||||
|
let headers = build_put_object_headers(BTreeMap::from([(
|
||||||
|
"asset-kind".to_string(),
|
||||||
|
"puzzle-cover".to_string(),
|
||||||
|
)]))
|
||||||
|
.expect("headers should build");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
headers.get("Cache-Control"),
|
||||||
|
Some(&DEFAULT_IMMUTABLE_CACHE_CONTROL.to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
headers.get("x-oss-meta-asset-kind"),
|
||||||
|
Some(&"puzzle-cover".to_string())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -541,6 +541,8 @@ pub struct DirectUploadTicketFormFields {
|
|||||||
pub success_action_status: String,
|
pub success_action_status: String,
|
||||||
#[serde(rename = "Content-Type", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "Content-Type", skip_serializing_if = "Option::is_none")]
|
||||||
pub content_type: Option<String>,
|
pub content_type: Option<String>,
|
||||||
|
#[serde(rename = "Cache-Control", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cache_control: Option<String>,
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub metadata: BTreeMap<String, String>,
|
pub metadata: BTreeMap<String, String>,
|
||||||
}
|
}
|
||||||
@@ -684,6 +686,7 @@ mod tests {
|
|||||||
signature: "sig".to_string(),
|
signature: "sig".to_string(),
|
||||||
success_action_status: "200".to_string(),
|
success_action_status: "200".to_string(),
|
||||||
content_type: Some("image/png".to_string()),
|
content_type: Some("image/png".to_string()),
|
||||||
|
cache_control: Some("public, max-age=31536000, immutable".to_string()),
|
||||||
metadata: BTreeMap::from([(
|
metadata: BTreeMap::from([(
|
||||||
"x-oss-meta-asset-kind".to_string(),
|
"x-oss-meta-asset-kind".to_string(),
|
||||||
"character_visual".to_string(),
|
"character_visual".to_string(),
|
||||||
|
|||||||
@@ -140,6 +140,48 @@ describe('assetReadUrlService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resolveAssetReadUrl exchanges generated Aliyun OSS url for signed url', async () => {
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
read: {
|
||||||
|
objectKey:
|
||||||
|
'generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png',
|
||||||
|
signedUrl: 'https://signed.example.com/puzzle.png',
|
||||||
|
expiresAt: '2099-01-01T00:10:00Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
meta: {
|
||||||
|
apiVersion: '2026-04-08',
|
||||||
|
routeVersion: '2026-04-08',
|
||||||
|
latencyMs: 1,
|
||||||
|
timestamp: '2099-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolveAssetReadUrl(
|
||||||
|
'https://genarrative-release.oss-cn-beijing.aliyuncs.com/generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png',
|
||||||
|
),
|
||||||
|
).resolves.toBe('https://signed.example.com/puzzle.png');
|
||||||
|
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||||
|
'legacyPublicPath=%2Fgenerated-puzzle-assets%2Fpuzzle-session-1%2Fcandidate-1%2Fasset-1%2Fimage.png',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('resolveAssetReadUrl does not append cache busting query to OSS signed url', async () => {
|
test('resolveAssetReadUrl does not append cache busting query to OSS signed url', async () => {
|
||||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||||
new Response(
|
new Response(
|
||||||
|
|||||||
@@ -67,6 +67,26 @@ export function isGeneratedLegacyPath(value: string) {
|
|||||||
return /^\/?generated-[^/?#]+\/.+/u.test(value.trim());
|
return /^\/?generated-[^/?#]+\/.+/u.test(value.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAliyunOssHost(hostname: string) {
|
||||||
|
return /^[^.]+\.oss-[^.]+\.aliyuncs\.com$/iu.test(hostname.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGeneratedLegacyPathFromUrl(value: string) {
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(
|
||||||
|
value,
|
||||||
|
globalThis.location?.origin ?? 'http://localhost',
|
||||||
|
);
|
||||||
|
if (!isAliyunOssHost(parsedUrl.hostname)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const legacyPath = decodeURIComponent(parsedUrl.pathname);
|
||||||
|
return isGeneratedLegacyPath(legacyPath) ? legacyPath : '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeLegacyPublicPath(value: string) {
|
function normalizeLegacyPublicPath(value: string) {
|
||||||
return `/${value.trim().replace(/^\/+/u, '')}`;
|
return `/${value.trim().replace(/^\/+/u, '')}`;
|
||||||
}
|
}
|
||||||
@@ -284,6 +304,21 @@ export async function resolveAssetReadUrl(
|
|||||||
value.startsWith('data:') ||
|
value.startsWith('data:') ||
|
||||||
value.startsWith('blob:')
|
value.startsWith('blob:')
|
||||||
) {
|
) {
|
||||||
|
const legacyPath = resolveGeneratedLegacyPathFromUrl(value);
|
||||||
|
if (legacyPath) {
|
||||||
|
const signedUrl = await getSignedAssetReadUrl(
|
||||||
|
{
|
||||||
|
legacyPublicPath: legacyPath,
|
||||||
|
expireSeconds: options.expireSeconds,
|
||||||
|
},
|
||||||
|
options.signal,
|
||||||
|
{
|
||||||
|
bypassCache:
|
||||||
|
options.refreshKey !== null && options.refreshKey !== undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return signedUrl;
|
||||||
|
}
|
||||||
return appendCacheBustParam(value, options.refreshKey);
|
return appendCacheBustParam(value, options.refreshKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user