Files
ttstd-infinite-canvas/web/src/services/user-data-sync.ts

367 lines
14 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 { getMediaBlob } from "@/services/file-storage";
import { getImageBlob } from "@/services/image-storage";
import { deleteRemoteAsset, deleteRemoteCanvasProject, getRemoteUserDataSnapshot, upsertRemoteAsset, upsertRemoteCanvasProject } from "@/services/api/user-data";
import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
import type { Asset } from "@/stores/use-asset-store";
import { useAssetStore } from "@/stores/use-asset-store";
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
let activeRemoteUserId = "";
let applyingRemoteState = false;
let syncTimer: number | null = null;
let syncPromise: Promise<void> | null = null;
let syncQueued = false;
let syncPauseCount = 0;
let syncPauseTail: Promise<void> = Promise.resolve();
let resumeSync: (() => void) | null = null;
let syncResumed: Promise<void> | null = null;
const activeRemoteSyncOperations = new Set<Promise<void>>();
let subscriptionsInstalled = false;
let remoteAssetVersions = new Map<string, string>();
let remoteProjectVersions = new Map<string, string>();
const LOCAL_STORAGE_KEY_PATTERN = /^(image|video|audio|file|video-reference|audio-reference):/;
export async function syncRemoteUserData(userId?: string | null) {
await runRemoteUserDataSyncOperation(async () => {
activeRemoteUserId = userId || "";
if (!activeRemoteUserId) return;
applyingRemoteState = true;
try {
// 登录只拉一次聚合快照。摘要列表再逐条请求详情会把 N 条数据放大成 2N+2 个请求,
// 并且会在登录阶段同时触发大量媒体解析,任何一项失败都会污染登录结果。
const snapshot = await getRemoteUserDataSnapshot();
remoteProjectVersions = versionMap(snapshot.projects);
remoteAssetVersions = versionMap(snapshot.assets);
const localProjects = useCanvasStore.getState().projects;
const localAssets = useAssetStore.getState().assets;
const mergedProjects = mergeById(localProjects, snapshot.projects);
// 这里只合并结构化素材数据,不在登录阶段解析图片/视频/音频 URL;媒体由实际使用方按需解析。
const mergedAssets = mergeById(localAssets, snapshot.assets);
useCanvasStore.getState().replaceProjects(mergedProjects);
useAssetStore.getState().replaceAssets(mergedAssets);
} finally {
applyingRemoteState = false;
}
});
// 首次登录可能带有尚未创建到云端的本地画布;先完成一次 upsert,避免详情页保存/分享先于项目创建。
try {
await saveRemoteUserDataNow();
} catch (error) {
console.warn("登录后画布首次同步失败,保留本地项目等待重试", error);
}
scheduleRemoteUserDataSync();
}
export function installRemoteUserDataAutoSync() {
if (subscriptionsInstalled) return;
subscriptionsInstalled = true;
useCanvasStore.subscribe((state, previous) => {
if (state.projects !== previous.projects) scheduleRemoteUserDataSync();
});
useAssetStore.subscribe((state, previous) => {
if (state.assets !== previous.assets) scheduleRemoteUserDataSync();
});
}
export function resetRemoteUserDataSync() {
activeRemoteUserId = "";
remoteAssetVersions.clear();
remoteProjectVersions.clear();
if (syncTimer) {
window.clearTimeout(syncTimer);
syncTimer = null;
}
syncQueued = false;
}
function waitForRemoteUserDataSyncResume() {
if (!syncPauseCount) return Promise.resolve();
if (!syncResumed) {
syncResumed = new Promise<void>((resolve) => {
resumeSync = resolve;
});
}
return syncResumed;
}
async function runRemoteUserDataSyncOperation<T>(operation: () => Promise<T>): Promise<T> {
while (syncPauseCount) await waitForRemoteUserDataSyncResume();
let finish!: () => void;
const active = new Promise<void>((resolve) => {
finish = resolve;
});
activeRemoteSyncOperations.add(active);
try {
return await operation();
} finally {
activeRemoteSyncOperations.delete(active);
finish();
}
}
export async function withRemoteUserDataSyncPaused<T>(operation: () => Promise<T>): Promise<T> {
syncPauseCount += 1;
if (syncTimer) {
window.clearTimeout(syncTimer);
syncTimer = null;
syncQueued = true;
}
let releaseTurn!: () => void;
const previousTurn = syncPauseTail;
syncPauseTail = new Promise<void>((resolve) => {
releaseTurn = resolve;
});
await previousTurn;
try {
if (activeRemoteSyncOperations.size) {
await Promise.allSettled([...activeRemoteSyncOperations]);
}
if (syncPromise) await syncPromise;
return await operation();
} finally {
releaseTurn();
syncPauseCount -= 1;
if (!syncPauseCount) {
const resolve = resumeSync;
resumeSync = null;
syncResumed = null;
resolve?.();
if (syncQueued) scheduleRemoteUserDataSync();
}
}
}
export function scheduleRemoteUserDataSync() {
if (!activeRemoteUserId || applyingRemoteState) return;
if (syncPauseCount) {
syncQueued = true;
if (syncTimer) {
window.clearTimeout(syncTimer);
syncTimer = null;
}
return;
}
if (syncPromise) {
syncQueued = true;
return;
}
if (syncTimer) window.clearTimeout(syncTimer);
syncTimer = window.setTimeout(() => {
syncTimer = null;
void saveRemoteUserDataNow().catch((error) => console.warn("云端自动同步失败", error));
}, 1200);
}
export async function createCanvasProjectWithRemoteSync(title: string, projectId?: string, initialContent?: Partial<Pick<CanvasProject, "nodes" | "connections">>) {
const id = useCanvasStore.getState().createProject(title, projectId);
if (initialContent) useCanvasStore.getState().updateProject(id, initialContent);
if (!activeRemoteUserId) return { id, syncError: new Error("尚未建立云端同步会话") };
try {
await saveRemoteUserDataNow();
return { id };
} catch (syncError) {
scheduleRemoteUserDataSync();
return { id, syncError };
}
}
export async function deleteAssetWithRemoteSync(id: string) {
await runRemoteUserDataSyncOperation(async () => {
if (activeRemoteUserId) {
await deleteRemoteAsset(id);
remoteAssetVersions.delete(id);
}
useAssetStore.getState().removeAsset(id);
});
}
export async function saveRemoteUserDataNow() {
if (!activeRemoteUserId) return;
if (syncPauseCount) {
syncQueued = true;
await waitForRemoteUserDataSyncResume();
return saveRemoteUserDataNow();
}
if (syncPromise) {
syncQueued = true;
return syncPromise;
}
syncPromise = drainRemoteUserDataChanges();
try {
await syncPromise;
} finally {
syncPromise = null;
}
}
async function drainRemoteUserDataChanges() {
do {
syncQueued = false;
await saveRemoteUserDataBatch();
} while (syncQueued);
}
async function saveRemoteUserDataBatch() {
try {
const currentProjects = useCanvasStore.getState().projects;
const currentAssets = useAssetStore.getState().assets;
const dirtyProjects = currentProjects.filter((item) => !sameVersion(remoteProjectVersions.get(item.id), item.updatedAt));
const dirtyAssets = currentAssets.filter((item) => !sameVersion(remoteAssetVersions.get(item.id), item.updatedAt));
const deletedProjectIds = missingIds(remoteProjectVersions, currentProjects);
const deletedAssetIds = missingIds(remoteAssetVersions, currentAssets);
if (!dirtyProjects.length && !dirtyAssets.length && !deletedProjectIds.length && !deletedAssetIds.length) return;
const uploaded = new Map<string, string>();
const projects = await prepareRemoteCanvasProjects(dirtyProjects, uploaded);
const assets = await prepareRemoteAssets(dirtyAssets, uploaded);
applyingRemoteState = true;
if (projects.length) useCanvasStore.getState().replaceProjects(replaceById(currentProjects, projects));
if (assets.length) useAssetStore.getState().replaceAssets(replaceById(currentAssets, assets));
applyingRemoteState = false;
// SQLite 和接口频控都要求写入保持有界;逐项提交还能准确记录已完成版本。
for (const project of projects) {
await upsertRemoteCanvasProject(project);
remoteProjectVersions.set(project.id, project.updatedAt);
}
for (const asset of assets) {
await upsertRemoteAsset(asset);
remoteAssetVersions.set(asset.id, asset.updatedAt);
}
for (const id of deletedProjectIds) {
await deleteRemoteCanvasProject(id);
remoteProjectVersions.delete(id);
}
for (const id of deletedAssetIds) {
await deleteRemoteAsset(id);
remoteAssetVersions.delete(id);
}
} finally {
applyingRemoteState = false;
}
}
async function prepareRemoteAssets(assets: Asset[], uploaded: Map<string, string>) {
const result: Asset[] = [];
for (const asset of assets) result.push(await ensureRemoteResourceReferences(asset, uploaded));
return result;
}
async function prepareRemoteCanvasProjects(projects: CanvasProject[], uploaded: Map<string, string>) {
const result: CanvasProject[] = [];
for (const project of projects) result.push(await ensureRemoteResourceReferences(project, uploaded));
return result;
}
async function ensureRemoteResourceReferences<T>(value: T, uploaded = new Map<string, string>()): 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));
return result as T;
}
const next: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value)) {
next[key] = await ensureRemoteResourceReferences(child, uploaded);
}
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 resourceStorage = await uploadInlineDataUrl(inline).catch(() => "");
return (resourceStorage ? applyResourceReference(next, resourceStorage) : next) as T;
}
const cached = uploaded.get(storageKey);
const resourceStorage = cached || (await uploadLocalStorageKey(storageKey, next).catch(() => ""));
if (!resourceStorage) return next as T;
uploaded.set(storageKey, resourceStorage);
return applyResourceReference(next, resourceStorage) as T;
}
function applyResourceReference(payload: Record<string, unknown>, storageKey: string) {
const url = resourceFileUrl(storageKey.slice("resource:".length));
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) {
const blob = await (await fetch(dataUrl)).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);
return resourceStorageKey(resource.id);
}
async function uploadLocalStorageKey(storageKey: string, payload: Record<string, unknown>) {
const blob = storageKey.startsWith("image:") ? await getImageBlob(storageKey) : await getMediaBlob(storageKey);
if (!blob) return "";
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),
});
return resourceStorageKey(resource.id);
}
function mergeById<T extends { id?: string; updatedAt?: string }>(local: T[], remote: T[]) {
const items = new Map<string, T>();
remote.forEach((item) => {
if (item.id) items.set(item.id, item);
});
local.forEach((item) => {
if (!item.id) return;
const current = items.get(item.id);
if (!current || timeValue(item.updatedAt) >= timeValue(current.updatedAt)) items.set(item.id, item);
});
return Array.from(items.values()).sort((a, b) => timeValue(b.updatedAt) - timeValue(a.updatedAt));
}
function versionMap(items: Array<{ id: string; updatedAt?: string }>) {
return new Map<string, string>(items.map((item) => [item.id, item.updatedAt || ""]));
}
function missingIds<T extends { id: string }>(remote: Map<string, string>, local: T[]) {
const localIds = new Set(local.map((item) => item.id));
return Array.from(remote.keys()).filter((id) => !localIds.has(id));
}
function replaceById<T extends { id: string }>(current: T[], changed: T[]) {
const changedById = new Map(changed.map((item) => [item.id, item]));
return current.map((item) => changedById.get(item.id) || item);
}
function timeValue(value?: string) {
const time = value ? Date.parse(value) : 0;
return Number.isFinite(time) ? time : 0;
}
function sameVersion(remote?: string, local?: string) {
return Boolean(remote && local) && timeValue(remote) === timeValue(local);
}
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;
}