4-26 树rust-2

This commit is contained in:
lix-2026
2026-04-26 04:29:23 +08:00
parent 94631f3636
commit 338bb2e20f
58 changed files with 11718 additions and 1256 deletions
@@ -1,23 +1,5 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { api } from "@/lib/convex/api";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
assertDocumentId,
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executePageLifecycleBridgeCommand,
type DocumentCopyTreePayload,
} from "@/lib/documents/page-command-adapter";
import {
copyMindmapFilesIfExists,
ensureDocumentScaffold,
} from "@/lib/documents/page-lifecycle-side-effects";
export const dynamic = "force-dynamic";
type CopyTreeItem = {
documentId: string;
@@ -25,102 +7,88 @@ type CopyTreeItem = {
};
type CopyTreePayload = {
items: CopyTreeItem[];
targetParentId: string | null;
items?: CopyTreeItem[] | null;
targetParentId?: string | null;
};
type TreeCopyResponse = {
requestId?: string;
traceId?: string;
result?: {
items?: Array<{
oldId: string;
newId: string;
}>;
} | null;
};
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (isConvexEnabled()) {
try {
const { client } = await getAuthedConvexClient();
const payload = (await request.json()) as CopyTreePayload;
const normalizedItems = (payload.items ?? [])
.filter((item) => item?.documentId)
.map((item) => ({
documentId: assertDocumentId(item.documentId),
recursive: Boolean(item.recursive),
}));
if (normalizedItems.length === 0) {
return NextResponse.json({ error: "items 为空" }, { status: 400 });
}
const normalizedTargetParentId = payload.targetParentId?.trim() || null;
let workspaceId: string | null = null;
if (normalizedTargetParentId) {
const targetDoc = await client.query(api.documents.getMeta, { id: normalizedTargetParentId });
if (!targetDoc) {
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
}
workspaceId = targetDoc.workspace_id?.trim() || null;
} else {
const firstDoc = await client.query(api.documents.getMeta, { id: normalizedItems[0].documentId });
if (!firstDoc) {
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
}
workspaceId = firstDoc.workspace_id?.trim() || null;
}
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
const bridgeContext = await buildDocumentBridgeContext({
request,
workspaceId,
});
const result = await executePageLifecycleBridgeCommand<
DocumentCopyTreePayload,
{
items: Array<{
oldId: string;
newId: string;
title?: string | null;
}>;
}
>({
context: bridgeContext,
envelope: buildDocumentCommandEnvelope({
name: "documents.copy_tree",
payload: {
workspaceId,
targetParentId: normalizedTargetParentId,
items: normalizedItems,
},
context: bridgeContext,
target: {
workspaceId,
pageId: normalizedTargetParentId,
},
}),
});
await Promise.all(
result.result.items.map(async (item) => {
await ensureDocumentScaffold(item.newId, item.title ?? "无标题");
await copyMindmapFilesIfExists(item.oldId, item.newId);
}),
);
return NextResponse.json({
items: result.result.items.map((item) => ({
oldId: item.oldId,
newId: item.newId,
})),
meta: {
requestId: result.requestId,
traceId: result.traceId,
commandId: result.commandId,
commandName: result.commandName,
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
try {
const payload = (await request.json()) as CopyTreePayload;
const normalizedItems = (payload.items ?? [])
.filter((item) => item?.documentId)
.map((item) => ({
documentId: item.documentId.trim(),
recursive: Boolean(item.recursive),
}))
.filter((item) => item.documentId.length > 0);
if (normalizedItems.length === 0) {
return NextResponse.json({ error: "items 为空" }, { status: 400 });
}
const upstreamUrl = new URL("/api/tree/commands", request.url);
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify({
action: "copy",
targetParentId:
typeof payload.targetParentId === "string" ? payload.targetParentId.trim() || null : null,
items: normalizedItems,
}),
});
const result = (await response.json().catch(() => null)) as
| TreeCopyResponse
| { error?: string }
| null;
if (!response.ok) {
return NextResponse.json(
{
error:
result && typeof result === "object" && "error" in result && typeof result.error === "string"
? result.error
: "复制页面失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
items: result?.result?.items ?? [],
meta: {
requestId: result?.requestId,
traceId: result?.traceId,
commandName: "tree.subtree.copy",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "复制页面失败,请稍后再试",
},
{ status: 500 },
);
}
}
export const runtime = "nodejs";
@@ -1,59 +1,75 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import {
assertDocumentId,
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executePageLifecycleBridgeCommand,
type DocumentDeletePayload,
} from "@/lib/documents/page-command-adapter";
type TreeArchiveResponse = {
requestId?: string;
traceId?: string;
};
type DeletePayload = {
documentId?: string | null;
workspaceId?: string | null;
};
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (isConvexEnabled()) {
try {
const body = (await request.json()) as { documentId?: string; workspaceId?: string | null };
const normalizedDocumentId = assertDocumentId(body.documentId);
const normalizedWorkspaceId = body.workspaceId?.trim() || null;
const bridgeContext = await buildDocumentBridgeContext({
request,
workspaceId: normalizedWorkspaceId,
});
const result = await executePageLifecycleBridgeCommand<DocumentDeletePayload, { ok: boolean }>({
context: bridgeContext,
envelope: buildDocumentCommandEnvelope({
name: "documents.delete",
payload: {
documentId: normalizedDocumentId,
workspaceId: normalizedWorkspaceId,
},
context: bridgeContext,
target: {
workspaceId: normalizedWorkspaceId,
pageId: normalizedDocumentId,
},
}),
});
return NextResponse.json({
success: true,
meta: {
requestId: result.requestId,
traceId: result.traceId,
commandId: result.commandId,
commandName: result.commandName,
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
try {
const { documentId, workspaceId }: DeletePayload = await request.json();
const normalizedDocumentId = typeof documentId === "string" ? documentId.trim() : "";
if (!normalizedDocumentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const upstreamUrl = new URL("/api/tree/commands", request.url);
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify({
action: "archive",
documentId: normalizedDocumentId,
workspaceId: typeof workspaceId === "string" ? workspaceId.trim() || null : null,
}),
});
const payload = (await response.json().catch(() => null)) as
| TreeArchiveResponse
| { error?: string }
| null;
if (!response.ok) {
return NextResponse.json(
{
error:
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
? payload.error
: "删除失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
success: true,
meta: {
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.node.archive",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "删除失败,请稍后再试",
},
{ status: 500 },
);
}
}
export const runtime = "nodejs";
@@ -1,11 +1,74 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { executeDocumentEmbedBridgeCommand } from "@/lib/documents/page-command-adapter";
type TreeEmbedResponse = {
requestId?: string;
traceId?: string;
};
type EmbedPayload = {
sourceId?: string | null;
targetId?: string | null;
};
export async function POST(request: Request) {
if (isConvexEnabled()) {
return executeDocumentEmbedBridgeCommand(request);
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
try {
const { sourceId, targetId }: EmbedPayload = await request.json();
const normalizedSourceId = typeof sourceId === "string" ? sourceId.trim() : "";
const normalizedTargetId = typeof targetId === "string" ? targetId.trim() : "";
if (!normalizedSourceId || !normalizedTargetId) {
return NextResponse.json({ error: "缺少 sourceId 或 targetId" }, { status: 400 });
}
const upstreamUrl = new URL("/api/tree/commands", request.url);
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify({
action: "embed",
sourceId: normalizedSourceId,
targetId: normalizedTargetId,
}),
});
const payload = (await response.json().catch(() => null)) as
| TreeEmbedResponse
| { error?: string }
| null;
if (!response.ok) {
return NextResponse.json(
{
error:
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
? payload.error
: "嵌入失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
ok: true,
meta: {
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.node.embed",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "嵌入失败,请稍后再试",
},
{ status: 500 },
);
}
}
export const runtime = "nodejs";
@@ -1,13 +1,77 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { executeDocumentPurgeBridgeCommand } from "@/lib/documents/page-command-adapter";
type TreePurgeResponse = {
requestId?: string;
traceId?: string;
result?: {
purged?: boolean;
} | null;
};
type PurgePayload = {
documentId?: string | null;
};
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (isConvexEnabled()) {
return executeDocumentPurgeBridgeCommand(request);
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
try {
const { documentId }: PurgePayload = await request.json();
const normalizedDocumentId = typeof documentId === "string" ? documentId.trim() : "";
if (!normalizedDocumentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const upstreamUrl = new URL("/api/tree/commands", request.url);
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify({
action: "purge",
documentId: normalizedDocumentId,
}),
});
const payload = (await response.json().catch(() => null)) as
| TreePurgeResponse
| { error?: string }
| null;
if (!response.ok) {
return NextResponse.json(
{
error:
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
? payload.error
: "彻底删除失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
success: true,
purged: payload?.result?.purged ?? true,
meta: {
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.node.purge",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "彻底删除失败,请稍后再试",
},
{ status: 500 },
);
}
}
export const runtime = "nodejs";
@@ -1,59 +1,75 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import {
assertDocumentId,
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executePageLifecycleBridgeCommand,
type DocumentRestorePayload,
} from "@/lib/documents/page-command-adapter";
type TreeRestoreResponse = {
requestId?: string;
traceId?: string;
};
type RestorePayload = {
documentId?: string | null;
workspaceId?: string | null;
};
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (isConvexEnabled()) {
try {
const body = (await request.json()) as { documentId?: string; workspaceId?: string | null };
const normalizedDocumentId = assertDocumentId(body.documentId);
const normalizedWorkspaceId = body.workspaceId?.trim() || null;
const bridgeContext = await buildDocumentBridgeContext({
request,
workspaceId: normalizedWorkspaceId,
});
const result = await executePageLifecycleBridgeCommand<DocumentRestorePayload, { ok: boolean }>({
context: bridgeContext,
envelope: buildDocumentCommandEnvelope({
name: "documents.restore",
payload: {
documentId: normalizedDocumentId,
workspaceId: normalizedWorkspaceId,
},
context: bridgeContext,
target: {
workspaceId: normalizedWorkspaceId,
pageId: normalizedDocumentId,
},
}),
});
return NextResponse.json({
success: true,
meta: {
requestId: result.requestId,
traceId: result.traceId,
commandId: result.commandId,
commandName: result.commandName,
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
try {
const { documentId, workspaceId }: RestorePayload = await request.json();
const normalizedDocumentId = typeof documentId === "string" ? documentId.trim() : "";
if (!normalizedDocumentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const upstreamUrl = new URL("/api/tree/commands", request.url);
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify({
action: "restore",
documentId: normalizedDocumentId,
workspaceId: typeof workspaceId === "string" ? workspaceId.trim() || null : null,
}),
});
const payload = (await response.json().catch(() => null)) as
| TreeRestoreResponse
| { error?: string }
| null;
if (!response.ok) {
return NextResponse.json(
{
error:
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
? payload.error
: "恢复失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
success: true,
meta: {
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.node.restore",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "恢复失败,请稍后再试",
},
{ status: 500 },
);
}
}
export const runtime = "nodejs";
@@ -72,22 +72,23 @@ vi.mock("@/lib/documents/page-aggregate-loader", () => ({
}));
import { POST as postCreateChild } from "@/app/api/documents/create-child/route";
import { POST as postDelete } from "@/app/api/documents/delete/route";
import { POST as postEmbed } from "@/app/api/documents/embed/route";
import { POST as postTemplate } from "@/app/api/documents/template/route";
import { POST as postEmptyTrash } from "@/app/api/documents/empty-trash/route";
import { POST as postPurge } from "@/app/api/documents/purge/route";
import { POST as postRestore } from "@/app/api/documents/restore/route";
import { POST as postTitle } from "@/app/api/documents/title/route";
import { POST as postOptions } from "@/app/api/documents/options/route";
import { POST as postSave } from "@/app/api/documents/save/route";
import { POST as postCopyTree } from "@/app/api/documents/copy-tree/route";
import { POST as postCreate } from "@/app/api/documents/create/route";
import { POST as postMove } from "@/app/api/documents/move/route";
import { GET as getPage } from "@/app/api/documents/page/route";
import {
executeDocumentCreateChildBridgeCommand,
executeDocumentEmbedBridgeCommand,
executeDocumentTemplateBridgeCommand,
executeDocumentEmptyTrashBridgeCommand,
executeDocumentPurgeBridgeCommand,
} from "@/lib/documents/page-command-adapter";
import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-command-adapter";
import { loadPageAggregate } from "@/lib/documents/page-aggregate-loader";
@@ -246,12 +247,115 @@ describe("documents route adapters", () => {
expect(executeDocumentCreateChildBridgeCommand).toHaveBeenCalled();
});
it("embed route delegates to unified adapter", async () => {
await postEmbed(new Request("http://localhost/api/documents/embed", {
it("delete route 作为 compat 壳委托 tree commands 主路径", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
requestId: "req_tree_archive_1",
traceId: "trace_tree_archive_1",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await postDelete(new Request("http://localhost/api/documents/delete", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1" }),
}));
const payload = await response.json() as {
success: boolean;
meta: { commandName: string };
};
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost/api/tree/commands",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body: JSON.stringify({
action: "archive",
documentId: "doc_1",
workspaceId: "ws_1",
}),
}),
);
expect(payload.success).toBe(true);
expect(payload.meta.commandName).toBe("tree.node.archive");
});
it("restore route 作为 compat 壳委托 tree commands 主路径", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
requestId: "req_tree_restore_1",
traceId: "trace_tree_restore_1",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await postRestore(new Request("http://localhost/api/documents/restore", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1" }),
}));
const payload = await response.json() as {
success: boolean;
meta: { commandName: string };
};
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost/api/tree/commands",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body: JSON.stringify({
action: "restore",
documentId: "doc_1",
workspaceId: "ws_1",
}),
}),
);
expect(payload.success).toBe(true);
expect(payload.meta.commandName).toBe("tree.node.restore");
});
it("embed route 作为 compat 壳委托 tree commands 主路径", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
requestId: "req_tree_embed_1",
traceId: "trace_tree_embed_1",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await postEmbed(new Request("http://localhost/api/documents/embed", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ sourceId: "doc_1", targetId: "doc_2" }),
}));
expect(executeDocumentEmbedBridgeCommand).toHaveBeenCalled();
const payload = await response.json() as {
ok: boolean;
meta: { commandName: string };
};
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost/api/tree/commands",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body: JSON.stringify({
action: "embed",
sourceId: "doc_1",
targetId: "doc_2",
}),
}),
);
expect(payload.ok).toBe(true);
expect(payload.meta.commandName).toBe("tree.node.embed");
});
it("template route delegates to unified adapter", async () => {
@@ -270,12 +374,85 @@ describe("documents route adapters", () => {
expect(executeDocumentEmptyTrashBridgeCommand).toHaveBeenCalled();
});
it("purge route delegates to unified adapter", async () => {
await postPurge(new Request("http://localhost/api/documents/purge", {
it("purge route 作为 compat 壳委托 tree commands 主路径", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
requestId: "req_tree_purge_1",
traceId: "trace_tree_purge_1",
result: {
purged: true,
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await postPurge(new Request("http://localhost/api/documents/purge", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ documentId: "doc_1" }),
}));
expect(executeDocumentPurgeBridgeCommand).toHaveBeenCalled();
const payload = await response.json() as {
success: boolean;
purged: boolean;
meta: { commandName: string };
};
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost/api/tree/commands",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body: JSON.stringify({
action: "purge",
documentId: "doc_1",
}),
}),
);
expect(payload.success).toBe(true);
expect(payload.purged).toBe(true);
expect(payload.meta.commandName).toBe("tree.node.purge");
});
it("copy-tree route 作为 compat 壳委托 tree commands 主路径", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
requestId: "req_tree_copy_1",
traceId: "trace_tree_copy_1",
result: {
items: [{ oldId: "doc_1", newId: "doc_2" }],
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await postCopyTree(new Request("http://localhost/api/documents/copy-tree", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ targetParentId: null, items: [{ documentId: "doc_1", recursive: true }] }),
}));
const payload = await response.json() as {
items: Array<{ oldId: string; newId: string }>;
meta: { commandName: string };
};
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost/api/tree/commands",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body: JSON.stringify({
action: "copy",
targetParentId: null,
items: [{ documentId: "doc_1", recursive: true }],
}),
}),
);
expect(payload.items).toEqual([{ oldId: "doc_1", newId: "doc_2" }]);
expect(payload.meta.commandName).toBe("tree.subtree.copy");
});
it("page route delegates to unified aggregate loader", async () => {