import { appendApiErrorRequestId, parseApiErrorMessage, } from '../../packages/shared/src/http'; import { parseSignedReadUrlExpiresAtMs, shouldReuseSignedReadUrlCacheEntry, type SignedReadUrlCacheEntry, } from '../../packages/shared/src/utils/signedReadUrlCache'; import { ApiClientError, type ApiRequestOptions, BACKGROUND_AUTH_REQUEST_OPTIONS, fetchWithApiAuth, requestJson, } from './apiClient'; export type AssetReadUrlRequest = { objectKey?: string; legacyPublicPath?: string; expireSeconds?: number; }; type AssetReadUrlResolveOptions = { signal?: AbortSignal; expireSeconds?: number; /** * 图片内容可能在同一路径下被重新写入。 * 对 generated 私有资源作为签名 URL 缓存版本维度; * 同一路径和同一 refreshKey 继续复用 signed URL,refreshKey 变化才重新换签。 * 普通非签名 URL 仍可追加 `_v` 避免浏览器图片缓存。 */ refreshKey?: string | number | null; }; type AssetReadBytesOptions = { signal?: AbortSignal; expireSeconds?: number; objectKey?: string | null; }; export type AssetReadUrlResponse = { read?: { objectKey?: string; signedUrl?: string; expiresAt?: string; }; signedUrl?: string; objectKey?: string; expiresAt?: string; }; 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_FAILURE_CACHE_WINDOW_MS = 60 * 1000; const ASSET_READ_URL_BACKGROUND_OPTIONS = BACKGROUND_AUTH_REQUEST_OPTIONS satisfies ApiRequestOptions; 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(); const signedReadUrlFailureCache = new Map(); const pendingSignedReadUrlRequests = new Map>(); let signedReadUrlDispatchBurstUsed = 0; let signedReadUrlNextDispatchAtMs = 0; export function isGeneratedLegacyPath(value: string) { return /^\/?generated-[^/?#]+\/.+/u.test(value.trim()); } function isAliyunOssHost(hostname: string) { return /^[^.]+\.oss-[^.]+\.aliyuncs\.com$/iu.test(hostname.trim()); } function resolveGeneratedLegacyPathFromUrl(value: string) { try { const parsedUrl = new URL( value, globalThis.location?.origin ?? 'http://localhost', ); if (!isAliyunOssHost(parsedUrl.hostname)) { return ''; } const legacyPath = decodeURIComponent(parsedUrl.pathname); return isGeneratedLegacyPath(legacyPath) ? legacyPath : ''; } catch { return ''; } } export function shouldResolveAssetReadUrl(source: string | null | undefined) { const value = source?.trim() ?? ''; return ( Boolean(value) && (isGeneratedLegacyPath(value) || Boolean(resolveGeneratedLegacyPathFromUrl(value))) ); } export function hasReadableAssetSource( source: string | null | undefined, objectKey?: string | null, ) { return Boolean( source?.trim() || objectKey?.trim().replace(/^\/+/u, ''), ); } 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 normalizeReadUrlCacheVersion(value: string | number | null | undefined) { if (value === null || value === undefined) { return ''; } return String(value).trim(); } function buildVersionedCacheKey( cacheKey: string, cacheVersion: string | number | null | undefined, ) { const normalizedVersion = normalizeReadUrlCacheVersion(cacheVersion); return cacheKey && normalizedVersion ? `${cacheKey}:version:${encodeURIComponent(normalizedVersion)}` : cacheKey; } function buildAssetReadSearchParams(request: AssetReadUrlRequest) { 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))); } return searchParams; } 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 shouldReuseCachedReadUrlFailure( entry: CachedReadUrlFailureEntry | undefined, ) { if (!entry) { return false; } return entry.expiresAtMs > Date.now(); } function signedReadUrlSessionCacheKey(cacheKey: string) { return `${SIGNED_READ_URL_SESSION_CACHE_PREFIX}${encodeURIComponent(cacheKey)}`; } function getSignedReadUrlSessionStorage() { try { return globalThis.sessionStorage ?? null; } catch { return null; } } function readSignedUrlSessionCache( cacheKey: string, ): SignedReadUrlCacheEntry | undefined { const storage = getSignedReadUrlSessionStorage(); if (!storage || !cacheKey) { return undefined; } const storageKey = signedReadUrlSessionCacheKey(cacheKey); try { const rawValue = storage.getItem(storageKey); if (!rawValue) { return undefined; } const parsed = JSON.parse(rawValue) as Partial; if ( typeof parsed.signedUrl !== 'string' || typeof parsed.expiresAtMs !== 'number' || !Number.isFinite(parsed.expiresAtMs) ) { storage.removeItem(storageKey); return undefined; } return { signedUrl: parsed.signedUrl, expiresAtMs: parsed.expiresAtMs, }; } catch { try { storage.removeItem(storageKey); } catch { // ignore storage cleanup errors } return undefined; } } function writeSignedUrlSessionCache( cacheKey: string, entry: SignedReadUrlCacheEntry, ) { const storage = getSignedReadUrlSessionStorage(); if (!storage || !cacheKey) { return; } try { storage.setItem( signedReadUrlSessionCacheKey(cacheKey), JSON.stringify(entry), ); } catch { // ignore storage quota or privacy mode failures } } function resetSignedReadUrlDispatchLimiter() { signedReadUrlDispatchBurstUsed = 0; signedReadUrlNextDispatchAtMs = 0; } function reserveSignedReadUrlDispatchDelayMs(nowMs = Date.now()) { if (signedReadUrlNextDispatchAtMs < nowMs) { signedReadUrlDispatchBurstUsed = 0; signedReadUrlNextDispatchAtMs = nowMs; } if ( signedReadUrlDispatchBurstUsed < SIGNED_READ_URL_INITIAL_DISPATCH_BURST ) { signedReadUrlDispatchBurstUsed += 1; return 0; } const dispatchAtMs = Math.max( signedReadUrlNextDispatchAtMs + SIGNED_READ_URL_DISPATCH_SPACING_MS, nowMs + SIGNED_READ_URL_DISPATCH_SPACING_MS, ); signedReadUrlNextDispatchAtMs = dispatchAtMs; return Math.max(0, dispatchAtMs - nowMs); } function createSignedReadUrlAbortError() { if (typeof DOMException === 'function') { return new DOMException('The operation was aborted.', 'AbortError'); } return new Error('The operation was aborted.'); } async function waitForSignedReadUrlDispatch(signal?: AbortSignal) { if (signal?.aborted) { throw createSignedReadUrlAbortError(); } const delayMs = reserveSignedReadUrlDispatchDelayMs(); if (delayMs <= 0) { return; } await new Promise((resolve, reject) => { const timer = setTimeout(() => { signal?.removeEventListener('abort', handleAbort); resolve(); }, delayMs); function handleAbort() { clearTimeout(timer); reject(createSignedReadUrlAbortError()); } signal?.addEventListener('abort', handleAbort, { once: true }); }); } function clearSignedUrlSessionCache() { const storage = getSignedReadUrlSessionStorage(); if (!storage) { return; } try { const keys: string[] = []; for (let index = 0; index < storage.length; index += 1) { const key = storage.key(index); if (key?.startsWith(SIGNED_READ_URL_SESSION_CACHE_PREFIX)) { keys.push(key); } } keys.forEach((key) => storage.removeItem(key)); } catch { // ignore storage cleanup failures } } export async function getSignedAssetReadUrl( request: AssetReadUrlRequest, signal?: AbortSignal, options: { bypassCache?: boolean; cacheVersion?: string | number | null; } = {}, ) { const cacheKey = buildVersionedCacheKey( buildCacheKey(request), options.cacheVersion, ); const bypassCache = options.bypassCache === true; const cached = !bypassCache && cacheKey ? signedReadUrlCache.get(cacheKey) : undefined; if (cached && shouldReuseSignedReadUrlCacheEntry(cached)) { return cached.signedUrl; } const sessionCached = !bypassCache && cacheKey ? readSignedUrlSessionCache(cacheKey) : undefined; if (sessionCached && shouldReuseSignedReadUrlCacheEntry(sessionCached)) { signedReadUrlCache.set(cacheKey, sessionCached); return sessionCached.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 = buildAssetReadSearchParams(request); try { await waitForSignedReadUrlDispatch(signal); 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 = parseSignedReadUrlExpiresAtMs(payload.expiresAt); if (cacheKey) { signedReadUrlFailureCache.delete(cacheKey); } if (cacheKey && expiresAtMs > 0) { const entry = { signedUrl: payload.signedUrl, expiresAtMs, }; signedReadUrlCache.set(cacheKey, entry); writeSignedUrlSessionCache(cacheKey, entry); } 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 变化,形成新的签名缓存版本;同一版本继续复用 signed 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:') ) { const legacyPath = resolveGeneratedLegacyPathFromUrl(value); if (legacyPath) { const signedUrl = await getSignedAssetReadUrl( { legacyPublicPath: legacyPath, expireSeconds: options.expireSeconds, }, options.signal, { cacheVersion: options.refreshKey, }, ); return signedUrl; } return appendCacheBustParam(value, options.refreshKey); } if (isGeneratedLegacyPath(value)) { const signedUrl = await getSignedAssetReadUrl( { legacyPublicPath: value, expireSeconds: options.expireSeconds, }, options.signal, { cacheVersion: options.refreshKey, }, ); return signedUrl; } return appendCacheBustParam(value, options.refreshKey); } export async function readAssetBytes( source: string | null | undefined, options: AssetReadBytesOptions = {}, ) { const value = source?.trim() ?? ''; const objectKey = options.objectKey?.trim().replace(/^\/+/u, '') ?? ''; if (!hasReadableAssetSource(value, objectKey)) { throw new Error('资源路径不能为空'); } const legacyPath = isGeneratedLegacyPath(value) ? value : resolveGeneratedLegacyPathFromUrl(value); if (!objectKey && !legacyPath) { const response = await fetch(value, { signal: options.signal }); if (!response.ok) { throw new Error(`读取资源内容失败(HTTP ${response.status})`); } return response; } const readRequest = { objectKey, legacyPublicPath: objectKey ? undefined : legacyPath, expireSeconds: options.expireSeconds, }; try { const signedUrl = await getSignedAssetReadUrl(readRequest, options.signal); const response = await fetch(signedUrl, { signal: options.signal }); if (isCompleteAssetReadResponse(response)) { // OSS 可能先返回 200/206 响应头、再在读取响应体时失败。必须在这里完整消费响应体, // 才能让读取失败进入同源字节代理兜底;返回新 Response,避免调用方拿到已消费的响应体。 const body = await response.blob(); return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers, }); } } catch { if (options.signal?.aborted) { throw createSignedReadUrlAbortError(); } // 中文注释:浏览器直读 OSS 失败时再走同源字节代理兜底。 } return readAssetBytesViaFallbackApi(readRequest, options.signal); } function isCompleteAssetReadResponse(response: Response) { if (response.status === 200) { return true; } if (response.status !== 206) { return false; } // 全量字节读取不能把媒体预览或浏览器缓存留下的局部分片当成完整素材。 // 只有 Content-Range 明确覆盖 0..total-1 时才接受 206;跨域未暴露该头时安全回退同源代理。 const contentRange = response.headers.get('content-range')?.trim() ?? ''; const match = /^bytes\s+0-(\d+)\/(\d+)$/iu.exec(contentRange); if (!match) { return false; } const end = Number(match[1]); const total = Number(match[2]); return ( Number.isSafeInteger(end) && Number.isSafeInteger(total) && total > 0 && end + 1 === total ); } async function readAssetBytesViaFallbackApi( request: AssetReadUrlRequest, signal?: AbortSignal, ) { const searchParams = buildAssetReadSearchParams({ objectKey: request.objectKey, legacyPublicPath: request.legacyPublicPath, expireSeconds: request.expireSeconds, }); const response = await fetchWithApiAuth( `${ASSET_READ_BYTES_API_PATH}?${searchParams.toString()}`, { method: 'GET', signal, }, { ...ASSET_READ_URL_BACKGROUND_OPTIONS, omitEnvelopeHeader: true, }, ); if (!response.ok) { const message = await response .text() .then((text) => appendApiErrorRequestId( parseApiErrorMessage(text, '读取资源内容失败'), response.headers.get('x-request-id'), ), ) .catch(() => ''); throw new Error(message || '读取资源内容失败'); } return response; } export function clearSignedAssetReadUrlCache() { signedReadUrlCache.clear(); signedReadUrlFailureCache.clear(); pendingSignedReadUrlRequests.clear(); resetSignedReadUrlDispatchLimiter(); clearSignedUrlSessionCache(); }