修复后台动作预览签名地址过期复用
缓存序列帧换签响应的过期时间并复用统一安全窗口 临近过期时自动换签并保持已就绪帧可见 补充跨五分钟回绕播放与缓存边界回归测试 同步后台素材预览架构约束修复 migration blocker。
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user