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

缓存序列帧换签响应的过期时间并复用统一安全窗口
临近过期时自动换签并保持已就绪帧可见
补充跨五分钟回绕播放与缓存边界回归测试
同步后台素材预览架构约束修复 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;