From 2a6da01307508f14a1474df9af3d44d0335d76da Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 7 Jun 2026 23:20:24 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=9F=E6=88=90=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E7=AD=BE=E5=90=8D=E5=9C=B0=E5=9D=80=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E6=8D=A2=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 refreshKey 调整为 signed URL 缓存版本号,同一路径同版本复用未过期签名地址。 让完整阿里云 OSS generated 地址在 hook 中先归一并走 read-url 换签。 补充前端回归测试,覆盖相同 refreshKey 不重复换签和完整 OSS 地址不裸写入图片。 更新运维文档与 Hermes 记忆,明确 refreshKey 不是每次绕过签名缓存。 --- .hermes/shared-memory/decision-log.md | 2 +- .hermes/shared-memory/pitfalls.md | 8 ++-- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- src/hooks/useResolvedAssetReadUrl.test.tsx | 48 +++++++++++++++++++ src/hooks/useResolvedAssetReadUrl.ts | 4 +- src/services/assetReadUrlService.test.ts | 45 +++++++++++++++++ src/services/assetReadUrlService.ts | 43 +++++++++++++---- 7 files changed, 136 insertions(+), 16 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 5a12f609..6e26d881 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -53,7 +53,7 @@ - 背景:生成图片如果以完整 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` 包含该普通请求头。 +- 验证方式:完整 OSS generated URL 应触发 `/api/assets/read-url?legacyPublicPath=...`,同一路径、同一 `refreshKey` 版本且未临近过期时复用本地 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 小程序微信绑定展示使用原生昵称组件 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 9b66a037..2ccd5583 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -19,8 +19,8 @@ - 现象:同一张 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`。 +- 处理:完整 OSS generated URL 先归一成 `/generated-*` legacy public path,再走 `/api/assets/read-url` 换签;`refreshKey` 是 signed 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=...`,且相同 `refreshKey` 不重复换签;`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 @@ -1032,8 +1032,8 @@ ## 拼图生成完成后图片只显示破图或 alt 文案 - 现象:拼图结果页生成完成后,“画面图”区域出现破图图标和作品名,图片无法正常预览;但打开历史拼图素材时同一张图可能可以正常预览。 -- 原因:拼图正式图保存为 `/generated-puzzle-assets/*` 兼容标识,旧 `/generated-*` 直读代理已删除;如果前端没有通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签,或收到无前导斜杠的 `generated-puzzle-assets/*` object key 后未识别为 generated 私有资源,浏览器会直接请求裸路径并失败。生成完成后的结果图还会传入 `refreshKey`,它只能用于重新请求 `/api/assets/read-url`,不能给 OSS V4 签名 URL 追加 `_v`;OSS 会把 query 纳入签名,额外参数会让签名失效,历史素材常因未传 `refreshKey` 而表现正常。 -- 处理:拼图结果页、发布预览、运行态和历史素材预览都走 `ResolvedAssetImage` 或 `useResolvedAssetReadUrl`;`isGeneratedLegacyPath(...)` 必须同时识别 `/generated-*` 和 `generated-*`;`refreshKey` 只绕过前端签名缓存并重新换签,不修改已返回的 OSS 签名 URL;禁止恢复 `/generated-puzzle-assets` 直读代理。 +- 原因:拼图正式图保存为 `/generated-puzzle-assets/*` 兼容标识,旧 `/generated-*` 直读代理已删除;如果前端没有通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签,或收到无前导斜杠的 `generated-puzzle-assets/*` object key 后未识别为 generated 私有资源,浏览器会直接请求裸路径并失败。生成完成后的结果图还会传入 `refreshKey`,它只能作为 signed URL 缓存版本号,不能给 OSS V4 签名 URL 追加 `_v`;OSS 会把 query 纳入签名,额外参数会让签名失效。 +- 处理:拼图结果页、发布预览、运行态和历史素材预览都走 `ResolvedAssetImage` 或 `useResolvedAssetReadUrl`;generated 私有资源识别必须同时覆盖 `/generated-*`、`generated-*` 和 `https://*.oss-*.aliyuncs.com/generated-*`;`refreshKey` 变化时重新换签,同一路径同一 `refreshKey` 且签名未临近过期时复用已返回的 OSS 签名 URL;禁止恢复 `/generated-puzzle-assets` 直读代理。 - 验证:运行 `npm run test -- src\services\assetReadUrlService.test.ts src\hooks\useResolvedAssetReadUrl.test.tsx src\components\puzzle-result\PuzzleResultView.test.tsx`,再触发一次真实生成确认 Network 中先请求 `/api/assets/read-url`,图片 `src` 为未追加 `_v` 的签名 URL。 - 关联:`src/services/assetReadUrlService.ts`、`src/components/ResolvedAssetImage.tsx`、`docs/technical/PUZZLE_IMAGE_ASSET_PROXY_FIX_2026-04-27.md`。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 47be1e71..303cfe1f 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -302,7 +302,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。 - 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 判断是限流、超时、解析失败还是未返回图片。 -- 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 配置补齐。 +- 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,且同一路径、同一 `refreshKey` 版本和未临近过期的 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。 - 本地 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 查看。 diff --git a/src/hooks/useResolvedAssetReadUrl.test.tsx b/src/hooks/useResolvedAssetReadUrl.test.tsx index 92bf1511..9c47d171 100644 --- a/src/hooks/useResolvedAssetReadUrl.test.tsx +++ b/src/hooks/useResolvedAssetReadUrl.test.tsx @@ -116,6 +116,54 @@ describe('useResolvedAssetReadUrl', () => { ); }); + test('完整 OSS generated 地址也会先换签再写入 img', 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', + }, + }, + ), + ); + + render( + , + ); + + expect(screen.queryByRole('img', { name: '候选图' })).toBeNull(); + + const image = await screen.findByRole('img', { name: '候选图' }); + expect(image.getAttribute('src')).toBe( + 'https://signed.example.com/puzzle.png', + ); + 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('refreshKey changes force a fresh signed url request without mutating OSS signature query', async () => { vi.spyOn(globalThis, 'fetch').mockImplementation( async () => diff --git a/src/hooks/useResolvedAssetReadUrl.ts b/src/hooks/useResolvedAssetReadUrl.ts index 4c075256..8bf24784 100644 --- a/src/hooks/useResolvedAssetReadUrl.ts +++ b/src/hooks/useResolvedAssetReadUrl.ts @@ -1,8 +1,8 @@ import { useEffect, useState } from 'react'; import { - isGeneratedLegacyPath, resolveAssetReadUrl, + shouldResolveAssetReadUrl, } from '../services/assetReadUrlService'; type UseResolvedAssetReadUrlOptions = { @@ -18,7 +18,7 @@ export function useResolvedAssetReadUrl( const enabled = options.enabled !== false; const normalizedSource = source?.trim() ?? ''; const shouldResolve = - enabled && Boolean(normalizedSource) && isGeneratedLegacyPath(normalizedSource); + enabled && shouldResolveAssetReadUrl(normalizedSource); const [resolvedUrl, setResolvedUrl] = useState( shouldResolve ? '' : normalizedSource, ); diff --git a/src/services/assetReadUrlService.test.ts b/src/services/assetReadUrlService.test.ts index 437df4c0..086c2141 100644 --- a/src/services/assetReadUrlService.test.ts +++ b/src/services/assetReadUrlService.test.ts @@ -223,6 +223,51 @@ describe('assetReadUrlService', () => { ); }); + test('resolveAssetReadUrl reuses signed url for the same refreshKey version', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2099-01-01T00:00:00Z')); + 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?x-oss-signature=stable', + 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', + }, + }, + ), + ); + + const source = + '/generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png'; + const first = await resolveAssetReadUrl(source, { + refreshKey: 'asset-version-1', + }); + const second = await resolveAssetReadUrl(source, { + refreshKey: 'asset-version-1', + }); + + expect(first).toBe(second); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + }); + test('getSignedAssetReadUrl reuses cached signed url before expiry', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2099-01-01T00:00:00Z')); diff --git a/src/services/assetReadUrlService.ts b/src/services/assetReadUrlService.ts index 5c0c7e06..6c591bd7 100644 --- a/src/services/assetReadUrlService.ts +++ b/src/services/assetReadUrlService.ts @@ -21,8 +21,8 @@ type AssetReadUrlResolveOptions = { expireSeconds?: number; /** * 图片内容可能在同一路径下被重新写入。 - * 对 generated 私有资源只跳过本地签名缓存并重新换签, - * 不能给 OSS V4 签名 URL 追加一次性参数。 + * 对 generated 私有资源作为签名 URL 缓存版本维度; + * 同一路径和同一 refreshKey 继续复用 signed URL,refreshKey 变化才重新换签。 * 普通非签名 URL 仍可追加 `_v` 避免浏览器图片缓存。 */ refreshKey?: string | number | null; @@ -87,6 +87,14 @@ function resolveGeneratedLegacyPathFromUrl(value: string) { } } +export function shouldResolveAssetReadUrl(source: string | null | undefined) { + const value = source?.trim() ?? ''; + return ( + Boolean(value) && + (isGeneratedLegacyPath(value) || Boolean(resolveGeneratedLegacyPathFromUrl(value))) + ); +} + function normalizeLegacyPublicPath(value: string) { return `/${value.trim().replace(/^\/+/u, '')}`; } @@ -103,6 +111,23 @@ function buildCacheKey(request: AssetReadUrlRequest) { return ''; } +function normalizeReadUrlCacheVersion(value: string | number | null | undefined) { + if (value === null || value === undefined) { + return ''; + } + return String(value).trim(); +} + +function buildVersionedCacheKey( + cacheKey: string, + cacheVersion: string | number | null | undefined, +) { + const normalizedVersion = normalizeReadUrlCacheVersion(cacheVersion); + return cacheKey && normalizedVersion + ? `${cacheKey}:version:${encodeURIComponent(normalizedVersion)}` + : cacheKey; +} + function buildAssetReadSearchParams(request: AssetReadUrlRequest) { const searchParams = new URLSearchParams(); if (request.objectKey?.trim()) { @@ -173,9 +198,13 @@ export async function getSignedAssetReadUrl( signal?: AbortSignal, options: { bypassCache?: boolean; + cacheVersion?: string | number | null; } = {}, ) { - const cacheKey = buildCacheKey(request); + const cacheKey = buildVersionedCacheKey( + buildCacheKey(request), + options.cacheVersion, + ); const bypassCache = options.bypassCache === true; const cached = !bypassCache && cacheKey ? signedReadUrlCache.get(cacheKey) : undefined; @@ -268,7 +297,7 @@ function appendCacheBustParam( } // OSS V4 签名会把 query 纳入签名计算,前端不能追加 `_v` 之类的缓存参数。 - // 需要刷新时通过 refreshKey 绕过本地签名缓存,重新请求 `/api/assets/read-url`。 + // 需要刷新时让 refreshKey 变化,形成新的签名缓存版本;同一版本继续复用 signed URL。 if (/[?&]x-oss-signature(?:=|&|$)/u.test(url)) { return url; } @@ -313,8 +342,7 @@ export async function resolveAssetReadUrl( }, options.signal, { - bypassCache: - options.refreshKey !== null && options.refreshKey !== undefined, + cacheVersion: options.refreshKey, }, ); return signedUrl; @@ -330,8 +358,7 @@ export async function resolveAssetReadUrl( }, options.signal, { - bypassCache: - options.refreshKey !== null && options.refreshKey !== undefined, + cacheVersion: options.refreshKey, }, ); return signedUrl;