import { ApiClientError, requestJson } from './apiClient'; export type AssetReadUrlRequest = { objectKey?: string; legacyPublicPath?: string; expireSeconds?: number; }; type AssetReadUrlResolveOptions = { signal?: AbortSignal; expireSeconds?: number; /** * 图片内容可能在同一路径下被重新写入。 * 这时需要显式跳过本地签名缓存,并在最终 URL 上追加一次性参数, * 避免结果页仍命中旧签名地址或浏览器图片缓存。 */ refreshKey?: string | number | null; }; export type AssetReadUrlResponse = { read?: { objectKey?: string; signedUrl?: string; expiresAt?: string; }; signedUrl?: string; objectKey?: string; expiresAt?: string; }; type CachedReadUrlEntry = { signedUrl: string; expiresAtMs: number; }; type CachedReadUrlFailureEntry = { expiresAtMs: number; }; const ASSET_READ_URL_API_PATH = '/api/assets/read-url'; const DEFAULT_CACHE_SAFETY_WINDOW_MS = 30 * 1000; const DEFAULT_FAILURE_CACHE_WINDOW_MS = 60 * 1000; const signedReadUrlCache = new Map(); const signedReadUrlFailureCache = new Map(); const pendingSignedReadUrlRequests = new Map>(); 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(); } function shouldReuseCachedReadUrlFailure( entry: CachedReadUrlFailureEntry | undefined, ) { if (!entry) { return false; } return entry.expiresAtMs > Date.now(); } export async function getSignedAssetReadUrl( request: AssetReadUrlRequest, signal?: AbortSignal, options: { bypassCache?: boolean; } = {}, ) { const cacheKey = buildCacheKey(request); const bypassCache = options.bypassCache === true; const cached = !bypassCache && cacheKey ? signedReadUrlCache.get(cacheKey) : undefined; if (cached && shouldReuseCachedReadUrl(cached)) { return cached.signedUrl; } const cachedFailure = !bypassCache && cacheKey ? signedReadUrlFailureCache.get(cacheKey) : undefined; if (cachedFailure && shouldReuseCachedReadUrlFailure(cachedFailure)) { throw new Error('资源不存在或暂时不可读取'); } if (cacheKey && !bypassCache) { 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))); } try { const response = await requestJson( `${ASSET_READ_URL_API_PATH}?${searchParams.toString()}`, { method: 'GET', signal, }, '获取资源访问地址失败', ); const payload = resolveSignedReadPayload(response); const expiresAtMs = parseExpiresAtMs(payload.expiresAt); if (cacheKey) { signedReadUrlFailureCache.delete(cacheKey); } if (cacheKey && expiresAtMs > 0) { signedReadUrlCache.set(cacheKey, { signedUrl: payload.signedUrl, expiresAtMs, }); } return payload.signedUrl; } catch (error) { if ( cacheKey && error instanceof ApiClientError && error.status === 404 ) { signedReadUrlFailureCache.set(cacheKey, { expiresAtMs: Date.now() + DEFAULT_FAILURE_CACHE_WINDOW_MS, }); } throw error; } })(); if (cacheKey && !bypassCache) { pendingSignedReadUrlRequests.set(cacheKey, requestPromise); } try { return await requestPromise; } finally { if (cacheKey && !bypassCache) { pendingSignedReadUrlRequests.delete(cacheKey); } } } function appendCacheBustParam( url: string, refreshKey: string | number | null | undefined, ) { const normalizedRefreshKey = refreshKey === null || refreshKey === undefined ? '' : String(refreshKey).trim(); if (!normalizedRefreshKey) { return url; } try { const parsedUrl = new URL(url, globalThis.location?.origin ?? 'http://localhost'); parsedUrl.searchParams.set('_v', normalizedRefreshKey); if (/^(?:https?:)?\/\//u.test(url)) { return parsedUrl.toString(); } return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`; } catch { const separator = url.includes('?') ? '&' : '?'; return `${url}${separator}_v=${encodeURIComponent(normalizedRefreshKey)}`; } } // 兼容层:普通 http(s)/data/blob 路径原样返回;历史 generated-* 路径自动换签名读 URL。 export async function resolveAssetReadUrl( source: string | null | undefined, options: AssetReadUrlResolveOptions = {}, ) { const value = source?.trim() ?? ''; if (!value) { return ''; } if ( /^(?:https?:)?\/\//u.test(value) || value.startsWith('data:') || value.startsWith('blob:') ) { return appendCacheBustParam(value, options.refreshKey); } if (isGeneratedLegacyPath(value)) { const signedUrl = await getSignedAssetReadUrl( { legacyPublicPath: value, expireSeconds: options.expireSeconds, }, options.signal, { bypassCache: options.refreshKey !== null && options.refreshKey !== undefined, }, ); return appendCacheBustParam(signedUrl, options.refreshKey); } return appendCacheBustParam(value, options.refreshKey); } export function clearSignedAssetReadUrlCache() { signedReadUrlCache.clear(); signedReadUrlFailureCache.clear(); pendingSignedReadUrlRequests.clear(); }