feat: 前端改为通过签名地址读取生成资源

This commit is contained in:
2026-04-21 16:45:05 +08:00
parent fcaf7bdb38
commit 78dcad1222
26 changed files with 779 additions and 76 deletions

View File

@@ -0,0 +1,113 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import {
clearSignedAssetReadUrlCache,
getSignedAssetReadUrl,
resolveAssetReadUrl,
} from './assetReadUrlService';
describe('assetReadUrlService', () => {
beforeEach(() => {
clearSignedAssetReadUrlCache();
vi.restoreAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
test('resolveAssetReadUrl returns passthrough for absolute url', async () => {
await expect(resolveAssetReadUrl('https://example.com/demo.png')).resolves.toBe(
'https://example.com/demo.png',
);
});
test('resolveAssetReadUrl returns passthrough for data url', async () => {
await expect(resolveAssetReadUrl('data:image/png;base64,abc')).resolves.toBe(
'data:image/png;base64,abc',
);
});
test('resolveAssetReadUrl exchanges legacy generated path for signed url', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({
ok: true,
data: {
read: {
objectKey: 'generated-characters/hero/visual/asset-01/master.png',
signedUrl: 'https://signed.example.com/master.png',
expiresAt: '2099-01-01T00:10:00Z',
},
},
error: null,
meta: {
apiVersion: '2026-04-08',
routeVersion: '2026-04-08',
latencyMs: 1,
timestamp: '2099-01-01T00:00:00Z',
},
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
},
),
);
await expect(
resolveAssetReadUrl('/generated-characters/hero/visual/asset-01/master.png'),
).resolves.toBe('https://signed.example.com/master.png');
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
'/api/assets/read-url?',
);
});
test('getSignedAssetReadUrl reuses cached signed url before expiry', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2099-01-01T00:00:00Z'));
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({
ok: true,
data: {
read: {
objectKey: 'generated-custom-world-scenes/profile-1/landmark-1/scene.png',
signedUrl: 'https://signed.example.com/scene.png',
expiresAt: '2099-01-01T00:10:00Z',
},
},
error: null,
meta: {
apiVersion: '2026-04-08',
routeVersion: '2026-04-08',
latencyMs: 1,
timestamp: '2099-01-01T00:00:00Z',
},
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
},
),
);
const first = await getSignedAssetReadUrl({
legacyPublicPath: '/generated-custom-world-scenes/profile-1/landmark-1/scene.png',
});
const second = await getSignedAssetReadUrl({
legacyPublicPath: '/generated-custom-world-scenes/profile-1/landmark-1/scene.png',
});
expect(first).toBe('https://signed.example.com/scene.png');
expect(second).toBe(first);
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,191 @@
import { requestJson } from './apiClient';
export type AssetReadUrlRequest = {
objectKey?: string;
legacyPublicPath?: string;
expireSeconds?: number;
};
export type AssetReadUrlResponse = {
read?: {
objectKey?: string;
signedUrl?: string;
expiresAt?: string;
};
signedUrl?: string;
objectKey?: string;
expiresAt?: string;
};
type CachedReadUrlEntry = {
signedUrl: string;
expiresAtMs: number;
};
const ASSET_READ_URL_API_PATH = '/api/assets/read-url';
const DEFAULT_CACHE_SAFETY_WINDOW_MS = 30 * 1000;
const signedReadUrlCache = new Map<string, CachedReadUrlEntry>();
const pendingSignedReadUrlRequests = new Map<string, Promise<string>>();
export function isGeneratedLegacyPath(value: string) {
return /^\/generated-[^/?#]+\/.+/u.test(value.trim());
}
function normalizeLegacyPublicPath(value: string) {
return `/${value.trim().replace(/^\/+/u, '')}`;
}
function buildCacheKey(request: AssetReadUrlRequest) {
if (request.objectKey?.trim()) {
return `object:${request.objectKey.trim().replace(/^\/+/u, '')}`;
}
if (request.legacyPublicPath?.trim()) {
return `legacy:${normalizeLegacyPublicPath(request.legacyPublicPath)}`;
}
return '';
}
function resolveSignedReadPayload(response: AssetReadUrlResponse) {
const read = response.read ?? response;
const signedUrl = typeof read.signedUrl === 'string' ? read.signedUrl.trim() : '';
const expiresAt = typeof read.expiresAt === 'string' ? read.expiresAt.trim() : '';
const objectKey = typeof read.objectKey === 'string' ? read.objectKey.trim() : '';
if (!signedUrl) {
throw new Error('资源访问地址缺失');
}
return {
signedUrl,
expiresAt,
objectKey,
};
}
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();
}
export async function getSignedAssetReadUrl(
request: AssetReadUrlRequest,
signal?: AbortSignal,
) {
const cacheKey = buildCacheKey(request);
const cached = cacheKey ? signedReadUrlCache.get(cacheKey) : undefined;
if (cached && shouldReuseCachedReadUrl(cached)) {
return cached.signedUrl;
}
if (cacheKey) {
const pendingRequest = pendingSignedReadUrlRequests.get(cacheKey);
if (pendingRequest) {
return pendingRequest;
}
}
const requestPromise = (async () => {
const searchParams = new URLSearchParams();
if (request.objectKey?.trim()) {
searchParams.set('objectKey', request.objectKey.trim().replace(/^\/+/u, ''));
}
if (request.legacyPublicPath?.trim()) {
searchParams.set(
'legacyPublicPath',
normalizeLegacyPublicPath(request.legacyPublicPath),
);
}
if (
typeof request.expireSeconds === 'number' &&
Number.isFinite(request.expireSeconds) &&
request.expireSeconds > 0
) {
searchParams.set('expireSeconds', String(Math.floor(request.expireSeconds)));
}
const response = await requestJson<AssetReadUrlResponse>(
`${ASSET_READ_URL_API_PATH}?${searchParams.toString()}`,
{
method: 'GET',
signal,
},
'获取资源访问地址失败',
);
const payload = resolveSignedReadPayload(response);
const expiresAtMs = parseExpiresAtMs(payload.expiresAt);
if (cacheKey && expiresAtMs > 0) {
signedReadUrlCache.set(cacheKey, {
signedUrl: payload.signedUrl,
expiresAtMs,
});
}
return payload.signedUrl;
})();
if (cacheKey) {
pendingSignedReadUrlRequests.set(cacheKey, requestPromise);
}
try {
return await requestPromise;
} finally {
if (cacheKey) {
pendingSignedReadUrlRequests.delete(cacheKey);
}
}
}
// 兼容层:普通 http(s)/data/blob 路径原样返回;历史 generated-* 路径自动换签名读 URL。
export async function resolveAssetReadUrl(
source: string | null | undefined,
options: {
signal?: AbortSignal;
expireSeconds?: number;
} = {},
) {
const value = source?.trim() ?? '';
if (!value) {
return '';
}
if (
/^(?:https?:)?\/\//u.test(value) ||
value.startsWith('data:') ||
value.startsWith('blob:')
) {
return value;
}
if (isGeneratedLegacyPath(value)) {
return getSignedAssetReadUrl(
{
legacyPublicPath: value,
expireSeconds: options.expireSeconds,
},
options.signal,
);
}
return value;
}
export function clearSignedAssetReadUrlCache() {
signedReadUrlCache.clear();
pendingSignedReadUrlRequests.clear();
}