Files
Genarrative/src/services/assetReadUrlService.ts
T
kdletters c5295a619c 收紧生成任务轮询与资产读取路径
空生成任务列表不再保持四秒轮询

读取资源字节优先通过read-url直连OSS

保留read-bytes作为OSS读取失败兜底

更新资产导出与项目记忆文档口径
2026-06-25 23:01:24 +08:00

614 lines
16 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,
type ApiRequestOptions,
BACKGROUND_AUTH_REQUEST_OPTIONS,
fetchWithApiAuth,
requestJson,
} from './apiClient';
export type AssetReadUrlRequest = {
objectKey?: string;
legacyPublicPath?: string;
expireSeconds?: number;
};
type AssetReadUrlResolveOptions = {
signal?: AbortSignal;
expireSeconds?: number;
/**
* 图片内容可能在同一路径下被重新写入。
* 对 generated 私有资源作为签名 URL 缓存版本维度;
* 同一路径和同一 refreshKey 继续复用 signed URL,refreshKey 变化才重新换签。
* 普通非签名 URL 仍可追加 `_v` 避免浏览器图片缓存。
*/
refreshKey?: string | number | null;
};
type AssetReadBytesOptions = {
signal?: AbortSignal;
expireSeconds?: number;
objectKey?: string | 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 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 SIGNED_READ_URL_SESSION_CACHE_PREFIX =
'genarrative.assetReadUrlCache.v1:';
const SIGNED_READ_URL_INITIAL_DISPATCH_BURST = 24;
const SIGNED_READ_URL_DISPATCH_SPACING_MS = 16;
const signedReadUrlCache = new Map<string, CachedReadUrlEntry>();
const signedReadUrlFailureCache = new Map<string, CachedReadUrlFailureEntry>();
const pendingSignedReadUrlRequests = new Map<string, Promise<string>>();
let signedReadUrlDispatchBurstUsed = 0;
let signedReadUrlNextDispatchAtMs = 0;
export function isGeneratedLegacyPath(value: string) {
return /^\/?generated-[^/?#]+\/.+/u.test(value.trim());
}
function isAliyunOssHost(hostname: string) {
return /^[^.]+\.oss-[^.]+\.aliyuncs\.com$/iu.test(hostname.trim());
}
function resolveGeneratedLegacyPathFromUrl(value: string) {
try {
const parsedUrl = new URL(
value,
globalThis.location?.origin ?? 'http://localhost',
);
if (!isAliyunOssHost(parsedUrl.hostname)) {
return '';
}
const legacyPath = decodeURIComponent(parsedUrl.pathname);
return isGeneratedLegacyPath(legacyPath) ? legacyPath : '';
} catch {
return '';
}
}
export function shouldResolveAssetReadUrl(source: string | null | undefined) {
const value = source?.trim() ?? '';
return (
Boolean(value) &&
(isGeneratedLegacyPath(value) || Boolean(resolveGeneratedLegacyPathFromUrl(value)))
);
}
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 normalizeReadUrlCacheVersion(value: string | number | null | undefined) {
if (value === null || value === undefined) {
return '';
}
return String(value).trim();
}
function buildVersionedCacheKey(
cacheKey: string,
cacheVersion: string | number | null | undefined,
) {
const normalizedVersion = normalizeReadUrlCacheVersion(cacheVersion);
return cacheKey && normalizedVersion
? `${cacheKey}:version:${encodeURIComponent(normalizedVersion)}`
: cacheKey;
}
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();
}
function signedReadUrlSessionCacheKey(cacheKey: string) {
return `${SIGNED_READ_URL_SESSION_CACHE_PREFIX}${encodeURIComponent(cacheKey)}`;
}
function getSignedReadUrlSessionStorage() {
try {
return globalThis.sessionStorage ?? null;
} catch {
return null;
}
}
function readSignedUrlSessionCache(
cacheKey: string,
): CachedReadUrlEntry | undefined {
const storage = getSignedReadUrlSessionStorage();
if (!storage || !cacheKey) {
return undefined;
}
const storageKey = signedReadUrlSessionCacheKey(cacheKey);
try {
const rawValue = storage.getItem(storageKey);
if (!rawValue) {
return undefined;
}
const parsed = JSON.parse(rawValue) as Partial<CachedReadUrlEntry>;
if (
typeof parsed.signedUrl !== 'string' ||
typeof parsed.expiresAtMs !== 'number' ||
!Number.isFinite(parsed.expiresAtMs)
) {
storage.removeItem(storageKey);
return undefined;
}
return {
signedUrl: parsed.signedUrl,
expiresAtMs: parsed.expiresAtMs,
};
} catch {
try {
storage.removeItem(storageKey);
} catch {
// ignore storage cleanup errors
}
return undefined;
}
}
function writeSignedUrlSessionCache(
cacheKey: string,
entry: CachedReadUrlEntry,
) {
const storage = getSignedReadUrlSessionStorage();
if (!storage || !cacheKey) {
return;
}
try {
storage.setItem(
signedReadUrlSessionCacheKey(cacheKey),
JSON.stringify(entry),
);
} catch {
// ignore storage quota or privacy mode failures
}
}
function resetSignedReadUrlDispatchLimiter() {
signedReadUrlDispatchBurstUsed = 0;
signedReadUrlNextDispatchAtMs = 0;
}
function reserveSignedReadUrlDispatchDelayMs(nowMs = Date.now()) {
if (signedReadUrlNextDispatchAtMs < nowMs) {
signedReadUrlDispatchBurstUsed = 0;
signedReadUrlNextDispatchAtMs = nowMs;
}
if (
signedReadUrlDispatchBurstUsed < SIGNED_READ_URL_INITIAL_DISPATCH_BURST
) {
signedReadUrlDispatchBurstUsed += 1;
return 0;
}
const dispatchAtMs = Math.max(
signedReadUrlNextDispatchAtMs + SIGNED_READ_URL_DISPATCH_SPACING_MS,
nowMs + SIGNED_READ_URL_DISPATCH_SPACING_MS,
);
signedReadUrlNextDispatchAtMs = dispatchAtMs;
return Math.max(0, dispatchAtMs - nowMs);
}
function createSignedReadUrlAbortError() {
if (typeof DOMException === 'function') {
return new DOMException('The operation was aborted.', 'AbortError');
}
return new Error('The operation was aborted.');
}
async function waitForSignedReadUrlDispatch(signal?: AbortSignal) {
if (signal?.aborted) {
throw createSignedReadUrlAbortError();
}
const delayMs = reserveSignedReadUrlDispatchDelayMs();
if (delayMs <= 0) {
return;
}
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signal?.removeEventListener('abort', handleAbort);
resolve();
}, delayMs);
function handleAbort() {
clearTimeout(timer);
reject(createSignedReadUrlAbortError());
}
signal?.addEventListener('abort', handleAbort, { once: true });
});
}
function clearSignedUrlSessionCache() {
const storage = getSignedReadUrlSessionStorage();
if (!storage) {
return;
}
try {
const keys: string[] = [];
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index);
if (key?.startsWith(SIGNED_READ_URL_SESSION_CACHE_PREFIX)) {
keys.push(key);
}
}
keys.forEach((key) => storage.removeItem(key));
} catch {
// ignore storage cleanup failures
}
}
export async function getSignedAssetReadUrl(
request: AssetReadUrlRequest,
signal?: AbortSignal,
options: {
bypassCache?: boolean;
cacheVersion?: string | number | null;
} = {},
) {
const cacheKey = buildVersionedCacheKey(
buildCacheKey(request),
options.cacheVersion,
);
const bypassCache = options.bypassCache === true;
const cached =
!bypassCache && cacheKey ? signedReadUrlCache.get(cacheKey) : undefined;
if (cached && shouldReuseCachedReadUrl(cached)) {
return cached.signedUrl;
}
const sessionCached =
!bypassCache && cacheKey ? readSignedUrlSessionCache(cacheKey) : undefined;
if (sessionCached && shouldReuseCachedReadUrl(sessionCached)) {
signedReadUrlCache.set(cacheKey, sessionCached);
return sessionCached.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 {
await waitForSignedReadUrlDispatch(signal);
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) {
const entry = {
signedUrl: payload.signedUrl,
expiresAtMs,
};
signedReadUrlCache.set(cacheKey, entry);
writeSignedUrlSessionCache(cacheKey, entry);
}
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 变化,形成新的签名缓存版本;同一版本继续复用 signed 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:')
) {
const legacyPath = resolveGeneratedLegacyPathFromUrl(value);
if (legacyPath) {
const signedUrl = await getSignedAssetReadUrl(
{
legacyPublicPath: legacyPath,
expireSeconds: options.expireSeconds,
},
options.signal,
{
cacheVersion: options.refreshKey,
},
);
return signedUrl;
}
return appendCacheBustParam(value, options.refreshKey);
}
if (isGeneratedLegacyPath(value)) {
const signedUrl = await getSignedAssetReadUrl(
{
legacyPublicPath: value,
expireSeconds: options.expireSeconds,
},
options.signal,
{
cacheVersion: options.refreshKey,
},
);
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('资源路径不能为空');
}
const objectKey = options.objectKey?.trim().replace(/^\/+/u, '') ?? '';
const legacyPath = isGeneratedLegacyPath(value)
? value
: resolveGeneratedLegacyPathFromUrl(value);
if (!objectKey && !legacyPath) {
const response = await fetch(value, { signal: options.signal });
if (!response.ok) {
throw new Error(`读取资源内容失败(HTTP ${response.status})`);
}
return response;
}
const readRequest = {
objectKey,
legacyPublicPath: objectKey ? undefined : legacyPath,
expireSeconds: options.expireSeconds,
};
try {
const signedUrl = await getSignedAssetReadUrl(readRequest, options.signal);
const response = await fetch(signedUrl, { signal: options.signal });
if (response.ok) {
return response;
}
} catch {
if (options.signal?.aborted) {
throw createSignedReadUrlAbortError();
}
// 中文注释:浏览器直读 OSS 失败时再走同源字节代理兜底。
}
return readAssetBytesViaFallbackApi(readRequest, options.signal);
}
async function readAssetBytesViaFallbackApi(
request: AssetReadUrlRequest,
signal?: AbortSignal,
) {
const searchParams = buildAssetReadSearchParams({
objectKey: request.objectKey,
legacyPublicPath: request.legacyPublicPath,
expireSeconds: request.expireSeconds,
});
const response = await fetchWithApiAuth(
`${ASSET_READ_BYTES_API_PATH}?${searchParams.toString()}`,
{
method: 'GET',
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();
resetSignedReadUrlDispatchLimiter();
clearSignedUrlSessionCache();
}