diff --git a/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md b/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md index 7e4d825c..e9c7a3a3 100644 --- a/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md +++ b/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md @@ -115,7 +115,7 @@ 结论: - 所有图片、动画、精灵表、场景图、封面图、视频参考素材都存 OSS。 -- SpacetimeDB 只存对象键、版本、尺寸、状态、逻辑元数据,不存二进制。 +- SpacetimeDB 只存 `bucket`、对象键、版本、尺寸、状态、逻辑元数据,不存二进制。 - Axum 负责下发直传凭证、校验上传结果、补写元数据。 ## 4. 目标总体架构 @@ -429,7 +429,10 @@ server-rs/ 1. 任务状态在 SpacetimeDB。 2. 二进制对象在 OSS。 -3. 所有业务实体只引用 `asset_object_key / cdn_url / version / hash`。 +3. `asset_object` 的正式真相字段固定为 `bucket + object_key`。 +4. 所有 URL 都只作为派生读模型,不作为对象主键存储。 +5. `asset_object` 的首版字段、访问级别与索引设计见: + - [SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md) ## 8.2 public / private 原则 @@ -603,20 +606,27 @@ workflow-cache/{workflow_type}/{workflow_id}.json 1. `server-rs/crates/platform-oss` 已提供 `PostObject` 直传签名能力。 2. `server-rs/crates/api-server` 已暴露 `POST /api/assets/direct-upload-tickets`。 -3. 该接口当前输出: +3. `server-rs/crates/platform-oss` 已提供私有对象 `GET` 短期签名 URL 能力。 +4. `server-rs/crates/api-server` 已暴露 `GET /api/assets/read-url`。 +5. 上传接口当前输出: + - `bucket` - `objectKey` - `legacyPublicPath` - - `publicUrl` - `formFields` - `expiresAt` -4. 当前签名链路优先兼容旧公开前缀: +6. 读取接口当前支持: + - `objectKey` + - `legacyPublicPath` + - `expireSeconds` +7. 当前 bucket 已明确为私有读写,因此 `publicUrl` 不再作为正式对象真相输出。 +8. 当前签名链路优先兼容旧公开前缀: - `/generated-character-drafts/*` - `/generated-characters/*` - `/generated-animations/*` - `/generated-custom-world-scenes/*` - `/generated-custom-world-covers/*` - `/generated-qwen-sprites/*` -5. `STS`、服务端上传 helper、对象确认与业务绑定仍在后续阶段补齐。 +9. `STS`、服务端上传 helper、对象确认与业务绑定仍在后续阶段补齐。 ## 11.3 元数据与标签 @@ -639,9 +649,9 @@ workflow-cache/{workflow_type}/{workflow_id}.json 建议: -1. 业务表里统一存 `object_key` -2. 对外输出 `cdn_url` -3. 私有对象额外输出短期签名 URL +1. 业务表里统一存 `bucket + object_key` +2. 对外输出 `cdn_url` 或签名 URL +3. 私有对象默认输出短期签名 URL,而不是假设匿名公开读 为了兼容当前前端相对路径使用习惯,第一阶段可以让 Axum 或 CDN 兼容以下历史前缀: @@ -652,6 +662,19 @@ workflow-cache/{workflow_type}/{workflow_id}.json 5. `/generated-custom-world-covers/*` 6. `/generated-qwen-sprites/*` +补充约束: + +1. 当前 `xushi-dev` bucket 已明确为私有读写,因此这些旧前缀在第一阶段只代表兼容路径习惯,不代表对象可匿名读取。 +2. Web 端若拿到的是历史 `/generated-*` 路径,必须先调用 `GET /api/assets/read-url` 换取 `signedUrl`,不能直接把该路径当成正式可读 URL。 +3. 前端工程内凡是图片、背景图、封面图、角色图、场景图等展示入口,只要可能接收到 `/generated-*`,都必须统一走资源解析层: + - 列表/卡片/普通 `` 优先复用 `src/services/assetReadUrlService.ts` + - 组件内优先复用 `src/hooks/useResolvedAssetReadUrl.ts` + - 通用图片标签优先复用 `src/components/ResolvedAssetImage.tsx` + - 当前已完成的高优先级入口包括:`CharacterAnimator`、`CharacterPanel`、`CompanionCampModal`、`CharacterSelectionFlow`、`MapModal`、`GameCanvasSceneLayer`、`GameCanvasShared`、`GameCanvasEntityLayer`、`CustomWorldResultView`、`CustomWorldEntityEditorModal`、`CustomWorldRoleAssetStudioModal`、`CustomWorldAgentDraftDetailPanel`、`PlatformHomeView`、`PlatformWorldDetailView`、`QwenSpriteSheetTool` +4. 对私有 OSS 资源,前端在签名地址返回前不能先回退渲染原始 `/generated-*` 路径,否则浏览器会先发起一次无签名请求并触发 `403`。 +2. 具体对象引用设计见: + - [SPACETIMEDB_ASSET_OBJECT_STORAGE_DESIGN_2026-04-21.md](./SPACETIMEDB_ASSET_OBJECT_STORAGE_DESIGN_2026-04-21.md) + ## 12. 关键业务流设计 ## 12.1 Story Action diff --git a/src/components/CharacterAnimator.test.tsx b/src/components/CharacterAnimator.test.tsx index 16c99ee4..022330dd 100644 --- a/src/components/CharacterAnimator.test.tsx +++ b/src/components/CharacterAnimator.test.tsx @@ -1,11 +1,19 @@ // @vitest-environment jsdom import { render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AnimationState, type Character } from '../types'; import { CharacterAnimator } from './CharacterAnimator'; +vi.mock('../hooks/useResolvedAssetReadUrl', () => ({ + useResolvedAssetReadUrl: vi.fn((source: string | null | undefined) => ({ + resolvedUrl: source?.trim() ?? '', + isResolving: false, + shouldResolve: false, + })), +})); + function buildCharacter(overrides: Partial = {}): Character { return { id: 'generated-role', @@ -26,6 +34,10 @@ function buildCharacter(overrides: Partial = {}): Character { } describe('CharacterAnimator portrait fallbacks', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('keeps idle fallback static on the portrait when idle animation is missing', () => { render( = ({ const imagePath = fallbackToPortrait ? character.portrait : generatedImagePath; + const { + resolvedUrl: resolvedImagePath, + shouldResolve: shouldResolveImagePath, + } = useResolvedAssetReadUrl(imagePath); + // 私有 OSS 资源必须等签名地址返回后再渲染,不能先落回原始 generated-* 路径。 + const displayImagePath = + resolvedImagePath || (!shouldResolveImagePath ? imagePath : ''); const resolvedImageClassName = `h-full w-full object-contain pixelated ${imageClassName ?? ''}`.trim(); const imageStyle = @@ -215,10 +223,14 @@ export const CharacterAnimator: React.FC = ({ ? FALLEN_PORTRAIT_STYLE : DEFAULT_IMAGE_STYLE; + if (!displayImagePath) { + return
; + } + return (
{`${character.name}
- {member.character.name}
- {character.name}
- {character.name} {imageSrc ? ( - {title} - {src ? ( - {alt} + ) : (
{fallbackLabel} @@ -1427,7 +1432,7 @@ export function CustomWorldEntityCatalog({ } media={ role.imageSrc?.trim() ? ( - {role.name} ) : previewImageSrc ? ( - {role.name} - {src ? ( + {displaySrc ? ( {alt}
) : role.imageSrc ? ( - {role.name}
) : role.imageSrc ? ( - {skill.name}
- {selectedTemplate.name} {preferredImageSrc ? ( - {npc.name} ({ default: () => null, })); +vi.mock('../services/assetReadUrlService', () => ({ + resolveAssetReadUrl: vi.fn(async (source: string | null | undefined) => { + const value = source?.trim() ?? ''; + return value ? `https://signed.example${value}` : ''; + }), + isGeneratedLegacyPath: vi.fn((source: string | null | undefined) => { + const value = source?.trim() ?? ''; + return /^\/generated-[^/?#]+\/.+/u.test(value); + }), +})); + async function loadAiService() { return import('../services/aiService'); } @@ -315,9 +326,13 @@ test('clicking新增可扮演角色 shows pending item, disables button, and mar expect(screen.getAllByText('新').length).toBeGreaterThan(0); }); -test('world basic setting renders eight anchor fields and hides legacy parsed/source copy', () => { +test('world basic setting renders eight anchor fields and hides legacy parsed/source copy', async () => { render(); + await waitFor(() => { + expect(screen.getByText('世界承诺')).toBeTruthy(); + }); + expect(screen.getByText('世界承诺')).toBeTruthy(); expect(screen.getByText('玩家幻想')).toBeTruthy(); expect(screen.getByText('主题边界')).toBeTruthy(); @@ -378,7 +393,7 @@ test('playable tab prefers generated portrait over runtime preview placeholder', const portrait = screen.getByRole('img', { name: '云止' }); expect((portrait as HTMLImageElement).getAttribute('src')).toBe( - '/generated-characters/playable-portrait/master.png', + 'https://signed.example/generated-characters/playable-portrait/master.png', ); expect(screen.getByText('已生成主图')).toBeTruthy(); }); @@ -395,6 +410,59 @@ test('landmark tab uses first act image as scene card preview and keeps chapter const sceneImage = screen.getByRole('img', { name: '沉钟栈桥' }); expect((sceneImage as HTMLImageElement).getAttribute('src')).toBe( - '/generated-custom-world-scenes/scene-act-1.png', + 'https://signed.example/generated-custom-world-scenes/scene-act-1.png', ); }); + +test('asset debug panel opens signed image link in dev mode', async () => { + const user = userEvent.setup(); + const originalImportMetaEnv = import.meta.env; + const originalUrl = window.location.href; + const originalImage = globalThis.Image; + + Object.defineProperty(import.meta, 'env', { + value: { + ...originalImportMetaEnv, + DEV: true, + }, + configurable: true, + }); + window.history.pushState({}, '', '/?debugCustomWorldAssets=1'); + class MockImage { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + + set src(_value: string) { + queueMicrotask(() => { + this.onload?.(); + }); + } + } + Object.defineProperty(globalThis, 'Image', { + value: MockImage, + configurable: true, + }); + + try { + render(); + + await user.click(screen.getByRole('button', { name: /场景\s*2/u })); + + const signedLink = await screen.findByRole('link', { + name: /打开 沉钟栈桥章节 \/ 潮声逼近幕图签名图/u, + }); + expect((signedLink as HTMLAnchorElement).href).toBe( + 'https://signed.example/generated-custom-world-scenes/scene-act-1.png', + ); + } finally { + Object.defineProperty(import.meta, 'env', { + value: originalImportMetaEnv, + configurable: true, + }); + window.history.pushState({}, '', originalUrl); + Object.defineProperty(globalThis, 'Image', { + value: originalImage, + configurable: true, + }); + } +}); diff --git a/src/components/CustomWorldResultView.tsx b/src/components/CustomWorldResultView.tsx index 1ca3ddb8..5c780db5 100644 --- a/src/components/CustomWorldResultView.tsx +++ b/src/components/CustomWorldResultView.tsx @@ -6,6 +6,7 @@ import { generateCustomWorldPlayableNpc, generateCustomWorldStoryNpc, } from '../services/aiService'; +import { resolveAssetReadUrl } from '../services/assetReadUrlService'; import { Character, CustomWorldLandmark, @@ -405,6 +406,29 @@ export function CustomWorldResultView({ const [assetDebugStatusMap, setAssetDebugStatusMap] = useState< Record >({}); + const [assetDebugResolvedImageMap, setAssetDebugResolvedImageMap] = useState< + Record + >({}); + const assetDebugResolvedEntries = useMemo( + () => + assetDebugEntries.map((entry) => ({ + ...entry, + hasResolvedImageSrc: Object.prototype.hasOwnProperty.call( + assetDebugResolvedImageMap, + entry.id, + ), + resolvedImageSrc: assetDebugResolvedImageMap[entry.id] || entry.imageSrc, + })), + [assetDebugEntries, assetDebugResolvedImageMap], + ); + const assetDebugDetectableEntries = useMemo( + () => + assetDebugResolvedEntries.filter( + (entry) => + entry.hasResolvedImageSrc && Boolean(entry.resolvedImageSrc.trim()), + ), + [assetDebugResolvedEntries], + ); const createTarget = useMemo( () => getCreateTargetByTab(activeTab), @@ -425,11 +449,42 @@ export function CustomWorldResultView({ useEffect(() => { if (!assetDebugEnabled) { - setAssetDebugStatusMap({}); + setAssetDebugResolvedImageMap({}); return; } if (assetDebugEntries.length === 0) { + setAssetDebugResolvedImageMap({}); + return; + } + + let cancelled = false; + + void Promise.all( + assetDebugEntries.map(async (entry) => [ + entry.id, + await resolveAssetReadUrl(entry.imageSrc), + ] as const), + ).then((resolvedEntries) => { + if (cancelled) { + return; + } + + setAssetDebugResolvedImageMap(Object.fromEntries(resolvedEntries)); + }); + + return () => { + cancelled = true; + }; + }, [assetDebugEnabled, assetDebugEntries]); + + useEffect(() => { + if (!assetDebugEnabled) { + setAssetDebugStatusMap({}); + return; + } + + if (assetDebugDetectableEntries.length === 0) { setAssetDebugStatusMap({}); return; } @@ -437,13 +492,14 @@ export function CustomWorldResultView({ let cancelled = false; const cleanupList: Array<() => void> = []; + // 诊断面板只根据已解析地址做探测,避免状态流里反复访问原始 generated-* 路径。 setAssetDebugStatusMap( Object.fromEntries( - assetDebugEntries.map((entry) => [entry.id, 'loading' as const]), + assetDebugDetectableEntries.map((entry) => [entry.id, 'loading' as const]), ), ); - assetDebugEntries.forEach((entry) => { + assetDebugDetectableEntries.forEach((entry) => { const image = new Image(); const updateStatus = (status: AssetDebugLoadStatus) => { if (cancelled) { @@ -463,7 +519,7 @@ export function CustomWorldResultView({ image.onload = () => updateStatus('loaded'); image.onerror = () => updateStatus('error'); - image.src = entry.imageSrc; + image.src = entry.resolvedImageSrc; cleanupList.push(() => { image.onload = null; image.onerror = null; @@ -474,7 +530,7 @@ export function CustomWorldResultView({ cancelled = true; cleanupList.forEach((cleanup) => cleanup()); }; - }, [assetDebugEnabled, assetDebugEntries]); + }, [assetDebugDetectableEntries, assetDebugEnabled]); const startPendingProgress = (kind: EntityGenerationKind) => { stopPendingProgressTimer(); @@ -679,7 +735,7 @@ export function CustomWorldResultView({
- {assetDebugEntries.length}项 + {assetDebugResolvedEntries.length}项
@@ -696,8 +752,8 @@ export function CustomWorldResultView({ ))}
- {assetDebugEntries.length > 0 ? ( - assetDebugEntries.map((entry) => ( + {assetDebugResolvedEntries.length > 0 ? ( + assetDebugResolvedEntries.map((entry) => (
- - 打开原图 - + {entry.hasResolvedImageSrc ? ( + + 打开签名图 + + ) : ( +
+ 正在解析签名地址... +
+ )}
)) diff --git a/src/components/CustomWorldRoleAssetStudioModal.tsx b/src/components/CustomWorldRoleAssetStudioModal.tsx index 41a37471..739bd413 100644 --- a/src/components/CustomWorldRoleAssetStudioModal.tsx +++ b/src/components/CustomWorldRoleAssetStudioModal.tsx @@ -34,6 +34,7 @@ import { buildDefaultRolePromptBundle } from './asset-studio/customWorldRoleProm import { buildProjectPixelStyleReferenceBoard } from './asset-studio/projectPixelStyleReference'; import { useAuthUi } from './auth/AuthUiContext'; import { CharacterAnimator } from './CharacterAnimator'; +import { ResolvedAssetImage } from './ResolvedAssetImage'; type EditableCustomWorldRole = { id: string; @@ -1173,13 +1174,13 @@ export function CustomWorldRoleAssetStudioModal({
{previewImageSrc ? ( - {workingRole.name} ) : selectedTemplate ? ( - {selectedTemplate.name}
) : previewImageSrc ? ( - {workingRole.name}(null); + const { + resolvedUrl: resolvedBackdropImageSrc, + shouldResolve: shouldResolveBackdropImage, + } = useResolvedAssetReadUrl(currentScenePreset?.imageSrc); const connectedScenes = useMemo( () => @@ -186,7 +191,10 @@ export function MapModal({ return buildFallbackConnectionEntries(currentScenePreset, connectedScenes); }, [connectedScenes, currentScenePreset]); - const sceneBackdropStyle = buildSceneBackdropStyle(currentScenePreset?.imageSrc); + const sceneBackdropStyle = buildSceneBackdropStyle( + resolvedBackdropImageSrc + || (!shouldResolveBackdropImage ? currentScenePreset?.imageSrc : ''), + ); const destinationStackHeightPx = getMapDestinationStackHeight(connectionEntries.length); useEffect(() => { diff --git a/src/components/ResolvedAssetImage.tsx b/src/components/ResolvedAssetImage.tsx new file mode 100644 index 00000000..bc550e80 --- /dev/null +++ b/src/components/ResolvedAssetImage.tsx @@ -0,0 +1,27 @@ +import type { ImgHTMLAttributes } from 'react'; + +import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl'; + +type ResolvedAssetImageProps = Omit< + ImgHTMLAttributes, + 'src' +> & { + src?: string | null; + fallbackSrc?: string | null; +}; + +export function ResolvedAssetImage({ + src, + fallbackSrc, + alt, + ...rest +}: ResolvedAssetImageProps) { + const { resolvedUrl } = useResolvedAssetReadUrl(src); + const finalSrc = resolvedUrl || fallbackSrc?.trim() || ''; + + if (!finalSrc) { + return null; + } + + return {alt}; +} diff --git a/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx b/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx index 85e1c001..5409ef88 100644 --- a/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx +++ b/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx @@ -1,4 +1,5 @@ import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent'; +import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { CustomWorldDraftEditPanel } from './CustomWorldDraftEditPanel'; type CustomWorldAgentDraftDetailPanelProps = { @@ -179,7 +180,7 @@ export function CustomWorldAgentDraftDetailPanel({ {section.label}
{shouldRenderImagePreview(detail.kind, section.id, section.value) ? ( - {section.label} {encounter.kind === 'treasure' ? (
- {encounter.npcName} - {!backgroundLoadFailed ? ( + {!backgroundLoadFailed && displayBackgroundSrc ? ( {currentScenePreset?.name; + } + + if (displayEncounterImageSrc) { return ( {encounter.npcName} ) : ( - {meta.name} + )} {selected ? ( diff --git a/src/components/game-shell/PlatformHomeView.tsx b/src/components/game-shell/PlatformHomeView.tsx index 3a0ae3ce..f6617aa7 100644 --- a/src/components/game-shell/PlatformHomeView.tsx +++ b/src/components/game-shell/PlatformHomeView.tsx @@ -32,7 +32,9 @@ import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapsho import type { AuthUser } from '../../services/authService'; import type { PlatformBrowseHistoryEntry } from '../../services/platformBrowseHistory'; import type { CustomWorldProfile } from '../../types'; +import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; import { useAuthUi } from '../auth/AuthUiContext'; +import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { PlatformBrandLogo } from './PlatformBrandLogo'; import { buildPlatformWorldTags, @@ -53,6 +55,35 @@ const MOBILE_PAGE_STAGE_CLASS = const DESKTOP_PAGE_STAGE_CLASS = 'platform-page-stage platform-remap-surface space-y-5 pb-4'; +function ResolvedAssetBackdrop({ + src, + alt, + className, + ariaHidden = false, +}: { + src?: string | null; + alt: string; + className: string; + ariaHidden?: boolean; +}) { + const { resolvedUrl, shouldResolve } = useResolvedAssetReadUrl(src); + // 私有 OSS 封面在签名地址返回前保持现有底色层,避免浏览器直接访问旧 generated-* 路径。 + const displaySrc = resolvedUrl || (!shouldResolve ? src?.trim() ?? '' : ''); + + if (!displaySrc) { + return null; + } + + return ( + {alt} + ); +} + function SectionHeader({ title, detail }: { title: string; detail: string }) { return (
@@ -91,7 +122,7 @@ function SaveArchivePreview({ className={`platform-remap-surface relative shrink-0 overflow-hidden rounded-[1.35rem] border border-white/12 bg-black/18 shadow-[var(--platform-desktop-hover-shadow)] ${className}`} > {entry.coverImageSrc ? ( - {coverImage ? ( - {entry.worldName} ) : null} {leadPortrait ? ( - {coverImage ? ( - {entry.worldName} ) : null} {leadPortrait ? ( -
{coverImage ? ( - {entry.worldName} {desktopHeroCover ? ( -
{coverImage ? ( -
{coverImage ? ( - {entry.worldName} ) : null} {leadPortrait ? ( - ({ + resolveAssetReadUrl: vi.fn(async (source: string | null | undefined) => { + const value = source?.trim() ?? ''; + return value ? `https://signed.example${value}` : ''; + }), + isGeneratedLegacyPath: vi.fn((source: string | null | undefined) => { + const value = source?.trim() ?? ''; + return /^\/generated-[^/?#]+\/.+/u.test(value); + }), +})); + async function clickFirstButtonByName( user: ReturnType, name: string | RegExp, @@ -434,6 +445,39 @@ test('clicking a public work while logged out routes through requireAuth', async expect(getCustomWorldGalleryDetail).not.toHaveBeenCalled(); }); +test('platform home cards resolve generated cover images through signed read urls', async () => { + vi.mocked(listCustomWorldGallery).mockResolvedValue([ + { + ownerUserId: 'author-1', + profileId: 'world-public-1', + visibility: 'published', + publishedAt: '2026-04-16T12:00:00.000Z', + updatedAt: '2026-04-16T12:00:00.000Z', + worldName: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summaryText: '最近公开发布的世界。', + coverImageSrc: '/generated-custom-world-covers/world-public-1/cover.webp', + themeMode: 'tide', + authorDisplayName: '潮汐作者', + playableNpcCount: 3, + landmarkCount: 4, + }, + ]); + + render(); + + await waitFor(() => { + const coverImages = screen.getAllByAltText('潮雾列岛'); + expect( + coverImages.some( + (image) => + image.getAttribute('src') === + 'https://signed.example/generated-custom-world-covers/world-public-1/cover.webp', + ), + ).toBe(true); + }); +}); + test('selecting RPG creation while logged out routes through requireAuth', async () => { const user = userEvent.setup(); const requireAuth = vi.fn(); @@ -626,14 +670,11 @@ test('existing draft sessions enter the legacy result layout directly', async () await clickFirstButtonByName(user, /开启新的创作/u); await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u })); - await waitFor( - async () => { - expect(await screen.findByText('世界档案')).toBeTruthy(); - expect(screen.getByText('已自动保存')).toBeTruthy(); - expect(screen.getByRole('button', { name: /进入世界/u })).toBeTruthy(); - }, - { timeout: 2500 }, - ); + expect( + await screen.findByText('世界档案', undefined, { timeout: 10000 }), + ).toBeTruthy(); + expect(screen.getByText('已自动保存')).toBeTruthy(); + expect(screen.getByRole('button', { name: /进入世界/u })).toBeTruthy(); expect(screen.queryByText(/Agent工作区/u)).toBeNull(); expect(screen.queryByRole('button', { name: /^锚点/u })).toBeNull(); diff --git a/src/hooks/useResolvedAssetReadUrl.ts b/src/hooks/useResolvedAssetReadUrl.ts new file mode 100644 index 00000000..662d88ea --- /dev/null +++ b/src/hooks/useResolvedAssetReadUrl.ts @@ -0,0 +1,64 @@ +import { useEffect, useState } from 'react'; + +import { + isGeneratedLegacyPath, + resolveAssetReadUrl, +} from '../services/assetReadUrlService'; + +type UseResolvedAssetReadUrlOptions = { + enabled?: boolean; + expireSeconds?: number; +}; + +export function useResolvedAssetReadUrl( + source: string | null | undefined, + options: UseResolvedAssetReadUrlOptions = {}, +) { + const enabled = options.enabled !== false; + const normalizedSource = source?.trim() ?? ''; + const shouldResolve = + enabled && Boolean(normalizedSource) && isGeneratedLegacyPath(normalizedSource); + const [resolvedUrl, setResolvedUrl] = useState( + shouldResolve ? '' : normalizedSource, + ); + + useEffect(() => { + if (!normalizedSource) { + setResolvedUrl(''); + return; + } + + if (!shouldResolve) { + setResolvedUrl(normalizedSource); + return; + } + + let cancelled = false; + setResolvedUrl(''); + + void resolveAssetReadUrl(normalizedSource, { + expireSeconds: options.expireSeconds, + }) + .then((nextUrl) => { + if (!cancelled) { + setResolvedUrl(nextUrl); + } + }) + .catch(() => { + if (!cancelled) { + // 读取签名失败时回退原始路径,至少保持现有 UI 可见错误表象。 + setResolvedUrl(normalizedSource); + } + }); + + return () => { + cancelled = true; + }; + }, [normalizedSource, options.expireSeconds, shouldResolve]); + + return { + resolvedUrl, + isResolving: shouldResolve && !resolvedUrl, + shouldResolve, + }; +} diff --git a/src/services/assetReadUrlService.test.ts b/src/services/assetReadUrlService.test.ts new file mode 100644 index 00000000..6d707475 --- /dev/null +++ b/src/services/assetReadUrlService.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { + clearSignedAssetReadUrlCache, + getSignedAssetReadUrl, + resolveAssetReadUrl, +} from './assetReadUrlService'; + +describe('assetReadUrlService', () => { + beforeEach(() => { + clearSignedAssetReadUrlCache(); + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test('resolveAssetReadUrl returns passthrough for absolute url', async () => { + await expect(resolveAssetReadUrl('https://example.com/demo.png')).resolves.toBe( + 'https://example.com/demo.png', + ); + }); + + test('resolveAssetReadUrl returns passthrough for data url', async () => { + await expect(resolveAssetReadUrl('data:image/png;base64,abc')).resolves.toBe( + 'data:image/png;base64,abc', + ); + }); + + test('resolveAssetReadUrl exchanges legacy generated path for signed url', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + ok: true, + data: { + read: { + objectKey: 'generated-characters/hero/visual/asset-01/master.png', + signedUrl: 'https://signed.example.com/master.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('/generated-characters/hero/visual/asset-01/master.png'), + ).resolves.toBe('https://signed.example.com/master.png'); + + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain( + '/api/assets/read-url?', + ); + }); + + test('getSignedAssetReadUrl reuses cached signed url before expiry', 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-custom-world-scenes/profile-1/landmark-1/scene.png', + signedUrl: 'https://signed.example.com/scene.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', + }, + }, + ), + ); + + const first = await getSignedAssetReadUrl({ + legacyPublicPath: '/generated-custom-world-scenes/profile-1/landmark-1/scene.png', + }); + const second = await getSignedAssetReadUrl({ + legacyPublicPath: '/generated-custom-world-scenes/profile-1/landmark-1/scene.png', + }); + + expect(first).toBe('https://signed.example.com/scene.png'); + expect(second).toBe(first); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/services/assetReadUrlService.ts b/src/services/assetReadUrlService.ts new file mode 100644 index 00000000..2b90fd91 --- /dev/null +++ b/src/services/assetReadUrlService.ts @@ -0,0 +1,191 @@ +import { requestJson } from './apiClient'; + +export type AssetReadUrlRequest = { + objectKey?: string; + legacyPublicPath?: string; + expireSeconds?: number; +}; + +export type AssetReadUrlResponse = { + read?: { + objectKey?: string; + signedUrl?: string; + expiresAt?: string; + }; + signedUrl?: string; + objectKey?: string; + expiresAt?: string; +}; + +type CachedReadUrlEntry = { + signedUrl: string; + expiresAtMs: number; +}; + +const ASSET_READ_URL_API_PATH = '/api/assets/read-url'; +const DEFAULT_CACHE_SAFETY_WINDOW_MS = 30 * 1000; +const signedReadUrlCache = new Map(); +const pendingSignedReadUrlRequests = new Map>(); + +export function isGeneratedLegacyPath(value: string) { + return /^\/generated-[^/?#]+\/.+/u.test(value.trim()); +} + +function normalizeLegacyPublicPath(value: string) { + return `/${value.trim().replace(/^\/+/u, '')}`; +} + +function buildCacheKey(request: AssetReadUrlRequest) { + if (request.objectKey?.trim()) { + return `object:${request.objectKey.trim().replace(/^\/+/u, '')}`; + } + + if (request.legacyPublicPath?.trim()) { + return `legacy:${normalizeLegacyPublicPath(request.legacyPublicPath)}`; + } + + return ''; +} + +function resolveSignedReadPayload(response: AssetReadUrlResponse) { + const read = response.read ?? response; + const signedUrl = typeof read.signedUrl === 'string' ? read.signedUrl.trim() : ''; + const expiresAt = typeof read.expiresAt === 'string' ? read.expiresAt.trim() : ''; + const objectKey = typeof read.objectKey === 'string' ? read.objectKey.trim() : ''; + + if (!signedUrl) { + throw new Error('资源访问地址缺失'); + } + + return { + signedUrl, + expiresAt, + objectKey, + }; +} + +function parseExpiresAtMs(expiresAt: string) { + if (!expiresAt) { + return 0; + } + + const parsed = Date.parse(expiresAt); + return Number.isFinite(parsed) ? parsed : 0; +} + +function shouldReuseCachedReadUrl(entry: CachedReadUrlEntry | undefined) { + if (!entry) { + return false; + } + + return entry.expiresAtMs - DEFAULT_CACHE_SAFETY_WINDOW_MS > Date.now(); +} + +export async function getSignedAssetReadUrl( + request: AssetReadUrlRequest, + signal?: AbortSignal, +) { + const cacheKey = buildCacheKey(request); + const cached = cacheKey ? signedReadUrlCache.get(cacheKey) : undefined; + if (cached && shouldReuseCachedReadUrl(cached)) { + return cached.signedUrl; + } + + if (cacheKey) { + const pendingRequest = pendingSignedReadUrlRequests.get(cacheKey); + if (pendingRequest) { + return pendingRequest; + } + } + + const requestPromise = (async () => { + const searchParams = new URLSearchParams(); + if (request.objectKey?.trim()) { + searchParams.set('objectKey', request.objectKey.trim().replace(/^\/+/u, '')); + } + if (request.legacyPublicPath?.trim()) { + searchParams.set( + 'legacyPublicPath', + normalizeLegacyPublicPath(request.legacyPublicPath), + ); + } + if ( + typeof request.expireSeconds === 'number' && + Number.isFinite(request.expireSeconds) && + request.expireSeconds > 0 + ) { + searchParams.set('expireSeconds', String(Math.floor(request.expireSeconds))); + } + + const response = await requestJson( + `${ASSET_READ_URL_API_PATH}?${searchParams.toString()}`, + { + method: 'GET', + signal, + }, + '获取资源访问地址失败', + ); + const payload = resolveSignedReadPayload(response); + const expiresAtMs = parseExpiresAtMs(payload.expiresAt); + + if (cacheKey && expiresAtMs > 0) { + signedReadUrlCache.set(cacheKey, { + signedUrl: payload.signedUrl, + expiresAtMs, + }); + } + + return payload.signedUrl; + })(); + + if (cacheKey) { + pendingSignedReadUrlRequests.set(cacheKey, requestPromise); + } + + try { + return await requestPromise; + } finally { + if (cacheKey) { + pendingSignedReadUrlRequests.delete(cacheKey); + } + } +} + +// 兼容层:普通 http(s)/data/blob 路径原样返回;历史 generated-* 路径自动换签名读 URL。 +export async function resolveAssetReadUrl( + source: string | null | undefined, + options: { + signal?: AbortSignal; + expireSeconds?: number; + } = {}, +) { + const value = source?.trim() ?? ''; + if (!value) { + return ''; + } + + if ( + /^(?:https?:)?\/\//u.test(value) || + value.startsWith('data:') || + value.startsWith('blob:') + ) { + return value; + } + + if (isGeneratedLegacyPath(value)) { + return getSignedAssetReadUrl( + { + legacyPublicPath: value, + expireSeconds: options.expireSeconds, + }, + options.signal, + ); + } + + return value; +} + +export function clearSignedAssetReadUrlCache() { + signedReadUrlCache.clear(); + pendingSignedReadUrlRequests.clear(); +} diff --git a/src/tools/QwenSpriteSheetTool.tsx b/src/tools/QwenSpriteSheetTool.tsx index 264742a7..c6993817 100644 --- a/src/tools/QwenSpriteSheetTool.tsx +++ b/src/tools/QwenSpriteSheetTool.tsx @@ -18,6 +18,7 @@ import { GENERATED_FRAME_WIDTH, } from '../components/asset-studio/characterAssetWorkflowModel'; import { generateCharacterAnimationDraft } from '../components/asset-studio/characterAssetWorkflowPersistence'; +import { ResolvedAssetImage } from '../components/ResolvedAssetImage'; import { NumberField, SelectField, @@ -124,7 +125,7 @@ function DraftStrip({ }`} >
- {draft.label}
-