fix(canvas): 资源引用 - 校验递归素材并修复断链
This commit is contained in:
@@ -13,6 +13,8 @@ import { imageSizeConfigWithPresets } from "@/lib/image-size-presets";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { PLUGIN_STORE_KEY, usePluginStore } from "@/stores/use-plugin-store";
|
||||
import { initializeRemoteUserDataSession, installRemoteUserDataAutoSync, resetRemoteUserDataSync, withRemoteUserDataSyncExclusive } from "@/services/user-data-sync";
|
||||
import { clearResourceAccessCache } from "@/services/api/resources";
|
||||
import { clearResourceBlobCache } from "@/services/resource-blob-cache";
|
||||
import { withGenerationConsumersPaused } from "@/services/generation-consumer-lifecycle";
|
||||
|
||||
export async function switchUserStorageScope(userId?: string | null) {
|
||||
@@ -20,6 +22,8 @@ export async function switchUserStorageScope(userId?: string | null) {
|
||||
await withRemoteUserDataSyncExclusive(async () => {
|
||||
await Promise.all([flushCanvasStorePersistence(), flushAssetStorePersistence()]);
|
||||
resetRemoteUserDataSync();
|
||||
clearResourceAccessCache();
|
||||
clearResourceBlobCache();
|
||||
setActiveUserScope(userId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ export type BackendEnvelope<T> = {
|
||||
data: T;
|
||||
msg: string;
|
||||
reason?: string;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -18,16 +19,18 @@ export class ApiError extends Error {
|
||||
readonly status?: number;
|
||||
readonly code?: number;
|
||||
readonly reason?: string;
|
||||
readonly details?: Record<string, unknown>;
|
||||
readonly retryable: boolean;
|
||||
readonly retryAfterMs?: number;
|
||||
readonly cause?: unknown;
|
||||
|
||||
constructor(message: string, options: { status?: number; code?: number; reason?: string; retryable?: boolean; retryAfterMs?: number; cause?: unknown } = {}) {
|
||||
constructor(message: string, options: { status?: number; code?: number; reason?: string; details?: Record<string, unknown>; retryable?: boolean; retryAfterMs?: number; cause?: unknown } = {}) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = options.status;
|
||||
this.code = options.code;
|
||||
this.reason = options.reason;
|
||||
this.details = options.details;
|
||||
this.retryable = options.retryable ?? isRetryableStatus(options.status ?? options.code);
|
||||
this.retryAfterMs = options.retryAfterMs;
|
||||
this.cause = options.cause;
|
||||
@@ -51,6 +54,7 @@ export async function request<T>(promise: Promise<{ data: BackendEnvelope<T>; st
|
||||
status: response.status,
|
||||
code: response.data.code,
|
||||
reason: response.data.reason,
|
||||
details: response.data.details,
|
||||
retryable: isRetryableStatus(response.status) || isRetryableStatus(response.data.code),
|
||||
retryAfterMs: retryAfterMilliseconds(response.headers),
|
||||
});
|
||||
@@ -75,6 +79,7 @@ function unwrapTransportError(error: unknown): never {
|
||||
status,
|
||||
code,
|
||||
reason: error.response?.data?.reason,
|
||||
details: error.response?.data?.details,
|
||||
retryable: isRetryableStatus(status) || isRetryableStatus(code),
|
||||
retryAfterMs: retryAfterMilliseconds(error.response?.headers),
|
||||
cause: error,
|
||||
|
||||
@@ -18,10 +18,10 @@ export type RemoteResource = {
|
||||
height?: number;
|
||||
durationMs?: number;
|
||||
etag?: string;
|
||||
playbackStatus?: string;
|
||||
playbackObjectKey?: string;
|
||||
playbackError?: string;
|
||||
error?: string;
|
||||
playbackStatus?: string;
|
||||
playbackObjectKey?: string;
|
||||
playbackError?: string;
|
||||
error?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
@@ -119,6 +119,21 @@ export type ResourceAccess = {
|
||||
|
||||
const accessCache = new Map<string, { value: ResourceAccess; expiresAt: number }>();
|
||||
const accessRequests = new Map<string, Promise<ResourceAccess>>();
|
||||
let accessGeneration = 0;
|
||||
|
||||
/**
|
||||
* Drop every in-memory access descriptor when the authenticated scope changes.
|
||||
* Signed URLs are credentials, so an old request must not be allowed to publish
|
||||
* its result into the next account's cache after a logout/login race.
|
||||
*/
|
||||
export function clearResourceAccessCache() {
|
||||
accessGeneration += 1;
|
||||
accessCache.clear();
|
||||
accessRequests.clear();
|
||||
resourceCache.clear();
|
||||
resourceRequests.clear();
|
||||
missingResourceIds.clear();
|
||||
}
|
||||
|
||||
export function resourceStorageKey(id: string) {
|
||||
return `resource:${id}`;
|
||||
@@ -160,12 +175,7 @@ export function isResourceUrl(url?: string) {
|
||||
const CHUNK_UPLOAD_THRESHOLD = 50 << 20;
|
||||
const CHUNK_UPLOAD_RETRIES = 2;
|
||||
|
||||
export async function uploadResourceFile(
|
||||
file: Blob,
|
||||
kind: "image" | "video" | "audio" | "file",
|
||||
meta?: ResourceUploadMeta,
|
||||
onProgress?: (uploadedBytes: number, totalBytes: number) => void,
|
||||
): Promise<RemoteResource> {
|
||||
export async function uploadResourceFile(file: Blob, kind: "image" | "video" | "audio" | "file", meta?: ResourceUploadMeta, onProgress?: (uploadedBytes: number, totalBytes: number) => void): Promise<RemoteResource> {
|
||||
const name = meta?.fileName || (file instanceof File ? file.name : `${kind}.${extensionFromMime(file.type, kind)}`);
|
||||
// 分片与 multipart 两条路径的失败都要归一成 ResourceUploadError,
|
||||
// 否则调用方只能靠文案猜测该重试还是该报错。
|
||||
@@ -183,9 +193,11 @@ export async function uploadResourceFile(
|
||||
if (meta?.durationMs) formData.append("durationMs", String(Math.round(meta.durationMs)));
|
||||
const data = await http.post<{ resource: RemoteResource }>("/resources", formData, {
|
||||
...uploadRequestConfig(meta?.idempotencyKey),
|
||||
onUploadProgress: onProgress ? ({ loaded, total }) => {
|
||||
if (total && total > 0) onProgress(Math.min(file.size, file.size * loaded / total), file.size);
|
||||
} : undefined,
|
||||
onUploadProgress: onProgress
|
||||
? ({ loaded, total }) => {
|
||||
if (total && total > 0) onProgress(Math.min(file.size, (file.size * loaded) / total), file.size);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
resourceCache.set(resourceCacheKey(data.resource.id), data.resource);
|
||||
return data.resource;
|
||||
@@ -209,7 +221,11 @@ async function uploadFileInChunks(file: Blob, name: string, kind: "image" | "vid
|
||||
}
|
||||
|
||||
async function runChunkedUpload(file: Blob, name: string, kind: "image" | "video" | "audio" | "file", meta: ResourceUploadMeta | undefined, onProgress?: (uploadedBytes: number, totalBytes: number) => void) {
|
||||
const session = await http.post<{ uploadId: string; chunkSize: number; chunkCount: number }>("/resources/uploads", { fileName: name, kind, size: file.size, width: meta?.width, height: meta?.height, durationMs: meta?.durationMs }, uploadRequestConfig(meta?.idempotencyKey));
|
||||
const session = await http.post<{ uploadId: string; chunkSize: number; chunkCount: number }>(
|
||||
"/resources/uploads",
|
||||
{ fileName: name, kind, size: file.size, width: meta?.width, height: meta?.height, durationMs: meta?.durationMs },
|
||||
uploadRequestConfig(meta?.idempotencyKey),
|
||||
);
|
||||
for (let index = 0; index < session.chunkCount; index++) {
|
||||
const start = index * session.chunkSize;
|
||||
const end = Math.min(file.size, start + session.chunkSize);
|
||||
@@ -260,48 +276,69 @@ function uploadRequestConfig(idempotencyKey?: string) {
|
||||
}
|
||||
|
||||
export function getResource(id: string): Promise<RemoteResource> {
|
||||
const generation = accessGeneration;
|
||||
const scope = getActiveUserScope();
|
||||
const cacheKey = resourceCacheKey(id);
|
||||
const cached = resourceCache.get(cacheKey);
|
||||
if (cached) return Promise.resolve(cached);
|
||||
if (missingResourceIds.has(cacheKey)) return Promise.reject(new Error("资源不存在或已被删除"));
|
||||
const pending = resourceRequests.get(cacheKey);
|
||||
if (pending) return pending;
|
||||
const task = http.get<{ resource: RemoteResource }>(`/resources/${encodeURIComponent(id)}`)
|
||||
const task = http
|
||||
.get<{ resource: RemoteResource }>(`/resources/${encodeURIComponent(id)}`)
|
||||
.then((data) => {
|
||||
assertResourceRequestCurrent(generation, scope);
|
||||
resourceCache.set(cacheKey, data.resource);
|
||||
return data.resource;
|
||||
})
|
||||
.catch((error) => {
|
||||
assertResourceRequestCurrent(generation, scope);
|
||||
if (error instanceof ApiError && error.status === 404) missingResourceIds.add(cacheKey);
|
||||
throw error;
|
||||
})
|
||||
.finally(() => resourceRequests.delete(cacheKey));
|
||||
.finally(() => {
|
||||
if (resourceRequests.get(cacheKey) === task) resourceRequests.delete(cacheKey);
|
||||
});
|
||||
resourceRequests.set(cacheKey, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
// refreshResource 绕过缓存强制拉取资源最新状态(转码副本就绪轮询用),并回写缓存。
|
||||
export function refreshResource(id: string): Promise<RemoteResource> {
|
||||
const generation = accessGeneration;
|
||||
const scope = getActiveUserScope();
|
||||
const cacheKey = resourceCacheKey(id);
|
||||
return http.get<{ resource: RemoteResource }>(`/resources/${encodeURIComponent(id)}`)
|
||||
.then((data) => {
|
||||
resourceCache.set(cacheKey, data.resource);
|
||||
missingResourceIds.delete(cacheKey);
|
||||
return data.resource;
|
||||
});
|
||||
return http.get<{ resource: RemoteResource }>(`/resources/${encodeURIComponent(id)}`).then((data) => {
|
||||
assertResourceRequestCurrent(generation, scope);
|
||||
resourceCache.set(cacheKey, data.resource);
|
||||
missingResourceIds.delete(cacheKey);
|
||||
return data.resource;
|
||||
});
|
||||
}
|
||||
|
||||
function assertResourceRequestCurrent(generation: number, scope: string) {
|
||||
if (generation !== accessGeneration || scope !== getActiveUserScope()) {
|
||||
throw new DOMException("资源请求已因账号切换失效", "AbortError");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getResourceAccess(storageKey: string | undefined, purpose: ResourceAccessPurpose = "display", variant: ResourceAccessVariant = "original", downloadName = "") {
|
||||
const id = resourceIdFromStorageKey(storageKey);
|
||||
if (!id) throw new Error("当前媒体尚未上传到后端资源存储");
|
||||
const key = `${resourceCacheKey(id)}:${purpose}:${variant}:${downloadName}`;
|
||||
const scope = getActiveUserScope();
|
||||
const generation = accessGeneration;
|
||||
const key = `${scope}:${id}:${purpose}:${variant}:${downloadName}`;
|
||||
const cached = accessCache.get(key);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.value;
|
||||
const pending = accessRequests.get(key);
|
||||
if (pending) return pending;
|
||||
const request = (async () => {
|
||||
let request!: Promise<ResourceAccess>;
|
||||
request = (async () => {
|
||||
try {
|
||||
const data = await http.post<{ items: Array<{ resourceId: string; access?: ResourceAccess; error?: { msg?: string } }> }>("/resources/access", [{ resourceId: id, purpose, variant, ...(downloadName ? { downloadName } : {}) }]);
|
||||
if (generation !== accessGeneration || scope !== getActiveUserScope()) {
|
||||
throw new DOMException("资源访问请求已因账号切换失效", "AbortError");
|
||||
}
|
||||
const item = data.items?.[0];
|
||||
if (!item?.access?.url) throw new Error(item?.error?.msg || "后端未返回资源访问地址");
|
||||
const value = item.access;
|
||||
@@ -312,7 +349,7 @@ export async function getResourceAccess(storageKey: string | undefined, purpose:
|
||||
if (error instanceof ApiError) throw new Error(error.message || "获取对象存储地址失败");
|
||||
throw error;
|
||||
} finally {
|
||||
accessRequests.delete(key);
|
||||
if (accessRequests.get(key) === request) accessRequests.delete(key);
|
||||
}
|
||||
})();
|
||||
accessRequests.set(key, request);
|
||||
|
||||
@@ -121,9 +121,9 @@ export function getRemoteCanvasProject(id: string) {
|
||||
return http.get<{ project: CanvasProject }>(`/canvas-projects/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export function upsertRemoteCanvasProject(project: CanvasProject) {
|
||||
export function upsertRemoteCanvasProject(project: CanvasProject, options?: { repairMissingResources?: boolean }) {
|
||||
const { viewport: _viewport, remoteContentHash: _hash, ...content } = project;
|
||||
return http.put<{ project: RemoteUserDataSummary & { revision: number } }>(`/canvas-projects/${encodeURIComponent(project.id)}`, { project: content });
|
||||
return http.put<{ project: RemoteUserDataSummary & { revision: number } }>(`/canvas-projects/${encodeURIComponent(project.id)}`, { project: content, ...(options?.repairMissingResources ? { repairMissingResources: true } : {}) });
|
||||
}
|
||||
|
||||
export function deleteRemoteCanvasProject(id: string) {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { canvasNodeToAsset } from "@/lib/canvas/canvas-node-asset";
|
||||
import { resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { refreshResource, resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { ApiError } from "@/services/api/request";
|
||||
import { useAssetStore, type Asset, type NewAsset } from "@/stores/use-asset-store";
|
||||
import { useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
import type { TimelineDirectMedia } from "@/types/timeline";
|
||||
import { getActiveUserScope } from "@/lib/user-scope";
|
||||
|
||||
export type CanvasAssetRepairResult = {
|
||||
createdAssets: number;
|
||||
@@ -134,6 +136,67 @@ function assetStorageKey(asset: Asset) {
|
||||
return asset.kind === "text" || asset.kind === "entity" ? undefined : asset.data.storageKey;
|
||||
}
|
||||
|
||||
export type CanvasVideoPreviewRepairResult = {
|
||||
clearedPreviews: number;
|
||||
updatedProjects: number;
|
||||
};
|
||||
|
||||
/** 仅清理确认缺失的派生封面;网络/鉴权失败必须上抛,不能当作不存在。 */
|
||||
export async function repairMissingCanvasVideoPreviews(projectIds?: Set<string>, lookup = refreshResource): Promise<CanvasVideoPreviewRepairResult> {
|
||||
const scope = getActiveUserScope();
|
||||
const projects = useCanvasStore.getState().projects.filter((project) => !projectIds || projectIds.has(project.id));
|
||||
const previewResourceIds = new Set<string>();
|
||||
for (const project of projects) {
|
||||
for (const node of project.nodes) {
|
||||
for (const id of previewResourceIDs(node)) previewResourceIds.add(id);
|
||||
}
|
||||
}
|
||||
const invalidIds = new Set<string>();
|
||||
const ids = [...previewResourceIds];
|
||||
for (let offset = 0; offset < ids.length; offset += 8) {
|
||||
await Promise.all(ids.slice(offset, offset + 8).map(async (id) => {
|
||||
try {
|
||||
const resource = await lookup(id);
|
||||
if (resource.status !== "ready") invalidIds.add(id);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) invalidIds.add(id);
|
||||
else throw error;
|
||||
}
|
||||
}));
|
||||
}
|
||||
if (getActiveUserScope() !== scope) throw new Error("账号已切换,已停止修复视频封面");
|
||||
if (!invalidIds.size) return { clearedPreviews: 0, updatedProjects: 0 };
|
||||
|
||||
let clearedPreviews = 0;
|
||||
let updatedProjects = 0;
|
||||
const canvasStore = useCanvasStore.getState();
|
||||
// Re-read live projects after I/O; never replace edits made while diagnosing.
|
||||
for (const project of canvasStore.projects.filter((item) => !projectIds || projectIds.has(item.id))) {
|
||||
let changed = false;
|
||||
const nodes = project.nodes.map((node) => {
|
||||
if (!previewResourceIDs(node).some((id) => invalidIds.has(id)) || !node.metadata?.videoPreview) return node;
|
||||
const { videoPreview: _videoPreview, ...metadata } = node.metadata;
|
||||
changed = true;
|
||||
clearedPreviews += 1;
|
||||
return { ...node, metadata };
|
||||
});
|
||||
if (!changed) continue;
|
||||
canvasStore.updateProject(project.id, { nodes });
|
||||
updatedProjects += 1;
|
||||
}
|
||||
return { clearedPreviews, updatedProjects };
|
||||
}
|
||||
|
||||
function previewResourceIDs(node: CanvasNodeData): string[] {
|
||||
const preview = node.metadata?.videoPreview;
|
||||
if (!preview) return [];
|
||||
return Object.entries(preview).flatMap(([key, value]) => {
|
||||
if (!["storageKey", "url", "dataUrl", "content"].includes(key) || typeof value !== "string") return [];
|
||||
const id = resourceIdFromStorageKey(value) || value.match(/(?:^|\/)api\/resources\/([A-Za-z0-9_-]+)\/file(?:[?#]|$)/)?.[1];
|
||||
return id ? [id] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export type CanvasAssetRebindResult = {
|
||||
reboundNodes: number;
|
||||
createdAssets: number;
|
||||
|
||||
@@ -18,12 +18,12 @@ const metaStore = localforage.createInstance({ name: "infinite-canvas", storeNam
|
||||
const objectUrls = new Map<string, string>();
|
||||
const sessionBlobs = new Map<string, Blob>();
|
||||
const inFlight = new Map<string, Promise<string>>();
|
||||
const scheduled = new Set<string>();
|
||||
const cacheMetaTouchWarnings = new Set<string>();
|
||||
const metaTouchedAt = new Map<string, number>();
|
||||
const downloadQueue: Array<() => void> = [];
|
||||
let activeDownloads = 0;
|
||||
let persistQueue: Promise<void> = Promise.resolve();
|
||||
let cacheGeneration = 0;
|
||||
const MAX_CACHE_BYTES = 2 * 1024 * 1024 * 1024;
|
||||
const FALLBACK_CACHE_BYTES = 512 * 1024 * 1024;
|
||||
const MIN_CACHE_BYTES = 64 * 1024 * 1024;
|
||||
@@ -33,6 +33,18 @@ const BUDGET_REFRESH_MS = 5 * 60 * 1000;
|
||||
// 现代浏览器对同源 HTTP/2 连接多路复用;上限给到 16 让大画布冷启动在 1~2 轮内完成并发拉取。
|
||||
const MAX_CONCURRENT_DOWNLOADS = 16;
|
||||
|
||||
/** Release session-only byte resources during account/logout transitions. */
|
||||
export function clearResourceBlobCache() {
|
||||
cacheGeneration += 1;
|
||||
objectUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||
objectUrls.clear();
|
||||
sessionBlobs.clear();
|
||||
inFlight.clear();
|
||||
metaTouchedAt.clear();
|
||||
cacheMetaTouchWarnings.clear();
|
||||
cacheStats = null;
|
||||
}
|
||||
|
||||
export async function getCachedResourceObjectUrl(storageKey: string) {
|
||||
const target = await cacheTarget(storageKey);
|
||||
if (!target) return "";
|
||||
@@ -56,42 +68,25 @@ export async function cacheResourceObjectUrl(storageKey: string) {
|
||||
const pending = inFlight.get(target.key);
|
||||
if (pending) return pending;
|
||||
|
||||
const task = withDownloadSlot(() => downloadAndCacheResource(storageKey, target)).finally(() => inFlight.delete(target.key));
|
||||
const generation = cacheGeneration;
|
||||
let task: Promise<string>;
|
||||
task = withDownloadSlot(() => downloadAndCacheResource(storageKey, target, generation)).finally(() => {
|
||||
if (inFlight.get(target.key) === task) inFlight.delete(target.key);
|
||||
});
|
||||
inFlight.set(target.key, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放器先使用支持 Range 的资源 URL 起播;确认用户实际播放后,再延迟下载完整 Blob。
|
||||
* 这样不会让 IndexedDB 缓存阻塞首帧,同时后续打开可直接复用本地 Object URL。
|
||||
*/
|
||||
export function scheduleResourceBlobCache(storageKey: string, delayMs = 4_000) {
|
||||
if (!resourceIdFromStorageKey(storageKey) || scheduled.has(storageKey)) return;
|
||||
scheduled.add(storageKey);
|
||||
const run = () => {
|
||||
void cacheResourceObjectUrl(storageKey)
|
||||
.catch((error) => {
|
||||
// 这是播放后的后台缓存优化,不应让播放器失败;但下载/持久化异常必须可观测。
|
||||
console.warn("后台缓存资源 Blob 失败", { storageKey, error });
|
||||
return "";
|
||||
})
|
||||
.finally(() => scheduled.delete(storageKey));
|
||||
};
|
||||
if (typeof window === "undefined") {
|
||||
run();
|
||||
return;
|
||||
}
|
||||
window.setTimeout(run, Math.max(0, delayMs));
|
||||
}
|
||||
|
||||
function withDownloadSlot<T>(task: () => Promise<T>) {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
downloadQueue.push(() => {
|
||||
activeDownloads += 1;
|
||||
task().then(resolve, reject).finally(() => {
|
||||
activeDownloads -= 1;
|
||||
runDownloadQueue();
|
||||
});
|
||||
task()
|
||||
.then(resolve, reject)
|
||||
.finally(() => {
|
||||
activeDownloads -= 1;
|
||||
runDownloadQueue();
|
||||
});
|
||||
});
|
||||
runDownloadQueue();
|
||||
});
|
||||
@@ -123,12 +118,12 @@ export async function getCachedResourceBlob(storageKey: string) {
|
||||
const pending = inFlight.get(target.key);
|
||||
if (pending) {
|
||||
await pending.catch(() => "");
|
||||
const downloaded = sessionBlobs.get(target.key) || await blobStore.getItem<Blob>(target.key);
|
||||
const downloaded = sessionBlobs.get(target.key) || (await blobStore.getItem<Blob>(target.key));
|
||||
if (downloaded) return downloaded;
|
||||
return loadAndPersistResource(storageKey);
|
||||
}
|
||||
await cacheResourceObjectUrl(storageKey).catch(() => "");
|
||||
const downloaded = sessionBlobs.get(target.key) || await blobStore.getItem<Blob>(target.key);
|
||||
const downloaded = sessionBlobs.get(target.key) || (await blobStore.getItem<Blob>(target.key));
|
||||
if (downloaded) return downloaded;
|
||||
return loadAndPersistResource(storageKey);
|
||||
}
|
||||
@@ -139,9 +134,9 @@ async function loadAndPersistResource(storageKey: string) {
|
||||
return blob;
|
||||
}
|
||||
|
||||
async function downloadAndCacheResource(storageKey: string, target: ResourceCacheMeta) {
|
||||
async function downloadAndCacheResource(storageKey: string, target: ResourceCacheMeta, generation: number) {
|
||||
const blob = await downloadResourceBlob(storageKey, target);
|
||||
if (!blob) return "";
|
||||
if (!blob || generation !== cacheGeneration) return "";
|
||||
return objectUrl(target.key, blob);
|
||||
}
|
||||
|
||||
@@ -251,7 +246,6 @@ async function touchCacheMeta(target: ResourceCacheMeta) {
|
||||
await metaStore.setItem(target.key, { ...current, lastAccessedAt: now });
|
||||
}
|
||||
|
||||
|
||||
function touchCacheMetaSafely(target: ResourceCacheMeta) {
|
||||
void touchCacheMeta(target).catch((error) => {
|
||||
// 访问时间只服务于缓存淘汰,不影响资源读取;失败可降级,但不能无痕吞掉。
|
||||
|
||||
Reference in New Issue
Block a user