修复后台动作预览签名地址过期复用

缓存序列帧换签响应的过期时间并复用统一安全窗口
临近过期时自动换签并保持已就绪帧可见
补充跨五分钟回绕播放与缓存边界回归测试
同步后台素材预览架构约束修复 migration blocker。
This commit is contained in:
2026-08-05 19:22:39 +08:00
parent c76d0a8835
commit 731bc02b5c
7 changed files with 268 additions and 50 deletions
@@ -34,6 +34,7 @@ function createDeferred<T>() {
test('角色动作预览跨窗口回播时复用父级换签缓存并等待目标帧就绪', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-04T10:50:00Z'));
const frameObjectKeys = [
'generated-animations/editor/task-cache/frame00.png',
'generated-animations/editor/task-cache/frame01.png',
@@ -149,6 +150,114 @@ test('角色动作预览跨窗口回播时复用父级换签缓存并等待目
).toBeLessThanOrEqual(3);
});
test('角色动作预览打开超过五分钟后回绕播放会在过期窗口内自动换签', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2099-01-01T00:00:00Z'));
const frameObjectKeys = Array.from(
{ length: 5 },
(_, index) => `generated-animations/editor/task-expiry/frame0${index}.png`,
);
const requestCounts = new Map<string, number>();
vi.mocked(getAdminAssetReadUrl).mockImplementation((_token, request) => {
const objectKey = request.objectKey ?? '';
const requestCount = (requestCounts.get(objectKey) ?? 0) + 1;
requestCounts.set(objectKey, requestCount);
return Promise.resolve({
read: {
objectKey,
signedUrl: `https://signed.example.com/v${requestCount}/${objectKey}`,
expiresAt:
requestCount === 1 ? '2099-01-01T00:05:00Z' : '2099-01-01T00:10:00Z',
},
});
});
render(
<AdminEditorAssetPreviewDialog
entry={{
assetId: 'asset-character-animation-expiry',
label: '过期换签动作',
imageSrc: `/${frameObjectKeys[0]}`,
objectKey: frameObjectKeys[0],
assetKind: 'character-animation',
imageSequenceFrames: frameObjectKeys.map((objectKey) => ({
imageSrc: `/${objectKey}`,
objectKey,
width: 192,
height: 256,
})),
imageSequenceDurationMs: 5_000,
}}
token="admin-token"
onClose={vi.fn()}
/>,
);
const dialog = screen.getByRole('dialog', { name: '素材预览' });
fireEvent.click(within(dialog).getByRole('button', { name: '暂停角色动作' }));
await act(async () => {
await vi.advanceTimersByTimeAsync(120);
});
loadMountedFrames(dialog);
for (let index = 0; index < frameObjectKeys.length; index += 1) {
fireEvent.click(
within(dialog).getByRole('button', { name: '播放角色动作' }),
);
await act(async () => {
await vi.advanceTimersByTimeAsync(1_000);
});
fireEvent.click(
within(dialog).getByRole('button', { name: '暂停角色动作' }),
);
await act(async () => {
await vi.advanceTimersByTimeAsync(120);
});
loadMountedFrames(dialog);
}
expect(within(dialog).getByText('1/5')).toBeTruthy();
frameObjectKeys.forEach((objectKey) => {
expect(requestCounts.get(objectKey)).toBe(1);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(300_001);
});
loadMountedFrames(dialog);
expect(resolveVisibleFrameSrc(dialog)).toContain('/v2/');
for (let index = 0; index < frameObjectKeys.length; index += 1) {
fireEvent.click(
within(dialog).getByRole('button', { name: '播放角色动作' }),
);
await act(async () => {
await vi.advanceTimersByTimeAsync(1_000);
});
fireEvent.click(
within(dialog).getByRole('button', { name: '暂停角色动作' }),
);
await act(async () => {
await vi.advanceTimersByTimeAsync(120);
});
loadMountedFrames(dialog);
}
expect(within(dialog).getByText('1/5')).toBeTruthy();
expect(resolveVisibleFrameSrc(dialog)).toBe(
`https://signed.example.com/v2/${frameObjectKeys[0]}`,
);
frameObjectKeys.forEach((objectKey) => {
expect(requestCounts.get(objectKey)).toBe(2);
});
expect(within(dialog).queryByText(//u)).toBeNull();
});
function loadMountedFrames(dialog: HTMLElement) {
dialog
.querySelectorAll<HTMLImageElement>('.admin-asset-query-sequence-frame')
.forEach((frame) => fireEvent.load(frame));
}
function resolveFrameByObjectKey(dialog: HTMLElement, objectKey: string) {
return Array.from(
dialog.querySelectorAll<HTMLImageElement>(
@@ -1,6 +1,11 @@
import { Pause, Play, X } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
parseSignedReadUrlExpiresAtMs,
shouldReuseSignedReadUrlCacheEntry,
SIGNED_READ_URL_CACHE_SAFETY_WINDOW_MS,
} from '../../../../packages/shared/src/utils/signedReadUrlCache';
import type { AdminAssetReadUrlResponse } from '../api/adminApiClient';
import { getAdminAssetReadUrl, isAdminApiError } from '../api/adminApiClient';
import type { AdminEditorImageSequenceFramePayload } from '../api/adminApiTypes';
@@ -337,6 +342,35 @@ function AdminEditorImageSequencePreview({
resolveFrames(mountedFrameItems);
}, [mountedFrameItems, resolveFrames]);
useEffect(() => {
const refreshAtMs = mountedFrameItems.reduce(
(earliest, item) => {
const cached = frameCache.get(item.key);
if (
!cached ||
cached.expiresAtMs === null ||
cached.status === 'resolving' ||
cached.status === 'refreshing' ||
cached.status === 'failed'
) {
return earliest;
}
const candidate =
cached.expiresAtMs - SIGNED_READ_URL_CACHE_SAFETY_WINDOW_MS;
return earliest === null || candidate < earliest ? candidate : earliest;
},
null as number | null,
);
if (refreshAtMs === null) {
return undefined;
}
const timer = window.setTimeout(
() => resolveFrames(mountedFrameItems),
Math.min(2_147_483_647, Math.max(0, refreshAtMs - Date.now())),
);
return () => window.clearTimeout(timer);
}, [frameCache, mountedFrameItems, resolveFrames]);
useEffect(() => {
if (!currentFrameKey || !failedFrameKeys.has(currentFrameKey)) {
return;
@@ -360,7 +394,7 @@ function AdminEditorImageSequencePreview({
return;
}
if (
frameCache.get(currentFrameKey)?.status !== 'ready' &&
!isAdminImageSequenceFrameReady(frameCache.get(currentFrameKey)) &&
visibleFrameKey
) {
return;
@@ -407,7 +441,7 @@ function AdminEditorImageSequencePreview({
frameKey={item.key}
resolvedUrl={cachedFrame?.resolvedUrl ?? ''}
visible={item.key === visibleFrameKey}
ready={cachedFrame?.status === 'ready'}
ready={isAdminImageSequenceFrameReady(cachedFrame)}
onReady={markFrameReady}
onFailed={markFrameFailed}
/>
@@ -446,8 +480,9 @@ type AdminImageSequenceFrameItem = {
};
type AdminImageSequenceFrameCacheEntry = {
expiresAtMs: number | null;
resolvedUrl: string;
status: 'resolving' | 'loading' | 'ready' | 'failed';
status: 'resolving' | 'refreshing' | 'loading' | 'ready' | 'failed';
};
type AdminImageSequenceFrameCacheState = {
@@ -524,36 +559,41 @@ function useAdminImageSequenceFrameCache(
const resolveFrames = useCallback(
(frameItemsToResolve: ReadonlyArray<AdminImageSequenceFrameItem>) => {
for (const item of frameItemsToResolve) {
if (
frameCache.has(item.key) ||
resolutionControllersRef.current.has(item.key)
) {
const cached = frameCache.get(item.key);
if (resolutionControllersRef.current.has(item.key)) {
continue;
}
if (cached && shouldReuseAdminImageSequenceFrameCacheEntry(cached)) {
continue;
}
const refreshReadyFrame = isAdminImageSequenceFrameReady(cached);
const controller = new AbortController();
resolutionControllersRef.current.set(item.key, controller);
updateFrameCacheEntry(item.key, () => ({
resolvedUrl: '',
status: 'resolving',
}));
updateFrameCacheEntry(item.key, (current) =>
refreshReadyFrame && current?.resolvedUrl
? { ...current, status: 'refreshing' }
: {
expiresAtMs: null,
resolvedUrl: '',
status: 'resolving',
},
);
void resolveAdminAssetUrl(
token,
item.frame.imageSrc,
item.frame.objectKey,
controller.signal,
)
.then((resolvedUrl) => {
if (!resolvedUrl) {
throw new Error('素材读取地址为空');
}
.then((resolved) => {
updateFrameCacheEntry(item.key, () => ({
resolvedUrl,
status: 'loading',
...resolved,
status: refreshReadyFrame ? 'refreshing' : 'loading',
}));
})
.catch(() => {
if (!controller.signal.aborted) {
updateFrameCacheEntry(item.key, () => ({
expiresAtMs: null,
resolvedUrl: '',
status: 'failed',
}));
@@ -585,6 +625,7 @@ function useAdminImageSequenceFrameCache(
resolutionControllersRef.current.get(frameKey)?.abort();
resolutionControllersRef.current.delete(frameKey);
updateFrameCacheEntry(frameKey, () => ({
expiresAtMs: null,
resolvedUrl: '',
status: 'failed',
}));
@@ -615,6 +656,27 @@ function useAdminImageSequenceFrameCache(
};
}
function isAdminImageSequenceFrameReady(
cached: AdminImageSequenceFrameCacheEntry | undefined,
) {
return cached?.status === 'ready' || cached?.status === 'refreshing';
}
function shouldReuseAdminImageSequenceFrameCacheEntry(
cached: AdminImageSequenceFrameCacheEntry,
) {
if (cached.status === 'failed') {
return false;
}
if (cached.expiresAtMs === null) {
return true;
}
return shouldReuseSignedReadUrlCacheEntry({
signedUrl: cached.resolvedUrl,
expiresAtMs: cached.expiresAtMs,
});
}
function findNextAvailableFrameIndex(
frameItems: ReadonlyArray<{ key: string }>,
failedFrameKeys: ReadonlySet<string>,
@@ -910,7 +972,10 @@ async function resolveAdminAssetUrl(
? normalizedImageSrc
: resolveAdminGeneratedLegacyPathFromUrl(normalizedImageSrc);
if (!normalizedObjectKey && !normalizedLegacyPublicPath) {
return normalizedImageSrc;
return {
expiresAtMs: null,
resolvedUrl: normalizedImageSrc,
};
}
let retryIndex = 0;
@@ -932,7 +997,18 @@ async function resolveAdminAssetUrl(
if (signal.aborted) {
throw new DOMException('The operation was aborted.', 'AbortError');
}
return resolveAdminAssetReadSignedUrl(response);
const read = response.read ?? response;
const resolvedUrl = resolveAdminAssetReadSignedUrl(response);
const expiresAt =
typeof read.expiresAt === 'string' ? read.expiresAt.trim() : '';
const expiresAtMs = parseSignedReadUrlExpiresAtMs(expiresAt);
if (!resolvedUrl) {
throw new Error('素材读取地址为空');
}
if (expiresAtMs <= Date.now()) {
throw new Error('素材读取地址过期时间无效');
}
return { expiresAtMs, resolvedUrl };
} catch (error: unknown) {
if (signal.aborted) {
throw error;
File diff suppressed because one or more lines are too long
+1
View File
@@ -50,5 +50,6 @@ export type * from './contracts/visualNovel';
export * from './http';
export * from './llm/narrativeLanguage';
export * from './llm/parsers';
export * from './utils/signedReadUrlCache';
// should not export component, instead, they should be ref by relative path
@@ -0,0 +1,24 @@
import { describe, expect, test } from 'vitest';
import {
parseSignedReadUrlExpiresAtMs,
shouldReuseSignedReadUrlCacheEntry,
} from './signedReadUrlCache';
describe('signedReadUrlCache', () => {
test('只在签名地址距离过期超过安全窗口时复用', () => {
const nowMs = Date.parse('2099-01-01T00:00:00Z');
const entry = {
signedUrl: 'https://signed.example.com/frame.png',
expiresAtMs: parseSignedReadUrlExpiresAtMs('2099-01-01T00:05:00Z'),
};
expect(shouldReuseSignedReadUrlCacheEntry(entry, nowMs)).toBe(true);
expect(shouldReuseSignedReadUrlCacheEntry(entry, nowMs + 269_999)).toBe(
true,
);
expect(shouldReuseSignedReadUrlCacheEntry(entry, nowMs + 270_000)).toBe(
false,
);
});
});
@@ -0,0 +1,26 @@
export const SIGNED_READ_URL_CACHE_SAFETY_WINDOW_MS = 30 * 1000;
export type SignedReadUrlCacheEntry = {
signedUrl: string;
expiresAtMs: number;
};
export function parseSignedReadUrlExpiresAtMs(expiresAt: string) {
if (!expiresAt) {
return 0;
}
const parsed = Date.parse(expiresAt);
return Number.isFinite(parsed) ? parsed : 0;
}
export function shouldReuseSignedReadUrlCacheEntry(
entry: SignedReadUrlCacheEntry | undefined,
nowMs = Date.now(),
) {
if (!entry) {
return false;
}
return entry.expiresAtMs - SIGNED_READ_URL_CACHE_SAFETY_WINDOW_MS > nowMs;
}
+12 -30
View File
@@ -2,6 +2,11 @@ import {
appendApiErrorRequestId,
parseApiErrorMessage,
} from '../../packages/shared/src/http';
import {
parseSignedReadUrlExpiresAtMs,
shouldReuseSignedReadUrlCacheEntry,
type SignedReadUrlCacheEntry,
} from '../../packages/shared/src/utils/signedReadUrlCache';
import {
ApiClientError,
type ApiRequestOptions,
@@ -45,18 +50,12 @@ export type AssetReadUrlResponse = {
expiresAt?: string;
};
type CachedReadUrlEntry = {
signedUrl: string;
expiresAtMs: number;
};
type CachedReadUrlFailureEntry = {
expiresAtMs: number;
};
const ASSET_READ_URL_API_PATH = '/api/assets/read-url';
const ASSET_READ_BYTES_API_PATH = '/api/assets/read-bytes';
const DEFAULT_CACHE_SAFETY_WINDOW_MS = 30 * 1000;
const DEFAULT_FAILURE_CACHE_WINDOW_MS = 60 * 1000;
const ASSET_READ_URL_BACKGROUND_OPTIONS =
BACKGROUND_AUTH_REQUEST_OPTIONS satisfies ApiRequestOptions;
@@ -64,7 +63,7 @@ const SIGNED_READ_URL_SESSION_CACHE_PREFIX =
'genarrative.assetReadUrlCache.v1:';
const SIGNED_READ_URL_INITIAL_DISPATCH_BURST = 24;
const SIGNED_READ_URL_DISPATCH_SPACING_MS = 16;
const signedReadUrlCache = new Map<string, CachedReadUrlEntry>();
const signedReadUrlCache = new Map<string, SignedReadUrlCacheEntry>();
const signedReadUrlFailureCache = new Map<string, CachedReadUrlFailureEntry>();
const pendingSignedReadUrlRequests = new Map<string, Promise<string>>();
let signedReadUrlDispatchBurstUsed = 0;
@@ -173,23 +172,6 @@ function resolveSignedReadPayload(response: AssetReadUrlResponse) {
};
}
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();
}
function shouldReuseCachedReadUrlFailure(
entry: CachedReadUrlFailureEntry | undefined,
) {
@@ -214,7 +196,7 @@ function getSignedReadUrlSessionStorage() {
function readSignedUrlSessionCache(
cacheKey: string,
): CachedReadUrlEntry | undefined {
): SignedReadUrlCacheEntry | undefined {
const storage = getSignedReadUrlSessionStorage();
if (!storage || !cacheKey) {
return undefined;
@@ -225,7 +207,7 @@ function readSignedUrlSessionCache(
if (!rawValue) {
return undefined;
}
const parsed = JSON.parse(rawValue) as Partial<CachedReadUrlEntry>;
const parsed = JSON.parse(rawValue) as Partial<SignedReadUrlCacheEntry>;
if (
typeof parsed.signedUrl !== 'string' ||
typeof parsed.expiresAtMs !== 'number' ||
@@ -250,7 +232,7 @@ function readSignedUrlSessionCache(
function writeSignedUrlSessionCache(
cacheKey: string,
entry: CachedReadUrlEntry,
entry: SignedReadUrlCacheEntry,
) {
const storage = getSignedReadUrlSessionStorage();
if (!storage || !cacheKey) {
@@ -358,12 +340,12 @@ export async function getSignedAssetReadUrl(
const bypassCache = options.bypassCache === true;
const cached =
!bypassCache && cacheKey ? signedReadUrlCache.get(cacheKey) : undefined;
if (cached && shouldReuseCachedReadUrl(cached)) {
if (cached && shouldReuseSignedReadUrlCacheEntry(cached)) {
return cached.signedUrl;
}
const sessionCached =
!bypassCache && cacheKey ? readSignedUrlSessionCache(cacheKey) : undefined;
if (sessionCached && shouldReuseCachedReadUrl(sessionCached)) {
if (sessionCached && shouldReuseSignedReadUrlCacheEntry(sessionCached)) {
signedReadUrlCache.set(cacheKey, sessionCached);
return sessionCached.signedUrl;
}
@@ -400,7 +382,7 @@ export async function getSignedAssetReadUrl(
},
);
const payload = resolveSignedReadPayload(response);
const expiresAtMs = parseExpiresAtMs(payload.expiresAt);
const expiresAtMs = parseSignedReadUrlExpiresAtMs(payload.expiresAt);
if (cacheKey) {
signedReadUrlFailureCache.delete(cacheKey);