Merge remote-tracking branch 'origin/master' into codex/architecture-adjustment
This commit is contained in:
@@ -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 小程序微信绑定展示使用原生昵称组件
|
||||
|
||||
@@ -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`。
|
||||
|
||||
|
||||
@@ -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 查看。
|
||||
|
||||
@@ -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(
|
||||
<ResolvedAssetImage
|
||||
src="https://genarrative-release.oss-cn-beijing.aliyuncs.com/generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png"
|
||||
alt="候选图"
|
||||
/>,
|
||||
);
|
||||
|
||||
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 () =>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user