276 lines
7.2 KiB
TypeScript
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();
|
|
}
|