feat: continue tree rust family cutover

- add rust renderer/state-family scaffolds and inline compat host thinning for page tree, file tree, and picker
- route tree/filetree preflight, file projection, resource artifact, and stream delta contracts through rust plans
- preserve canonical move-order validation, file-tree search projection, and related frontend/runtime regression coverage
This commit is contained in:
lix-2026
2026-04-26 19:35:52 +08:00
parent 338bb2e20f
commit e564dfde02
93 changed files with 17492 additions and 1856 deletions
@@ -0,0 +1,234 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockIsConvexEnabled = vi.fn(() => true);
const mockRequireAuthContext = vi.fn();
const mockGetConvexAuthedHttpClient = vi.fn();
const mockBuildDocumentBridgeContext = vi.fn();
const mockBuildDocumentCommandEnvelope = vi.fn();
const mockResolveRustBridgeCommandPlan = vi.fn();
const mockExecuteRustBridgeMutationTransport = vi.fn();
const mockRecordRustBridgeCommandArtifacts = vi.fn();
const mockMaterializeRustTreeStreamDelta = vi.fn();
const mockMaterializeRustTreeDomainEventPlan = vi.fn();
const mockReadRustTreeDomainEventType = vi.fn();
const mockRecordBridgeCommandArtifacts = vi.fn();
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: () => mockIsConvexEnabled(),
}));
vi.mock("@/lib/auth/authContext", () => ({
HttpError: class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
},
requireAuthContext: () => mockRequireAuthContext(),
}));
vi.mock("@/lib/convex/api", () => ({
api: {
mediaAssets: {
listByIds: "mediaAssets:listByIds",
patchById: "mediaAssets:patchById",
},
documents: {
getMeta: "documents:getMeta",
},
},
}));
vi.mock("@/lib/convex/server", () => ({
getConvexAuthedHttpClient: () => mockGetConvexAuthedHttpClient(),
}));
vi.mock("@/lib/documents/bridge", () => ({
buildDocumentBridgeContext: (...args: unknown[]) => mockBuildDocumentBridgeContext(...args),
buildDocumentCommandEnvelope: (...args: unknown[]) => mockBuildDocumentCommandEnvelope(...args),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
resolveRustBridgeCommandPlan: (...args: unknown[]) => mockResolveRustBridgeCommandPlan(...args),
executeRustBridgeMutationTransport: (...args: unknown[]) =>
mockExecuteRustBridgeMutationTransport(...args),
recordRustBridgeCommandArtifacts: (...args: unknown[]) =>
mockRecordRustBridgeCommandArtifacts(...args),
materializeRustTreeStreamDelta: (...args: unknown[]) =>
mockMaterializeRustTreeStreamDelta(...args),
materializeRustTreeDomainEventPlan: (...args: unknown[]) =>
mockMaterializeRustTreeDomainEventPlan(...args),
readRustTreeDomainEventType: (...args: unknown[]) => mockReadRustTreeDomainEventType(...args),
}));
vi.mock("@/lib/documents/bridge-log", () => ({
recordBridgeCommandArtifacts: (...args: unknown[]) => mockRecordBridgeCommandArtifacts(...args),
}));
vi.mock("@/lib/url/proxyForBrowser", () => ({
maybeProxyForBrowserUrl: (_request: Request, url: string) => url,
}));
describe("/api/media/batch route", () => {
beforeEach(() => {
vi.resetModules();
mockIsConvexEnabled.mockReset().mockReturnValue(true);
mockRequireAuthContext.mockReset().mockResolvedValue({
userId: "user_1",
});
mockBuildDocumentBridgeContext.mockReset().mockResolvedValue({
requestId: "req_1",
traceId: "trace_1",
workspaceId: "ws_1",
actor: { actorType: "user", actorId: "user_1", sessionId: null },
source: { channel: "next-route", client: "vitest" },
tenantId: null,
deploymentId: null,
projectId: null,
authToken: null,
idempotencyKey: null,
validateOnly: false,
dryRun: false,
});
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => ({
...(input as Record<string, unknown>),
commandId: "cmd_asset_1",
idempotencyKey: null,
}));
mockResolveRustBridgeCommandPlan.mockReset().mockResolvedValue({
kind: "command",
commandName: "tree.resource.move",
commandId: "cmd_asset_1",
functionName: "mediaAssets:batchMove",
argsJson: {
domainEventPlan: {
family: "tree",
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "tree.resource.moved",
},
domainEventHint: {
family: "tree",
eventType: "tree.resource.moved",
},
},
});
mockExecuteRustBridgeMutationTransport.mockReset().mockResolvedValue({
items: [
{
id: "asset_1",
workspace_id: "ws_1",
document_id: "doc_target",
asset_type: "file",
file_url: "/file.pdf",
thumbnail_url: "/file.pdf",
file_name: "file.pdf",
file_size: 1024,
mime_type: "application/pdf",
ocr_text: null,
ocr_status: null,
created_at: "2026-04-26T00:00:00Z",
updated_at: "2026-04-26T00:00:00Z",
},
],
});
mockRecordRustBridgeCommandArtifacts.mockReset().mockResolvedValue(null);
mockMaterializeRustTreeStreamDelta.mockReset().mockReturnValue({
op: "upsert_assets",
upsertAssets: [{ id: "asset_1" }],
});
mockMaterializeRustTreeDomainEventPlan.mockReset().mockReturnValue({
family: "tree",
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "tree.resource.moved",
streamDelta: {
op: "upsert_assets",
upsertAssets: [{ id: "asset_1" }],
},
});
mockReadRustTreeDomainEventType.mockReset().mockReturnValue("tree.resource.moved");
mockRecordBridgeCommandArtifacts.mockReset().mockResolvedValue(undefined);
});
it("copy/move 应进入 Rust resource command plan 并记录资源 delta artifact", async () => {
const client = {
query: vi.fn(async (name: string, args: Record<string, unknown>) => {
if (name === "mediaAssets:listByIds") {
expect(args).toEqual({ userId: "user_1", ids: ["asset_1"] });
return [{ id: "asset_1", workspace_id: "ws_1" }];
}
if (name === "documents:getMeta") {
expect(args).toEqual({ id: "doc_target" });
return { id: "doc_target", workspace_id: "ws_1" };
}
return null;
}),
mutation: vi.fn(),
};
mockGetConvexAuthedHttpClient.mockResolvedValue(client);
const { POST } = await import("./route");
const response = await POST(
new Request("http://localhost/api/media/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
action: "move",
assetIds: ["asset_1"],
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
}),
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
items: [
{
id: "asset_1",
document_id: "doc_target",
},
],
});
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
expect.objectContaining({
name: "tree.resource.move",
payload: {
assetIds: ["asset_1"],
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
},
target: {
workspaceId: "ws_1",
pageId: "doc_target",
},
}),
);
expect(mockExecuteRustBridgeMutationTransport).toHaveBeenCalledWith(
expect.objectContaining({
client,
plan: expect.objectContaining({
functionName: "mediaAssets:batchMove",
}),
}),
);
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
expect.objectContaining({
envelope: expect.objectContaining({
name: "tree.resource.move",
}),
plan: expect.objectContaining({
commandName: "tree.resource.move",
functionName: "mediaAssets:batchMove",
}),
result: expect.objectContaining({
items: [expect.objectContaining({ id: "asset_1" })],
}),
}),
);
expect(mockRecordBridgeCommandArtifacts).not.toHaveBeenCalled();
});
});
+65 -94
View File
@@ -1,12 +1,18 @@
import { NextResponse } from "next/server";
import { makeUniqueFileName } from "@/lib/file-tree/naming";
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { api } from "@/lib/convex/api";
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
import {
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
} from "@/lib/documents/bridge";
import {
recordRustBridgeCommandArtifacts,
resolveRustBridgeCommandPlan,
executeRustBridgeMutationTransport,
} from "@/lib/documents/rust-runtime";
export const dynamic = "force-dynamic";
@@ -19,41 +25,6 @@ interface BatchPayload {
targetSubPath?: string;
newName?: string;
}
const BUCKET = process.env.NEXT_PUBLIC_SUPABASE_MEDIA_BUCKET ?? "workspace";
const DEFAULT_DOC_BUCKET = process.env.NEXT_PUBLIC_SUPABASE_DOC_BUCKET ?? "documents";
const parseStoragePath = (fileUrl: string) => {
try {
const url = new URL(fileUrl);
const segments = url.pathname.split("/").filter(Boolean);
const objectIdx = segments.findIndex((seg) => seg === "object");
if (objectIdx === -1 || objectIdx + 2 >= segments.length) return null;
if (segments[objectIdx + 1] === "public") {
const bucket = segments[objectIdx + 2];
const path = segments.slice(objectIdx + 3).join("/");
return { bucket, path };
}
if (segments[objectIdx + 1] === "sign") {
const bucket = segments[objectIdx + 2];
const path = segments.slice(objectIdx + 3).join("/");
return { bucket, path };
}
return null;
} catch {
return null;
}
};
function resolveAssetLocation(asset: any): { bucket: string; path: string } | null {
if (asset?.storage_path) {
return { bucket: asset.bucket || BUCKET, path: asset.storage_path };
}
if (asset?.file_url) {
return parseStoragePath(asset.file_url);
}
return null;
}
function sanitizeSubPath(input: string | undefined): string {
const raw = typeof input === "string" ? input : "";
@@ -67,6 +38,14 @@ function sanitizeSubPath(input: string | undefined): string {
return cleaned.join("/");
}
function sanitizeTransferredAssetForBrowser(request: Request, asset: any) {
return {
...asset,
file_url: maybeProxyForBrowserUrl(request, String(asset?.file_url ?? "")),
thumbnail_url: maybeProxyForBrowserUrl(request, String(asset?.thumbnail_url ?? asset?.file_url ?? "")),
};
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
let auth;
@@ -149,65 +128,57 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "目标页面不存在" }, { status: 404 });
}
const existing = (await client.query(api.mediaAssets.listByDocument, {
userId: auth.userId,
documentId: payload.targetDocumentId,
limit: 500,
})) as any[];
const existingNames = new Set<string>(
(existing ?? []).map((r) => (r?.file_name ?? "").toString()).filter(Boolean),
);
const results: any[] = [];
for (const asset of assets) {
const fileName = makeUniqueFileName(asset.file_name ?? "附件", existingNames).replace(/[\\/]/g, "_");
const storageId = (asset as { storage_id?: string | null })?.storage_id ?? null;
if (!storageId) continue;
if (payload.action === "copy") {
const newId =
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
const created = await client.mutation(api.mediaAssets.createWithStorage, {
userId: auth.userId,
storageId: storageId as any,
asset: {
id: newId,
workspace_id: String(targetDoc.workspace_id),
document_id: String(payload.targetDocumentId),
asset_type: String(asset.asset_type ?? "file"),
file_name: fileName,
file_size: typeof asset.file_size === "number" ? asset.file_size : null,
mime_type: (asset.mime_type ?? null) as any,
},
});
results.push(created);
} else {
await client.mutation(api.mediaAssets.patchById, {
userId: auth.userId,
id: String(asset.id),
patch: {
workspace_id: String(targetDoc.workspace_id),
document_id: String(payload.targetDocumentId),
file_name: fileName,
},
});
results.push({ ...asset, workspace_id: String(targetDoc.workspace_id), document_id: String(payload.targetDocumentId), file_name: fileName });
}
const workspaceId = String(targetDoc.workspace_id ?? "").trim();
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
const safeItems = (results ?? []).map((a: any) => ({
...a,
file_url: maybeProxyForBrowserUrl(request, String(a?.file_url ?? "")),
thumbnail_url: maybeProxyForBrowserUrl(request, String(a?.thumbnail_url ?? a?.file_url ?? "")),
}));
const context = await buildDocumentBridgeContext({
request,
workspaceId,
});
const commandName =
payload.action === "copy" ? "tree.resource.copy" : "tree.resource.move";
const envelope = buildDocumentCommandEnvelope({
name: commandName,
payload: {
assetIds: payload.assetIds,
targetDocumentId: payload.targetDocumentId,
targetSubPath: sanitizeSubPath(payload.targetSubPath),
},
context,
target: {
workspaceId,
pageId: payload.targetDocumentId,
},
reason: `media-batch ${commandName}`,
refs: ["file-tree-resource-command"],
});
const plan = await resolveRustBridgeCommandPlan({
context,
envelope,
});
const result = await executeRustBridgeMutationTransport<{ items?: any[] }>({
client: client as never,
plan,
});
try {
await recordRustBridgeCommandArtifacts({
context,
envelope,
client: client as never,
plan,
result,
});
} catch (error) {
console.warn("[media.batch] Rust bridge artifacts skipped:", error);
}
return NextResponse.json({ items: safeItems });
const safeItems = (result.items ?? []).map((item: any) =>
sanitizeTransferredAssetForBrowser(request, item),
);
return NextResponse.json({ items: safeItems });
}
default:
return NextResponse.json({ error: "不支持的操作" }, { status: 400 });
@@ -0,0 +1,252 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockIsConvexEnabled = vi.fn(() => true);
const mockRequireAuthContext = vi.fn();
const mockGetConvexAuthedHttpClient = vi.fn();
const mockBuildDocumentBridgeContext = vi.fn();
const mockBuildDocumentCommandEnvelope = vi.fn();
const mockResolveRustBridgeCommandPlan = vi.fn();
const mockRecordRustBridgeCommandArtifacts = vi.fn();
const mockMaterializeRustTreeStreamDelta = vi.fn();
const mockMaterializeRustTreeDomainEventPlan = vi.fn();
const mockReadRustTreeDomainEventType = vi.fn();
const mockRecordBridgeCommandArtifacts = vi.fn();
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: () => mockIsConvexEnabled(),
}));
vi.mock("@/lib/auth/authContext", () => ({
HttpError: class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
},
requireAuthContext: () => mockRequireAuthContext(),
}));
vi.mock("@/lib/convex/api", () => ({
api: {
mediaAssets: {
generateUploadUrl: "mediaAssets:generateUploadUrl",
createWithStorage: "mediaAssets:createWithStorage",
},
},
}));
vi.mock("@/lib/convex/server", () => ({
getConvexAuthedHttpClient: () => mockGetConvexAuthedHttpClient(),
}));
vi.mock("@/lib/documents/bridge", () => ({
buildDocumentBridgeContext: (...args: unknown[]) => mockBuildDocumentBridgeContext(...args),
buildDocumentCommandEnvelope: (...args: unknown[]) => mockBuildDocumentCommandEnvelope(...args),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
resolveRustBridgeCommandPlan: (...args: unknown[]) => mockResolveRustBridgeCommandPlan(...args),
recordRustBridgeCommandArtifacts: (...args: unknown[]) =>
mockRecordRustBridgeCommandArtifacts(...args),
materializeRustTreeStreamDelta: (...args: unknown[]) =>
mockMaterializeRustTreeStreamDelta(...args),
materializeRustTreeDomainEventPlan: (...args: unknown[]) =>
mockMaterializeRustTreeDomainEventPlan(...args),
readRustTreeDomainEventType: (...args: unknown[]) => mockReadRustTreeDomainEventType(...args),
}));
vi.mock("@/lib/documents/bridge-log", () => ({
recordBridgeCommandArtifacts: (...args: unknown[]) => mockRecordBridgeCommandArtifacts(...args),
}));
vi.mock("@/lib/url/proxyForBrowser", () => ({
maybeProxyForBrowserUrl: (_request: Request, url: string) => url,
}));
describe("/api/media/upload route", () => {
beforeEach(() => {
vi.resetModules();
vi.restoreAllMocks();
mockIsConvexEnabled.mockReset().mockReturnValue(true);
mockRequireAuthContext.mockReset().mockResolvedValue({
userId: "user_1",
});
mockBuildDocumentBridgeContext.mockReset().mockResolvedValue({
requestId: "req_1",
traceId: "trace_1",
workspaceId: "ws_1",
actor: { actorType: "user", actorId: "user_1", sessionId: null },
source: { channel: "next-route", client: "vitest" },
tenantId: null,
deploymentId: null,
projectId: null,
authToken: null,
idempotencyKey: null,
validateOnly: false,
dryRun: false,
});
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => ({
...(input as Record<string, unknown>),
commandId: "cmd_upload_asset",
idempotencyKey: null,
}));
mockResolveRustBridgeCommandPlan.mockReset().mockResolvedValue({
kind: "command",
commandName: "tree.resource.upload",
commandId: "cmd_upload_asset",
functionName: "mediaAssets:createWithStorage",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {
domainEventPlan: {
family: "tree",
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "tree.resource.uploaded",
},
assetId: "asset_upload_1",
workspaceId: "ws_1",
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
fileName: "demo.pdf",
fileSize: 7,
mimeType: "application/pdf",
assetType: "file",
resourceUploadPlan: {
action: "upload",
assetId: "asset_upload_1",
workspaceId: "ws_1",
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
fileName: "demo.pdf",
fileSize: 7,
mimeType: "application/pdf",
assetType: "file",
},
},
});
mockMaterializeRustTreeStreamDelta.mockReset().mockReturnValue({
op: "upsert_assets",
upsertAssets: [{ id: "asset_upload_1" }],
});
mockMaterializeRustTreeDomainEventPlan.mockReset().mockReturnValue({
family: "tree",
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "tree.resource.uploaded",
streamDelta: {
op: "upsert_assets",
upsertAssets: [{ id: "asset_upload_1" }],
},
});
mockReadRustTreeDomainEventType.mockReset().mockReturnValue("tree.resource.uploaded");
mockRecordRustBridgeCommandArtifacts.mockReset().mockResolvedValue(null);
mockRecordBridgeCommandArtifacts.mockReset().mockResolvedValue(undefined);
});
it("上传应通过 tree.resource.upload plan 写入资源元数据并记录资源 delta artifact", async () => {
const createdAsset = {
id: "asset_upload_1",
workspace_id: "ws_1",
document_id: "doc_target",
asset_type: "file",
file_url: "/files/demo.pdf",
thumbnail_url: "/files/demo.pdf",
file_name: "demo.pdf",
file_size: 7,
mime_type: "application/pdf",
created_at: "2026-04-26T00:00:00Z",
updated_at: "2026-04-26T00:00:00Z",
};
const client = {
mutation: vi.fn(async (name: string, args: Record<string, unknown>) => {
if (name === "mediaAssets:generateUploadUrl") {
expect(args).toEqual({ userId: "user_1" });
return "https://convex.test/upload";
}
if (name === "mediaAssets:createWithStorage") {
expect(args).toMatchObject({
userId: "user_1",
storageId: "storage_1",
targetSubPath: "mindmaps/mind_1",
resourceUploadPlan: {
action: "upload",
assetId: "asset_upload_1",
targetDocumentId: "doc_target",
},
asset: {
id: "asset_upload_1",
workspace_id: "ws_1",
document_id: "doc_target",
file_name: "demo.pdf",
},
});
return createdAsset;
}
return null;
}),
};
mockGetConvexAuthedHttpClient.mockResolvedValue(client);
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ storageId: "storage_1" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
const { POST } = await import("./route");
const formData = new FormData();
formData.append("file", new Blob(["content"], { type: "application/pdf" }), "demo.pdf");
formData.set("workspaceId", "ws_1");
formData.set("documentId", "doc_target");
formData.set("mindmapId", "mind_1");
const response = await POST(
new Request("http://localhost/api/media/upload", {
method: "POST",
body: formData,
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
asset: {
id: "asset_upload_1",
document_id: "doc_target",
},
mindmapUrl: "asset:asset_upload_1",
});
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
expect.objectContaining({
name: "tree.resource.upload",
payload: expect.objectContaining({
workspaceId: "ws_1",
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
fileName: expect.any(String),
}),
}),
);
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
expect.objectContaining({
envelope: expect.objectContaining({
name: "tree.resource.upload",
}),
plan: expect.objectContaining({
commandName: "tree.resource.upload",
functionName: "mediaAssets:createWithStorage",
}),
result: expect.objectContaining({
items: [expect.objectContaining({ id: "asset_upload_1" })],
}),
}),
);
expect(mockRecordBridgeCommandArtifacts).not.toHaveBeenCalled();
});
});
@@ -1,18 +1,21 @@
import { NextResponse } from "next/server";
import type { MediaAsset } from "@/types/media";
import { extname } from "path";
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { api } from "@/lib/convex/api";
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
import {
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
} from "@/lib/documents/bridge";
import {
recordRustBridgeCommandArtifacts,
resolveRustBridgeCommandPlan,
} from "@/lib/documents/rust-runtime";
export const dynamic = "force-dynamic";
const DOC_BUCKET = process.env.NEXT_PUBLIC_SUPABASE_DOC_BUCKET ?? "documents";
const resolveAssetType = (mime: string): "image" | "video" | "audio" | "file" => {
if (mime.startsWith("image/")) return "image";
if (mime.startsWith("video/")) return "video";
@@ -20,6 +23,26 @@ const resolveAssetType = (mime: string): "image" | "video" | "audio" | "file" =>
return "file";
};
function isUploadFile(value: FormDataEntryValue | null): value is File {
return Boolean(
value &&
typeof value === "object" &&
(typeof File === "undefined" || value instanceof File || "arrayBuffer" in value) &&
typeof (value as File).arrayBuffer === "function" &&
typeof (value as File).name === "string",
);
}
function readOptionalStringArg(args: Record<string, unknown>, key: string): string | null {
const value = args[key];
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function readNumberArg(args: Record<string, unknown>, key: string): number | null {
const value = args[key];
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
let auth;
@@ -38,7 +61,7 @@ export async function POST(request: Request) {
const documentId = String(formData.get("documentId") ?? "");
const mindmapIdRaw = String(formData.get("mindmapId") ?? "").trim();
if (!(file instanceof File) || !workspaceId || !documentId) {
if (!isUploadFile(file) || !workspaceId || !documentId) {
return NextResponse.json({ error: "缺少必要参数" }, { status: 400 });
}
@@ -51,6 +74,35 @@ export async function POST(request: Request) {
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
const assetType = resolveAssetType(file.type || "");
const client = await getConvexAuthedHttpClient();
const targetSubPath = mindmapIdRaw ? `mindmaps/${mindmapIdRaw}` : undefined;
const context = await buildDocumentBridgeContext({
request,
workspaceId,
});
const envelope = buildDocumentCommandEnvelope({
name: "tree.resource.upload",
payload: {
assetId,
workspaceId,
targetDocumentId: documentId,
targetSubPath,
fileName: file.name || null,
fileSize: file.size,
mimeType: file.type || null,
assetType,
},
context,
target: {
workspaceId,
pageId: documentId,
},
reason: "media-upload tree.resource.upload",
refs: ["file-tree-resource-upload"],
});
const plan = await resolveRustBridgeCommandPlan({
context,
envelope,
});
// 1) 获取 Convex 的上传 URL(短时有效)
const uploadUrl = await client.mutation(api.mediaAssets.generateUploadUrl, { userId: auth.userId });
@@ -78,16 +130,32 @@ export async function POST(request: Request) {
const created = await client.mutation(api.mediaAssets.createWithStorage, {
userId: auth.userId,
storageId: storageId as any,
targetSubPath: readOptionalStringArg(plan.argsJson, "targetSubPath"),
resourceUploadPlan:
plan.argsJson.resourceUploadPlan && typeof plan.argsJson.resourceUploadPlan === "object"
? plan.argsJson.resourceUploadPlan
: undefined,
asset: {
id: assetId,
workspace_id: workspaceId,
document_id: documentId,
asset_type: assetType,
file_name: file.name || null,
file_size: file.size,
mime_type: file.type || null,
id: readOptionalStringArg(plan.argsJson, "assetId") ?? assetId,
workspace_id: readOptionalStringArg(plan.argsJson, "workspaceId") ?? workspaceId,
document_id: readOptionalStringArg(plan.argsJson, "targetDocumentId") ?? documentId,
asset_type: readOptionalStringArg(plan.argsJson, "assetType") ?? assetType,
file_name: readOptionalStringArg(plan.argsJson, "fileName"),
file_size: readNumberArg(plan.argsJson, "fileSize"),
mime_type: readOptionalStringArg(plan.argsJson, "mimeType"),
},
});
try {
await recordRustBridgeCommandArtifacts({
context,
envelope,
client: client as never,
plan,
result: { items: [created] },
});
} catch (error) {
console.warn("[media.upload] Rust bridge artifacts skipped:", error);
}
const asset = created as unknown as MediaAsset;
const safeAsset = {