refactor(*): 平台架构 - 拆分后端领域模块与画布前端组件并补充测试

This commit is contained in:
ddcat666
2026-09-30 10:01:39 +08:00
parent e0a2697c60
commit 7f5d87ef1a
186 changed files with 33727 additions and 29905 deletions

View File

@@ -1,12 +1,9 @@
import { getMediaBlob } from "@/services/file-storage";
import { getImageBlob } from "@/services/image-storage";
import { deleteRemoteAssets, deleteRemoteCanvasProject, getRemoteAsset, getRemoteAssetsByIds, getRemoteCanvasProject, getRemoteUserDataSnapshot, listRemoteAssetsPage, restoreRemoteCanvasHistory, upsertRemoteAsset, upsertRemoteCanvasProject } from "@/services/api/user-data";
import { ApiError } from "@/services/api/request";
import { canvasContentHash, sameCanvasContent } from "@/lib/canvas/canvas-content";
import { getActiveUserScope } from "@/lib/user-scope";
import { preserveCanvasSyncDraft, readCanvasSyncDrafts } from "@/services/canvas-sync-drafts";
import { appQueryClient } from "@/lib/query-client";
import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
import { parseAssetRecordList } from "@/lib/asset-record";
import { assetForRemoteSync } from "@/lib/asset-remote-sync";
import type { Asset } from "@/stores/use-asset-store";
@@ -18,6 +15,9 @@ import { useCanvasHistoryStore } from "@/stores/canvas/use-canvas-history-store"
import { repairMissingCanvasAssets, repairMissingCanvasVideoPreviews, collectCanvasMediaAssetIds, rebindInconsistentCanvasAssets, type CanvasAssetRebindResult } from "@/services/canvas-asset-repair";
import { canvasNodeToAsset } from "@/lib/canvas/canvas-node-asset";
import { applyAgentCanvasPatch, type AgentCanvasPatch } from "@/lib/canvas/agent-canvas-patch";
import { collectLocalMediaKeys, ensureRemoteResourceReferences } from "./user-data-sync-media";
export { numberValue } from "./user-data-sync-media";
let activeRemoteUserId = "";
type RemoteUserDataPhase = "inactive" | "hydrating" | "ready" | "failed";
@@ -295,8 +295,6 @@ export async function loadAssetsForUse(ids: Iterable<string>) {
});
}
const LOCAL_STORAGE_KEY_PATTERN = /^(image|video|audio|file|video-reference|audio-reference):/;
export async function syncRemoteUserData(userId?: string | null) {
// 登录/切换账号时,服务端快照建立新的远端基线;本地 IndexedDB 只负责首屏缓存,
// 不能把服务端已经删除或当前用户无权访问的实体重新补回去。后续增量保存必须基于这份基线做冲突校验。
@@ -866,111 +864,6 @@ async function saveRemoteUserDataBatch(uploaded: Map<string, string>, options: {
if (dirtyProjects.length) void appQueryClient.invalidateQueries({ queryKey: ["canvas-library"] });
}
function collectLocalMediaKeys(value: unknown, set = new Set<string>()): string[] {
if (!value || typeof value !== "object") return [...set];
if (Array.isArray(value)) {
for (const item of value) collectLocalMediaKeys(item, set);
return [...set];
}
const record = value as Record<string, unknown>;
const storageKey = typeof record.storageKey === "string" ? record.storageKey : "";
if (isLocalStorageKey(storageKey) && !resourceIdFromStorageKey(storageKey)) {
set.add(storageKey);
} else {
const inline = inlineMediaDataUrl(record);
if (inline) set.add(`${inline.length}:${inline.slice(0, 64)}:${inline.slice(-64)}`);
}
for (const child of Object.values(record)) {
collectLocalMediaKeys(child, set);
}
return [...set];
}
async function ensureRemoteResourceReferences<T>(value: T, uploaded = new Map<string, string>(), onUploaded?: () => void): Promise<T> {
if (!value || typeof value !== "object") return value;
if (Array.isArray(value)) {
const result: unknown[] = [];
for (const item of value) result.push(await ensureRemoteResourceReferences(item, uploaded, onUploaded));
return result as T;
}
const next: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value)) {
next[key] = await ensureRemoteResourceReferences(child, uploaded, onUploaded);
}
const storageKey = typeof next.storageKey === "string" ? next.storageKey : "";
const remoteResourceId = resourceIdFromStorageKey(storageKey);
if (remoteResourceId) return applyResourceReference(next, storageKey) as T;
if (!isLocalStorageKey(storageKey)) {
const inline = inlineMediaDataUrl(next);
if (!inline) return next as T;
const identity = await inlineMediaUploadIdentity(inline);
const cached = uploaded.get(identity);
if (cached) return applyResourceReference(next, cached) as T;
const resourceStorage = await uploadInlineDataUrl(inline, identity);
uploaded.set(identity, resourceStorage);
onUploaded?.();
return applyResourceReference(next, resourceStorage) as T;
}
const cached = uploaded.get(storageKey);
if (cached) return applyResourceReference(next, cached) as T;
const resourceStorage = await uploadLocalStorageKey(storageKey, next);
uploaded.set(storageKey, resourceStorage);
onUploaded?.();
return applyResourceReference(next, resourceStorage) as T;
}
function applyResourceReference(payload: Record<string, unknown>, storageKey: string) {
const resourceId = resourceIdFromStorageKey(storageKey);
if (!resourceId) {
throw new Error(`远端资源引用无效:${storageKey}`);
}
const url = resourceFileUrl(resourceId);
payload.storageKey = storageKey;
for (const key of ["content", "dataUrl", "url", "coverUrl"]) {
if (typeof payload[key] === "string") payload[key] = url;
}
return payload;
}
function inlineMediaDataUrl(payload: Record<string, unknown>) {
for (const key of ["dataUrl", "content", "url", "coverUrl"]) {
const value = payload[key];
if (typeof value === "string" && /^data:(image|video|audio)\//i.test(value)) return value;
}
return "";
}
async function uploadInlineDataUrl(dataUrl: string, identity: string) {
const response = await fetch(dataUrl);
if (!response.ok) throw new Error("内嵌媒体读取失败");
const blob = await response.blob();
const kind: "image" | "video" | "audio" | "file" = blob.type.startsWith("image/") ? "image" : blob.type.startsWith("video/") ? "video" : blob.type.startsWith("audio/") ? "audio" : "file";
const resource = await uploadResourceFile(blob, kind, { idempotencyKey: identity });
return resourceStorageKey(resource.id);
}
async function inlineMediaUploadIdentity(dataUrl: string) {
const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(dataUrl));
return `inline:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
}
async function uploadLocalStorageKey(storageKey: string, payload: Record<string, unknown>) {
const blob = storageKey.startsWith("image:") ? await getImageBlob(storageKey) : await getMediaBlob(storageKey);
if (!blob) throw new Error(`本地媒体不存在,无法同步:${storageKey}`);
const kind = blob.type.startsWith("image/") ? "image" : blob.type.startsWith("video/") ? "video" : blob.type.startsWith("audio/") ? "audio" : "file";
const resource = await uploadResourceFile(blob, kind, {
width: numberValue(payload.naturalWidth) || numberValue(payload.width),
height: numberValue(payload.naturalHeight) || numberValue(payload.height),
durationMs: numberValue(payload.durationMs),
idempotencyKey: storageKey,
});
return resourceStorageKey(resource.id);
}
function requireRemoteUserDataBaseline() {
if (remoteUserDataPhase !== "ready") throw new Error("云端数据基线尚未建立,已停止写入");
}
@@ -979,15 +872,6 @@ function sameEntitySnapshot<T>(acknowledged: T | undefined, current: T) {
return acknowledged !== undefined && (acknowledged === current || JSON.stringify(acknowledged) === JSON.stringify(current));
}
function isLocalStorageKey(value: string) {
return LOCAL_STORAGE_KEY_PATTERN.test(value) && !resourceIdFromStorageKey(value);
}
function numberValue(value: unknown) {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? number : undefined;
}
function sanitizeCanvasProjectForRemoteSync<T>(project: T): T {
if (!project || typeof project !== "object") return project;
const clone = { ...(project as Record<string, unknown>) };