import { ApiClientError, type ApiRequestOptions, requestJson, } from './apiClient'; export type AssetReadUrlRequest = { objectKey?: string; legacyPublicPath?: string; expireSeconds?: number; }; type AssetReadUrlResolveOptions = { signal?: AbortSignal; expireSeconds?: number; /** * 图片内容可能在同一路径下被重新写入。 * 对 generated 私有资源只跳过本地签名缓存并重新换签, * 不能给 OSS V4 签名 URL 追加一次性参数。 * 普通非签名 URL 仍可追加 `_v` 避免浏览器图片缓存。 */ 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 ASSET_READ_URL_BACKGROUND_OPTIONS = { skipRefresh: true, notifyAuthStateChange: false, clearAuthOnUnauthorized: false, } satisfies ApiRequestOptions; 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, }, '获取资源访问地址失败', { // 中文注释:图片换签属于展示层后台请求,失败只影响当前图片,不应刷新或清空全局登录态。 ...ASSET_READ_URL_BACKGROUND_OPTIONS, }, ); 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; } // OSS V4 签名会把 query 纳入签名计算,前端不能追加 `_v` 之类的缓存参数。 // 需要刷新时通过 refreshKey 绕过本地签名缓存,重新请求 `/api/assets/read-url`。 if (/[?&]x-oss-signature(?:=|&|$)/u.test(url)) { return url; } try { const parsedUrl = new URL(url, globalThis.location?.origin ?? 'http://localhost'); if (parsedUrl.searchParams.has('x-oss-signature')) { return url; } 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 signedUrl; } return appendCacheBustParam(value, options.refreshKey); } export function clearSignedAssetReadUrlCache() { signedReadUrlCache.clear(); signedReadUrlFailureCache.clear(); pendingSignedReadUrlRequests.clear(); }