361 lines
9.6 KiB
TypeScript
361 lines
9.6 KiB
TypeScript
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();
|
||
}
|