Files
Genarrative/src/services/assetReadUrlService.ts
2026-04-27 14:23:19 +08:00

276 lines
7.2 KiB
TypeScript

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<string, CachedReadUrlEntry>();
const signedReadUrlFailureCache = new Map<string, CachedReadUrlFailureEntry>();
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();
}
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<AssetReadUrlResponse>(
`${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();
}