修复后台动作预览提前换签失败
Project CI / Repository checks (pull_request) Successful in 1m10s
Project CI / Backend tests (pull_request) Successful in 4m10s
Project CI / Native shell tests (pull_request) Successful in 12m47s
Project CI / Frontend tests (pull_request) Successful in 2m37s

提前换签失败时保留真实过期前的已就绪帧并执行有界退避重试
仅在无可用缓存或图片解码失败时标记失败
补充后台角色动作回归测试并同步数据契约文档
This commit is contained in:
2026-08-06 16:20:38 +08:00
parent fcb67bc4b0
commit f52947d771
3 changed files with 139 additions and 17 deletions
@@ -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