Files
Genarrative/src/services/assetReadUrlService.ts
2026-05-28 15:42:46 +08:00

361 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
appendApiErrorRequestId,
parseApiErrorMessage,
} from '../../packages/shared/src/http';
import {
ApiClientError,
BACKGROUND_AUTH_REQUEST_OPTIONS,
type ApiRequestOptions,
fetchWithApiAuth,
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;
};
type AssetReadBytesOptions = {
signal?: AbortSignal;
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 ASSET_READ_BYTES_API_PATH = '/api/assets/read-bytes';
const DEFAULT_CACHE_SAFETY_WINDOW_MS = 30 * 1000;
const DEFAULT_FAILURE_CACHE_WINDOW_MS = 60 * 1000;
const ASSET_READ_URL_BACKGROUND_OPTIONS =
BACKGROUND_AUTH_REQUEST_OPTIONS satisfies ApiRequestOptions;
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 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 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 = buildAssetReadSearchParams(request);
try {
const response = await requestJson<AssetReadUrlResponse>(
`${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 async function readAssetBytes(
source: string | null | undefined,
options: AssetReadBytesOptions = {},
) {
const value = source?.trim() ?? '';
if (!value) {
throw new Error('资源路径不能为空');
}
if (!isGeneratedLegacyPath(value)) {
const response = await fetch(value, { signal: options.signal });
if (!response.ok) {
throw new Error('读取资源内容失败');
}
return response;
}
// 中文注释:这里要拿图片字节转 Data URL不能直接 fetch OSS 签名 URL否则浏览器会受 bucket CORS 限制。
const searchParams = buildAssetReadSearchParams({
legacyPublicPath: value,
expireSeconds: options.expireSeconds,
});
const response = await fetchWithApiAuth(
`${ASSET_READ_BYTES_API_PATH}?${searchParams.toString()}`,
{
method: 'GET',
signal: options.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();
}