修复后台动作预览提前换签失败
提前换签失败时保留真实过期前的已就绪帧并执行有界退避重试 仅在无可用缓存或图片解码失败时标记失败 补充后台角色动作回归测试并同步数据契约文档
This commit is contained in:
@@ -252,6 +252,85 @@ test('角色动作预览打开超过五分钟后回绕播放会在过期窗口
|
||||
expect(within(dialog).queryByText(/帧加载失败/u)).toBeNull();
|
||||
});
|
||||
|
||||
test('角色动作预览提前换签失败时保留已就绪帧并有限重试', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2099-01-01T00:00:00Z'));
|
||||
const frameObjectKeys = [
|
||||
'generated-animations/editor/task-refresh-failure/frame00.png',
|
||||
'generated-animations/editor/task-refresh-failure/frame01.png',
|
||||
] as const;
|
||||
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);
|
||||
if (objectKey === frameObjectKeys[0] && requestCount > 1) {
|
||||
return Promise.reject(new Error('refresh unavailable'));
|
||||
}
|
||||
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-refresh-failure',
|
||||
label: '换签失败动作',
|
||||
imageSrc: `/${frameObjectKeys[0]}`,
|
||||
objectKey: frameObjectKeys[0],
|
||||
assetKind: 'character-animation',
|
||||
imageSequenceFrames: frameObjectKeys.map((objectKey) => ({
|
||||
imageSrc: `/${objectKey}`,
|
||||
objectKey,
|
||||
width: 192,
|
||||
height: 256,
|
||||
})),
|
||||
imageSequenceDurationMs: 2_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);
|
||||
const readyFrameSrc = resolveVisibleFrameSrc(dialog);
|
||||
expect(readyFrameSrc).toBe(
|
||||
`https://signed.example.com/v1/${frameObjectKeys[0]}`,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(270_100);
|
||||
});
|
||||
expect(resolveVisibleFrameSrc(dialog)).toBe(readyFrameSrc);
|
||||
expect(within(dialog).queryByText(/帧加载失败/u)).toBeNull();
|
||||
|
||||
for (const retryWindowMs of [450, 1_250, 3_050]) {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(retryWindowMs);
|
||||
});
|
||||
expect(resolveVisibleFrameSrc(dialog)).toBe(readyFrameSrc);
|
||||
}
|
||||
expect(requestCounts.get(frameObjectKeys[0])).toBe(5);
|
||||
expect(within(dialog).queryByText(/帧加载失败/u)).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
});
|
||||
expect(requestCounts.get(frameObjectKeys[0])).toBe(5);
|
||||
expect(resolveVisibleFrameSrc(dialog)).toBe(readyFrameSrc);
|
||||
});
|
||||
|
||||
function loadMountedFrames(dialog: HTMLElement) {
|
||||
dialog
|
||||
.querySelectorAll<HTMLImageElement>('.admin-asset-query-sequence-frame')
|
||||
|
||||
@@ -3,7 +3,6 @@ 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';
|
||||
@@ -355,8 +354,10 @@ function AdminEditorImageSequencePreview({
|
||||
) {
|
||||
return earliest;
|
||||
}
|
||||
const candidate =
|
||||
cached.expiresAtMs - SIGNED_READ_URL_CACHE_SAFETY_WINDOW_MS;
|
||||
const candidate = cached.nextRefreshAtMs;
|
||||
if (candidate === null) {
|
||||
return earliest;
|
||||
}
|
||||
return earliest === null || candidate < earliest ? candidate : earliest;
|
||||
},
|
||||
null as number | null,
|
||||
@@ -481,6 +482,8 @@ type AdminImageSequenceFrameItem = {
|
||||
|
||||
type AdminImageSequenceFrameCacheEntry = {
|
||||
expiresAtMs: number | null;
|
||||
nextRefreshAtMs: number | null;
|
||||
refreshRetryIndex: number;
|
||||
resolvedUrl: string;
|
||||
status: 'resolving' | 'refreshing' | 'loading' | 'ready' | 'failed';
|
||||
};
|
||||
@@ -574,6 +577,8 @@ function useAdminImageSequenceFrameCache(
|
||||
? { ...current, status: 'refreshing' }
|
||||
: {
|
||||
expiresAtMs: null,
|
||||
nextRefreshAtMs: null,
|
||||
refreshRetryIndex: 0,
|
||||
resolvedUrl: '',
|
||||
status: 'resolving',
|
||||
},
|
||||
@@ -587,16 +592,49 @@ function useAdminImageSequenceFrameCache(
|
||||
.then((resolved) => {
|
||||
updateFrameCacheEntry(item.key, () => ({
|
||||
...resolved,
|
||||
nextRefreshAtMs:
|
||||
resolved.expiresAtMs === null
|
||||
? null
|
||||
: resolved.expiresAtMs -
|
||||
SIGNED_READ_URL_CACHE_SAFETY_WINDOW_MS,
|
||||
refreshRetryIndex: 0,
|
||||
status: refreshReadyFrame ? 'refreshing' : 'loading',
|
||||
}));
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((error: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
updateFrameCacheEntry(item.key, () => ({
|
||||
expiresAtMs: null,
|
||||
resolvedUrl: '',
|
||||
status: 'failed',
|
||||
}));
|
||||
updateFrameCacheEntry(item.key, (current) => {
|
||||
if (!isAdminImageSequenceFrameUrlUsable(current)) {
|
||||
return {
|
||||
expiresAtMs: null,
|
||||
nextRefreshAtMs: null,
|
||||
refreshRetryIndex: 0,
|
||||
resolvedUrl: '',
|
||||
status: 'failed',
|
||||
};
|
||||
}
|
||||
const retryDelay =
|
||||
isAdminApiError(error) && error.status === 429
|
||||
? undefined
|
||||
: ADMIN_ASSET_READ_RETRY_DELAYS_MS[
|
||||
current.refreshRetryIndex
|
||||
];
|
||||
return {
|
||||
...current,
|
||||
nextRefreshAtMs:
|
||||
typeof retryDelay === 'number'
|
||||
? Math.min(
|
||||
current.expiresAtMs ?? Number.POSITIVE_INFINITY,
|
||||
Date.now() + retryDelay,
|
||||
)
|
||||
: current.expiresAtMs,
|
||||
refreshRetryIndex:
|
||||
typeof retryDelay === 'number'
|
||||
? current.refreshRetryIndex + 1
|
||||
: current.refreshRetryIndex,
|
||||
status: 'ready',
|
||||
};
|
||||
});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -626,6 +664,8 @@ function useAdminImageSequenceFrameCache(
|
||||
resolutionControllersRef.current.delete(frameKey);
|
||||
updateFrameCacheEntry(frameKey, () => ({
|
||||
expiresAtMs: null,
|
||||
nextRefreshAtMs: null,
|
||||
refreshRetryIndex: 0,
|
||||
resolvedUrl: '',
|
||||
status: 'failed',
|
||||
}));
|
||||
@@ -668,13 +708,16 @@ function shouldReuseAdminImageSequenceFrameCacheEntry(
|
||||
if (cached.status === 'failed') {
|
||||
return false;
|
||||
}
|
||||
if (cached.expiresAtMs === null) {
|
||||
return true;
|
||||
}
|
||||
return shouldReuseSignedReadUrlCacheEntry({
|
||||
signedUrl: cached.resolvedUrl,
|
||||
expiresAtMs: cached.expiresAtMs,
|
||||
});
|
||||
return cached.nextRefreshAtMs === null || cached.nextRefreshAtMs > Date.now();
|
||||
}
|
||||
|
||||
function isAdminImageSequenceFrameUrlUsable(
|
||||
cached: AdminImageSequenceFrameCacheEntry | undefined,
|
||||
): cached is AdminImageSequenceFrameCacheEntry {
|
||||
return Boolean(
|
||||
cached?.resolvedUrl &&
|
||||
(cached.expiresAtMs === null || cached.expiresAtMs > Date.now()),
|
||||
);
|
||||
}
|
||||
|
||||
function findNextAvailableFrameIndex(
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user