This commit is contained in:
232
src/services/assetReadUrlService.ts
Normal file
232
src/services/assetReadUrlService.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { ApiClientError, requestJson } from './apiClient';
|
||||
|
||||
export type AssetReadUrlRequest = {
|
||||
objectKey?: string;
|
||||
legacyPublicPath?: string;
|
||||
expireSeconds?: number;
|
||||
};
|
||||
|
||||
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,
|
||||
) {
|
||||
const cacheKey = buildCacheKey(request);
|
||||
const cached = cacheKey ? signedReadUrlCache.get(cacheKey) : undefined;
|
||||
if (cached && shouldReuseCachedReadUrl(cached)) {
|
||||
return cached.signedUrl;
|
||||
}
|
||||
|
||||
const cachedFailure = cacheKey
|
||||
? signedReadUrlFailureCache.get(cacheKey)
|
||||
: undefined;
|
||||
if (cachedFailure && shouldReuseCachedReadUrlFailure(cachedFailure)) {
|
||||
throw new Error('资源不存在或暂时不可读取');
|
||||
}
|
||||
|
||||
if (cacheKey) {
|
||||
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) {
|
||||
pendingSignedReadUrlRequests.set(cacheKey, requestPromise);
|
||||
}
|
||||
|
||||
try {
|
||||
return await requestPromise;
|
||||
} finally {
|
||||
if (cacheKey) {
|
||||
pendingSignedReadUrlRequests.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容层:普通 http(s)/data/blob 路径原样返回;历史 generated-* 路径自动换签名读 URL。
|
||||
export async function resolveAssetReadUrl(
|
||||
source: string | null | undefined,
|
||||
options: {
|
||||
signal?: AbortSignal;
|
||||
expireSeconds?: number;
|
||||
} = {},
|
||||
) {
|
||||
const value = source?.trim() ?? '';
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (
|
||||
/^(?:https?:)?\/\//u.test(value) ||
|
||||
value.startsWith('data:') ||
|
||||
value.startsWith('blob:')
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (isGeneratedLegacyPath(value)) {
|
||||
return getSignedAssetReadUrl(
|
||||
{
|
||||
legacyPublicPath: value,
|
||||
expireSeconds: options.expireSeconds,
|
||||
},
|
||||
options.signal,
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function clearSignedAssetReadUrlCache() {
|
||||
signedReadUrlCache.clear();
|
||||
signedReadUrlFailureCache.clear();
|
||||
pendingSignedReadUrlRequests.clear();
|
||||
}
|
||||
Reference in New Issue
Block a user