feat: 完成 rust cutover phase 8 收口
This commit is contained in:
@@ -6,7 +6,6 @@ import { createToolRegistry, resolveAllowedToolIds } from "@/lib/ai-agent/tools/
|
||||
import { builtinTools, builtinToolSets } from "@/lib/ai-agent/tools/builtins/registryBuiltins";
|
||||
import { runAiAgent } from "@/lib/ai-agent/runtime/runAgent";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { searchSearxng } from "@/lib/ai-agent/tools/builtins/searchWeb";
|
||||
import { createMindmapServerTools, type MindmapSupabaseClient } from "@/lib/ai-agent/tools/builtins/mindmap/mindmapServerTools";
|
||||
import { createDocServerTools, type DocSupabaseClient } from "@/lib/ai-agent/tools/builtins/doc/docServerTools";
|
||||
import { createRagServerTools } from "@/lib/ai-agent/tools/builtins/rag/lightragServerTools";
|
||||
@@ -18,6 +17,13 @@ import { buildClientToolKey, registerClientToolCall } from "@/lib/ai-agent/runti
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import type { ConvexHttpClient } from "convex/browser";
|
||||
import {
|
||||
buildDocumentBridgeContextWithActor,
|
||||
buildDocumentCommandEnvelope,
|
||||
DocumentBridgeError,
|
||||
} from "@/lib/documents/bridge";
|
||||
import { recordBridgeCommandFailureArtifacts } from "@/lib/documents/bridge-log";
|
||||
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
|
||||
import {
|
||||
DEFAULT_AGENT_MAX_STEPS,
|
||||
MAX_AGENT_STEPS,
|
||||
@@ -106,6 +112,26 @@ const makeRunId = () => {
|
||||
};
|
||||
|
||||
const isOnlyOfficeClientTool = (toolId: string) => toolId.startsWith("oo_");
|
||||
const isDocRustTool = (toolId: string): toolId is "doc_get" | "doc_find" | "doc_insert_blocks" | "doc_replace_range" =>
|
||||
toolId === "doc_get" ||
|
||||
toolId === "doc_find" ||
|
||||
toolId === "doc_insert_blocks" ||
|
||||
toolId === "doc_replace_range";
|
||||
const isMindmapWriteTool = (toolId: string) =>
|
||||
toolId === "mindmap_put" ||
|
||||
toolId === "mindmap_apply_ops" ||
|
||||
toolId === "mindmap_add_child" ||
|
||||
toolId === "mindmap_add_sibling_after" ||
|
||||
toolId === "mindmap_update_node_text" ||
|
||||
toolId === "mindmap_set_hyperlink" ||
|
||||
toolId === "mindmap_append_note" ||
|
||||
toolId === "mindmap_set_refs" ||
|
||||
toolId === "mindmap_delete_node" ||
|
||||
toolId === "mindmap_add_attachment_ref" ||
|
||||
toolId === "mindmap_add_attachment_child" ||
|
||||
toolId === "mindmap_add_image_child" ||
|
||||
toolId === "mindmap_append_image_note" ||
|
||||
toolId === "mindmap_expand_node";
|
||||
|
||||
const sseHeaders = {
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
@@ -179,6 +205,20 @@ export async function POST(request: Request) {
|
||||
return errorResponses.unauthorized();
|
||||
}
|
||||
|
||||
const aiBridgeContext = buildDocumentBridgeContextWithActor({
|
||||
request,
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: userId,
|
||||
sessionId: null,
|
||||
},
|
||||
workspaceId: null,
|
||||
source: {
|
||||
channel: "ai-agent-route",
|
||||
client: "wolai-frontend",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = normalizeProvider(payload.options?.ai?.provider);
|
||||
const modelOverride = String(payload.options?.ai?.model ?? "").trim() || null;
|
||||
|
||||
@@ -587,10 +627,6 @@ export async function POST(request: Request) {
|
||||
const blocks = normalizeBlocksForTools(res?.content ?? null);
|
||||
return { blocks, source: "convex" };
|
||||
},
|
||||
saveBlocks: async (blocks: unknown[]) => {
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
await convexClient.mutation(api.documents.updateContent, { id: documentId, content: blocks });
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
@@ -668,12 +704,12 @@ export async function POST(request: Request) {
|
||||
: null;
|
||||
|
||||
const mediaTools = allowedToolIds.has("image_read")
|
||||
? createMediaServerTools({
|
||||
supabase: supabase as unknown as MediaSupabaseClient,
|
||||
ctx: { userId, attachments },
|
||||
allowedToolIds,
|
||||
...(convexOn
|
||||
? {
|
||||
? createMediaServerTools({
|
||||
supabase: supabase as unknown as MediaSupabaseClient,
|
||||
ctx: { userId, attachments },
|
||||
allowedToolIds,
|
||||
...(convexOn
|
||||
? {
|
||||
loadById: async (id: string) => {
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
return await convexClient.query(api.mediaAssets.getById, { userId, id });
|
||||
@@ -694,7 +730,7 @@ export async function POST(request: Request) {
|
||||
: null;
|
||||
|
||||
const slashTools = allowedToolIds.has("slash_run")
|
||||
? createSlashServerTools({
|
||||
? createSlashServerTools({
|
||||
supabase: supabase as unknown as SlashSupabaseClient,
|
||||
ctx: { userId, currentDocumentId: documentId || undefined },
|
||||
allowedToolIds,
|
||||
@@ -755,7 +791,20 @@ export async function POST(request: Request) {
|
||||
if (toolId === "search_web") {
|
||||
const query = String(toolArgs.query ?? "").trim();
|
||||
const count = Number(toolArgs.count ?? DEFAULT_SEARCH_COUNT);
|
||||
return await searchSearxng(query, Number.isFinite(count) ? count : DEFAULT_SEARCH_COUNT);
|
||||
const rustCount = Number.isFinite(count) ? count : DEFAULT_SEARCH_COUNT;
|
||||
const rustResult = await executeRustBridgeTool<unknown>({
|
||||
context: aiBridgeContext,
|
||||
toolName: "search_web",
|
||||
invocationKind: "query",
|
||||
args: {
|
||||
query,
|
||||
count: rustCount,
|
||||
},
|
||||
data: {
|
||||
source: "ai-agent-route",
|
||||
},
|
||||
});
|
||||
return rustResult.result;
|
||||
}
|
||||
if (toolId.startsWith("rag_")) {
|
||||
if (!ragTools) throw new Error(`工具未初始化:${toolId}`);
|
||||
@@ -766,25 +815,162 @@ export async function POST(request: Request) {
|
||||
return await docsTools.run(toolId, toolArgs);
|
||||
}
|
||||
if (toolId === "image_read") {
|
||||
if (!mediaTools) throw new Error(`工具未初始化:${toolId}`);
|
||||
return await mediaTools.run(toolId, toolArgs);
|
||||
if (!mediaTools) throw new Error(`工具未初始化:${toolId}`);
|
||||
const transport = await mediaTools.resolveImageReadTransport(toolArgs);
|
||||
const rustResult = await executeRustBridgeTool<typeof transport>({
|
||||
context: aiBridgeContext,
|
||||
toolName: "image_read",
|
||||
invocationKind: "query",
|
||||
args: {
|
||||
assetId: typeof toolArgs.assetId === "string" ? toolArgs.assetId : undefined,
|
||||
fileUrl: typeof toolArgs.fileUrl === "string" ? toolArgs.fileUrl : undefined,
|
||||
attachmentRef: typeof toolArgs.attachmentRef === "string" ? toolArgs.attachmentRef : undefined,
|
||||
},
|
||||
data: {
|
||||
source: "ai-agent-route",
|
||||
...transport,
|
||||
},
|
||||
});
|
||||
return rustResult.result;
|
||||
}
|
||||
if (toolId.startsWith("asset_")) {
|
||||
if (!onlyofficeTools) throw new Error(`工具未初始化:${toolId}`);
|
||||
return await onlyofficeTools.run(toolId, toolArgs);
|
||||
}
|
||||
if (toolId === "slash_run") {
|
||||
if (!slashTools) throw new Error(`工具未初始化:${toolId}`);
|
||||
return await slashTools.run(toolId, toolArgs);
|
||||
if (!slashTools) throw new Error(`工具未初始化:${toolId}`);
|
||||
const rustResult = await executeRustBridgeTool<{
|
||||
ok: boolean;
|
||||
source?: string;
|
||||
parsed?: unknown;
|
||||
}>({
|
||||
context: aiBridgeContext,
|
||||
toolName: "slash_run",
|
||||
invocationKind: "command",
|
||||
args: {
|
||||
text: typeof toolArgs.text === "string" ? toolArgs.text : undefined,
|
||||
command: typeof toolArgs.command === "string" ? toolArgs.command : undefined,
|
||||
params:
|
||||
isPlainObject(toolArgs.params) || Array.isArray(toolArgs.params)
|
||||
? toolArgs.params
|
||||
: undefined,
|
||||
},
|
||||
data: {
|
||||
source: "ai-agent-route",
|
||||
},
|
||||
});
|
||||
const parsed = rustResult.result?.parsed;
|
||||
if (!isPlainObject(parsed) || parsed.ok !== true) {
|
||||
throw new Error("Rust runtime 未返回有效的 slash_run 解析结果");
|
||||
}
|
||||
return await slashTools.executeSlashTransport(parsed as {
|
||||
ok: true;
|
||||
command: "new_doc" | "rename_doc";
|
||||
params: Record<string, unknown>;
|
||||
});
|
||||
}
|
||||
if (toolId.startsWith("doc_")) {
|
||||
if (!hasDocumentContext) throw new Error(`工具需要 document 上下文:${toolId}`);
|
||||
if (convexOn && convexClient && isDocRustTool(toolId)) {
|
||||
const base = normalizeBlocksForTools(documentBlocks);
|
||||
const runtimeContext = buildDocumentBridgeContextWithActor({
|
||||
request,
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: userId,
|
||||
sessionId: null,
|
||||
},
|
||||
workspaceId: null,
|
||||
source: {
|
||||
channel: "ai-agent-route",
|
||||
client: "wolai-frontend",
|
||||
},
|
||||
});
|
||||
const result = await executeRustBridgeTool({
|
||||
context: runtimeContext,
|
||||
toolName: toolId,
|
||||
invocationKind:
|
||||
toolId === "doc_get" || toolId === "doc_find" ? "query" : "command",
|
||||
args: toolArgs,
|
||||
data: {
|
||||
source: base.length > 0 ? "client" : "convex",
|
||||
blocks:
|
||||
base.length > 0
|
||||
? base
|
||||
: normalizeBlocksForTools(
|
||||
(await convexClient.query(api.documents.getContent, { id: documentId }))?.content ?? null,
|
||||
),
|
||||
},
|
||||
target: {
|
||||
pageId: documentId,
|
||||
workspaceId: null,
|
||||
blockId:
|
||||
typeof toolArgs.blockId === "string"
|
||||
? toolArgs.blockId
|
||||
: typeof toolArgs.afterBlockId === "string"
|
||||
? toolArgs.afterBlockId
|
||||
: typeof toolArgs.beforeBlockId === "string"
|
||||
? toolArgs.beforeBlockId
|
||||
: null,
|
||||
},
|
||||
reason:
|
||||
typeof toolArgs.reason === "string" && toolArgs.reason.trim()
|
||||
? toolArgs.reason.trim()
|
||||
: `ai-agent:${toolId}`,
|
||||
refs: ["task-031", "ai-agent"],
|
||||
});
|
||||
return result.result;
|
||||
}
|
||||
if (!docTools) throw new Error(`工具需要 document 上下文:${toolId}`);
|
||||
return await docTools.run(toolId, toolArgs);
|
||||
}
|
||||
if (!hasMindmapContext) throw new Error(`工具需要 mindmap 上下文:${toolId}`);
|
||||
if (!mindmapTools) throw new Error(`工具需要 mindmap 上下文:${toolId}`);
|
||||
return await mindmapTools.run(toolId, toolArgs);
|
||||
try {
|
||||
return await mindmapTools.run(toolId, toolArgs);
|
||||
} catch (error) {
|
||||
if (isMindmapWriteTool(toolId) && hasMindmapContext) {
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.save",
|
||||
payload: {
|
||||
documentId,
|
||||
workspaceId: null,
|
||||
reason:
|
||||
typeof toolArgs.reason === "string" && toolArgs.reason.trim()
|
||||
? toolArgs.reason.trim()
|
||||
: `ai-agent:${toolId}`,
|
||||
toolId,
|
||||
toolArgs,
|
||||
},
|
||||
context: aiBridgeContext,
|
||||
target: {
|
||||
workspaceId: null,
|
||||
pageId: documentId,
|
||||
blockId: mindmapId,
|
||||
},
|
||||
reason:
|
||||
typeof toolArgs.reason === "string" && toolArgs.reason.trim()
|
||||
? toolArgs.reason.trim()
|
||||
: `ai-agent:${toolId}`,
|
||||
refs: ["task-042", "ai-agent", toolId],
|
||||
});
|
||||
await recordBridgeCommandFailureArtifacts({
|
||||
context: aiBridgeContext,
|
||||
envelope,
|
||||
client: convexClient ?? undefined,
|
||||
error:
|
||||
error instanceof DocumentBridgeError
|
||||
? error
|
||||
: new DocumentBridgeError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
500,
|
||||
"TRANSPORT_ERROR",
|
||||
{ reason: "mindmap_failed", toolId },
|
||||
),
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const maxSteps = (() => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getBlocksFromDocumentContent, findBlockInTree, withBlocksWrittenBack } from "@/lib/blocks";
|
||||
import {
|
||||
documentBridgeErrorResponse,
|
||||
executeBlockEmbedCommand,
|
||||
} from "@/lib/blocks/block-command-adapter";
|
||||
|
||||
type EmbedBlockPayload = {
|
||||
sourceDocumentId: string;
|
||||
@@ -24,42 +24,26 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { client } = await getAuthedConvexClient();
|
||||
|
||||
const source = await client.query(api.documents.getContent, { id: sourceDocumentId });
|
||||
if (!source) return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
||||
const sourceBlocks = getBlocksFromDocumentContent(source.content);
|
||||
const hit = findBlockInTree(sourceBlocks, blockId);
|
||||
if (!hit) return NextResponse.json({ error: "源块不存在或无权限" }, { status: 404 });
|
||||
|
||||
const target = await client.query(api.documents.getContent, { id: targetDocumentId });
|
||||
if (!target) return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||
const targetMeta = await client.query(api.documents.getMeta, { id: targetDocumentId });
|
||||
|
||||
const targetBlocks = getBlocksFromDocumentContent(target.content);
|
||||
const anchorId = (targetMeta as any)?.embed_default_block_id as string | null | undefined;
|
||||
const anchorIndex =
|
||||
typeof anchorId === "string" && anchorId.trim()
|
||||
? targetBlocks.findIndex((b) => String((b as any)?.id ?? "") === anchorId)
|
||||
: -1;
|
||||
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : targetBlocks.length;
|
||||
const referenceBlock = {
|
||||
id: randomUUID(),
|
||||
type: "blockReference",
|
||||
props: {
|
||||
try {
|
||||
const result = await executeBlockEmbedCommand({
|
||||
request,
|
||||
sourceDocumentId,
|
||||
targetBlockId: blockId,
|
||||
display: "embed",
|
||||
},
|
||||
content: [],
|
||||
children: [],
|
||||
};
|
||||
|
||||
const nextBlocks = [...targetBlocks.slice(0, insertIndex), referenceBlock, ...targetBlocks.slice(insertIndex)];
|
||||
const payload = withBlocksWrittenBack(target.content, nextBlocks);
|
||||
await client.mutation(api.documents.updateContent, { id: targetDocumentId, content: payload });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
blockId,
|
||||
targetDocumentId,
|
||||
});
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
...(result.result && typeof result.result === "object" ? result.result : {}),
|
||||
meta: {
|
||||
requestId: result.requestId,
|
||||
traceId: result.traceId,
|
||||
commandId: result.commandId,
|
||||
commandName: result.commandName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
@@ -121,3 +105,5 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ ok: true });
|
||||
*/
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getBlocksFromDocumentContent, findBlockInTree } from "@/lib/blocks";
|
||||
import {
|
||||
documentBridgeErrorResponse,
|
||||
executeBlockGetQuery,
|
||||
} from "@/lib/blocks/block-command-adapter";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
@@ -14,13 +15,16 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const doc = await client.query(api.documents.getContent, { id: sourceDocumentId });
|
||||
if (!doc) return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 });
|
||||
const blocks = getBlocksFromDocumentContent(doc.content);
|
||||
const hit = findBlockInTree(blocks, blockId);
|
||||
if (!hit) return NextResponse.json({ error: "块不存在或无权限" }, { status: 404 });
|
||||
return NextResponse.json({ ok: true, block: hit.block });
|
||||
try {
|
||||
const result = await executeBlockGetQuery({
|
||||
request,
|
||||
sourceDocumentId,
|
||||
blockId,
|
||||
});
|
||||
return NextResponse.json({ ok: true, block: result.block, meta: result.meta });
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
@@ -47,3 +51,5 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ ok: true, block: hit.block });
|
||||
*/
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getBlocksFromDocumentContent, removeBlockSubtree, withBlocksWrittenBack } from "@/lib/blocks";
|
||||
import {
|
||||
documentBridgeErrorResponse,
|
||||
executeBlockMoveCommand,
|
||||
} from "@/lib/blocks/block-command-adapter";
|
||||
|
||||
type MoveBlockPayload = {
|
||||
sourceDocumentId: string;
|
||||
@@ -24,25 +25,30 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { client } = await getAuthedConvexClient();
|
||||
|
||||
const source = await client.query(api.documents.getContent, { id: sourceDocumentId });
|
||||
if (!source) return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
||||
const sourceBlocks = getBlocksFromDocumentContent(source.content);
|
||||
const removedRes = removeBlockSubtree(sourceBlocks, blockId);
|
||||
if (!removedRes.removed) return NextResponse.json({ error: "源块不存在或无权限" }, { status: 404 });
|
||||
|
||||
const target = await client.query(api.documents.getContent, { id: targetDocumentId });
|
||||
if (!target) return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||
const targetBlocks = getBlocksFromDocumentContent(target.content);
|
||||
|
||||
const nextSourcePayload = withBlocksWrittenBack(source.content, removedRes.nextBlocks);
|
||||
const nextTargetPayload = withBlocksWrittenBack(target.content, [...targetBlocks, removedRes.removed]);
|
||||
|
||||
await client.mutation(api.documents.updateContent, { id: sourceDocumentId, content: nextSourcePayload });
|
||||
await client.mutation(api.documents.updateContent, { id: targetDocumentId, content: nextTargetPayload });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
try {
|
||||
const result = await executeBlockMoveCommand({
|
||||
request,
|
||||
sourceDocumentId,
|
||||
blockId,
|
||||
targetDocumentId,
|
||||
});
|
||||
const payload =
|
||||
result.result && typeof result.result === "object"
|
||||
? (result.result as Record<string, unknown>)
|
||||
: {};
|
||||
return NextResponse.json({
|
||||
...payload,
|
||||
ok: typeof payload.ok === "boolean" ? payload.ok : true,
|
||||
meta: {
|
||||
requestId: result.requestId,
|
||||
traceId: result.traceId,
|
||||
commandId: result.commandId,
|
||||
commandName: result.commandName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
@@ -98,3 +104,5 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ ok: true });
|
||||
*/
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getBlocksFromDocumentContent, replaceBlockInTree, withBlocksWrittenBack } from "@/lib/blocks";
|
||||
import {
|
||||
assertBlockId,
|
||||
assertDocumentId,
|
||||
assertNextBlock,
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentCommandEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
|
||||
executeBlockPatchCommand,
|
||||
} from "@/lib/blocks/block-command-adapter";
|
||||
|
||||
type PatchPayload = {
|
||||
sourceDocumentId: string;
|
||||
@@ -24,78 +16,20 @@ export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
try {
|
||||
const { sourceDocumentId, workspaceId, blockId, nextBlock }: PatchPayload = await request.json();
|
||||
const normalizedDocumentId = assertDocumentId(sourceDocumentId);
|
||||
const normalizedWorkspaceId = workspaceId?.trim() || null;
|
||||
const normalizedBlockId = assertBlockId(blockId);
|
||||
assertNextBlock(nextBlock);
|
||||
|
||||
const bridgeContext = await buildDocumentBridgeContext({
|
||||
const result = await executeBlockPatchCommand({
|
||||
request,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
});
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "blocks.patch",
|
||||
payload: {
|
||||
documentId: normalizedDocumentId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
blockId: normalizedBlockId,
|
||||
nextBlock,
|
||||
},
|
||||
context: bridgeContext,
|
||||
target: {
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
pageId: normalizedDocumentId,
|
||||
blockId: normalizedBlockId,
|
||||
},
|
||||
});
|
||||
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const doc = await client.query(api.documents.getContent, { id: normalizedDocumentId });
|
||||
if (!doc) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "页面不存在或无权限",
|
||||
meta: {
|
||||
requestId: bridgeContext.requestId,
|
||||
traceId: bridgeContext.traceId,
|
||||
commandId: envelope.commandId,
|
||||
commandName: envelope.name,
|
||||
},
|
||||
},
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const blocks = getBlocksFromDocumentContent(doc.content);
|
||||
const replaced = replaceBlockInTree(blocks, normalizedBlockId, nextBlock as any);
|
||||
if (!replaced.ok) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "块不存在或无权限",
|
||||
meta: {
|
||||
requestId: bridgeContext.requestId,
|
||||
traceId: bridgeContext.traceId,
|
||||
commandId: envelope.commandId,
|
||||
commandName: envelope.name,
|
||||
},
|
||||
},
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const payload = withBlocksWrittenBack(doc.content, replaced.nextBlocks);
|
||||
await client.mutation(api.documents.updateContent, { id: normalizedDocumentId, content: payload });
|
||||
await recordBridgeCommandArtifacts({
|
||||
context: bridgeContext,
|
||||
envelope,
|
||||
sourceDocumentId,
|
||||
workspaceId,
|
||||
blockId,
|
||||
nextBlock,
|
||||
});
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
meta: {
|
||||
requestId: bridgeContext.requestId,
|
||||
traceId: bridgeContext.traceId,
|
||||
commandId: envelope.commandId,
|
||||
commandName: envelope.name,
|
||||
requestId: result.requestId,
|
||||
traceId: result.traceId,
|
||||
commandId: result.commandId,
|
||||
commandName: result.commandName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -135,3 +69,5 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ ok: true });
|
||||
*/
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -1,26 +1,89 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { documentBridgeErrorResponse } from "@/lib/documents/bridge";
|
||||
import {
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentQueryEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
executeRustBridgeQueryTransport,
|
||||
resolveRustBridgeQueryPlan,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
|
||||
const bridgeLogsApi = api as any;
|
||||
function sortByNewest<T extends Record<string, unknown>>(rows: T[]) {
|
||||
return [...rows].sort((left, right) => {
|
||||
const leftTime =
|
||||
(typeof left.created_at === "string" && left.created_at) ||
|
||||
(typeof left.finished_at === "string" && left.finished_at) ||
|
||||
"";
|
||||
const rightTime =
|
||||
(typeof right.created_at === "string" && right.created_at) ||
|
||||
(typeof right.finished_at === "string" && right.finished_at) ||
|
||||
"";
|
||||
return rightTime.localeCompare(leftTime);
|
||||
});
|
||||
}
|
||||
|
||||
function filterByCommandId<T extends Record<string, unknown>>(rows: T[], commandId: string | null) {
|
||||
if (!commandId) return rows;
|
||||
return rows.filter((row) => row.command_id === commandId);
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const workspaceId = url.searchParams.get("workspaceId")?.trim() ?? "";
|
||||
const requestId = url.searchParams.get("requestId")?.trim() ?? "";
|
||||
const commandId = url.searchParams.get("commandId")?.trim() ?? "";
|
||||
if (!workspaceId || !requestId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId 或 requestId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const result = await client.query(bridgeLogsApi.bridgeLogs.listByRequest, {
|
||||
const context = await buildDocumentBridgeContext({
|
||||
request,
|
||||
workspaceId,
|
||||
requestId,
|
||||
});
|
||||
const envelope = buildDocumentQueryEnvelope({
|
||||
name: "bridge.request.get",
|
||||
payload: {
|
||||
workspaceId,
|
||||
requestId,
|
||||
commandId: commandId || null,
|
||||
},
|
||||
});
|
||||
const plan = await resolveRustBridgeQueryPlan({
|
||||
context,
|
||||
envelope,
|
||||
});
|
||||
const result = await executeRustBridgeQueryTransport<{
|
||||
request_id: string;
|
||||
command_logs: Array<Record<string, unknown>>;
|
||||
domain_events: Array<Record<string, unknown>>;
|
||||
generated_at: string;
|
||||
}>({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
const commandLogs = sortByNewest(filterByCommandId(result.command_logs ?? [], commandId || null));
|
||||
const domainEvents = sortByNewest(filterByCommandId(result.domain_events ?? [], commandId || null));
|
||||
|
||||
return NextResponse.json(result);
|
||||
return NextResponse.json({
|
||||
request_id: result.request_id,
|
||||
command_logs: commandLogs,
|
||||
domain_events: domainEvents,
|
||||
generated_at: result.generated_at,
|
||||
counts: {
|
||||
command_logs: commandLogs.length,
|
||||
domain_events: domainEvents.length,
|
||||
},
|
||||
meta: {
|
||||
requestId: context.requestId,
|
||||
traceId: context.traceId,
|
||||
queryName: envelope.name,
|
||||
commandId: commandId || null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,89 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { documentBridgeErrorResponse } from "@/lib/documents/bridge";
|
||||
import {
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentQueryEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
executeRustBridgeQueryTransport,
|
||||
resolveRustBridgeQueryPlan,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
|
||||
const bridgeLogsApi = api as any;
|
||||
function sortByNewest<T extends Record<string, unknown>>(rows: T[]) {
|
||||
return [...rows].sort((left, right) => {
|
||||
const leftTime =
|
||||
(typeof left.created_at === "string" && left.created_at) ||
|
||||
(typeof left.finished_at === "string" && left.finished_at) ||
|
||||
"";
|
||||
const rightTime =
|
||||
(typeof right.created_at === "string" && right.created_at) ||
|
||||
(typeof right.finished_at === "string" && right.finished_at) ||
|
||||
"";
|
||||
return rightTime.localeCompare(leftTime);
|
||||
});
|
||||
}
|
||||
|
||||
function filterByCommandId<T extends Record<string, unknown>>(rows: T[], commandId: string | null) {
|
||||
if (!commandId) return rows;
|
||||
return rows.filter((row) => row.command_id === commandId);
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const workspaceId = url.searchParams.get("workspaceId")?.trim() ?? "";
|
||||
const traceId = url.searchParams.get("traceId")?.trim() ?? "";
|
||||
const commandId = url.searchParams.get("commandId")?.trim() ?? "";
|
||||
if (!workspaceId || !traceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId 或 traceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const result = await client.query(bridgeLogsApi.bridgeLogs.listByTrace, {
|
||||
const context = await buildDocumentBridgeContext({
|
||||
request,
|
||||
workspaceId,
|
||||
traceId,
|
||||
});
|
||||
const envelope = buildDocumentQueryEnvelope({
|
||||
name: "bridge.trace.get",
|
||||
payload: {
|
||||
workspaceId,
|
||||
traceId,
|
||||
commandId: commandId || null,
|
||||
},
|
||||
});
|
||||
const plan = await resolveRustBridgeQueryPlan({
|
||||
context,
|
||||
envelope,
|
||||
});
|
||||
const result = await executeRustBridgeQueryTransport<{
|
||||
trace_id: string;
|
||||
command_logs: Array<Record<string, unknown>>;
|
||||
domain_events: Array<Record<string, unknown>>;
|
||||
generated_at: string;
|
||||
}>({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
const commandLogs = sortByNewest(filterByCommandId(result.command_logs ?? [], commandId || null));
|
||||
const domainEvents = sortByNewest(filterByCommandId(result.domain_events ?? [], commandId || null));
|
||||
|
||||
return NextResponse.json(result);
|
||||
return NextResponse.json({
|
||||
trace_id: result.trace_id,
|
||||
command_logs: commandLogs,
|
||||
domain_events: domainEvents,
|
||||
generated_at: result.generated_at,
|
||||
counts: {
|
||||
command_logs: commandLogs.length,
|
||||
domain_events: domainEvents.length,
|
||||
},
|
||||
meta: {
|
||||
requestId: context.requestId,
|
||||
traceId: context.traceId,
|
||||
queryName: envelope.name,
|
||||
commandId: commandId || null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import {
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentQueryEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
executeRustBridgeQueryTransport,
|
||||
resolveRustBridgeQueryPlan,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const workspaceId = url.searchParams.get("workspaceId")?.trim() ?? "";
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const limit = Number(url.searchParams.get("limit") ?? "50");
|
||||
const cursor = url.searchParams.get("cursor")?.trim() ?? "";
|
||||
const commandStatus = url.searchParams.get("commandStatus")?.trim() ?? "";
|
||||
const eventStatus = url.searchParams.get("eventStatus")?.trim() ?? "";
|
||||
const targetPageId = url.searchParams.get("targetPageId")?.trim() ?? "";
|
||||
const targetBlockId = url.searchParams.get("targetBlockId")?.trim() ?? "";
|
||||
const aggregateType = url.searchParams.get("aggregateType")?.trim() ?? "";
|
||||
const aggregateId = url.searchParams.get("aggregateId")?.trim() ?? "";
|
||||
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const context = await buildDocumentBridgeContext({
|
||||
request,
|
||||
workspaceId,
|
||||
});
|
||||
const envelope = buildDocumentQueryEnvelope({
|
||||
name: "bridge.workspace.overview",
|
||||
payload: {
|
||||
workspaceId,
|
||||
limit: Number.isFinite(limit) ? limit : 50,
|
||||
cursor: cursor || null,
|
||||
commandStatus: commandStatus || null,
|
||||
eventStatus: eventStatus || null,
|
||||
targetPageId: targetPageId || null,
|
||||
targetBlockId: targetBlockId || null,
|
||||
aggregateType: aggregateType || null,
|
||||
aggregateId: aggregateId || null,
|
||||
},
|
||||
});
|
||||
const plan = await resolveRustBridgeQueryPlan({
|
||||
context,
|
||||
envelope,
|
||||
});
|
||||
const result = await executeRustBridgeQueryTransport<{
|
||||
workspace_id: string;
|
||||
command_logs: Array<Record<string, unknown>>;
|
||||
domain_events: Array<Record<string, unknown>>;
|
||||
counts: {
|
||||
command_logs: number;
|
||||
domain_events: number;
|
||||
};
|
||||
filters: Record<string, unknown> | null;
|
||||
next_cursor: string | null;
|
||||
has_more: boolean;
|
||||
generated_at: string;
|
||||
}>({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
workspace_id: result.workspace_id,
|
||||
command_logs: result.command_logs ?? [],
|
||||
domain_events: result.domain_events ?? [],
|
||||
counts: result.counts ?? {
|
||||
command_logs: Array.isArray(result.command_logs) ? result.command_logs.length : 0,
|
||||
domain_events: Array.isArray(result.domain_events) ? result.domain_events.length : 0,
|
||||
},
|
||||
filters: result.filters ?? null,
|
||||
next_cursor: result.next_cursor ?? null,
|
||||
has_more: Boolean(result.has_more),
|
||||
generated_at: result.generated_at,
|
||||
meta: {
|
||||
requestId: context.requestId,
|
||||
traceId: context.traceId,
|
||||
queryName: envelope.name,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import {
|
||||
assertDocumentId,
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentQueryEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
executeRustBridgeQueryTransport,
|
||||
resolveRustBridgeQueryPlan,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
@@ -23,9 +27,17 @@ export async function GET(request: Request) {
|
||||
name: "documents.content.get",
|
||||
payload: { documentId, workspaceId },
|
||||
});
|
||||
|
||||
const result = await client.query(api.documents.getContent, {
|
||||
id: documentId,
|
||||
const plan = await resolveRustBridgeQueryPlan({
|
||||
context: bridgeContext,
|
||||
envelope,
|
||||
});
|
||||
const result = await executeRustBridgeQueryTransport<{
|
||||
content?: unknown;
|
||||
revision?: number | null;
|
||||
conflict_detection_key?: string | null;
|
||||
} | null>({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
import type { Json } from "@/types/supabase";
|
||||
import { makeUniqueTitle } from "@/lib/file-tree/naming";
|
||||
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
|
||||
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";
|
||||
|
||||
@@ -20,525 +29,98 @@ type CopyTreePayload = {
|
||||
targetParentId: string | null;
|
||||
};
|
||||
|
||||
type DocRow = {
|
||||
id: string;
|
||||
title: string | null;
|
||||
parent_id: string | null;
|
||||
workspace_id: string;
|
||||
access_scope: "private" | "shared" | "public" | null;
|
||||
sort_order: number | null;
|
||||
created_at: string | null;
|
||||
content: Json | null;
|
||||
};
|
||||
|
||||
type AssetRow = {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
document_id: string;
|
||||
asset_type: string | null;
|
||||
file_name: string | null;
|
||||
file_size: number | null;
|
||||
mime_type: string | null;
|
||||
file_url: string | null;
|
||||
thumbnail_url: string | null;
|
||||
bucket: string | null;
|
||||
storage_path: string | null;
|
||||
};
|
||||
|
||||
const documentsBaseDir = getDocumentsBaseDir();
|
||||
const DEFAULT_DOC_BUCKET = process.env.NEXT_PUBLIC_SUPABASE_DOC_BUCKET ?? "documents";
|
||||
|
||||
async function ensureDocumentScaffold(id: string, title: string | null) {
|
||||
const folder = path.join(documentsBaseDir, id);
|
||||
const indexFile = path.join(folder, "index.md");
|
||||
await fs.mkdir(folder, { recursive: true });
|
||||
try {
|
||||
await fs.access(indexFile);
|
||||
} catch {
|
||||
const safeTitle = title && title.trim() ? title.trim() : "无标题";
|
||||
const content = `# ${safeTitle}\n`;
|
||||
await fs.writeFile(indexFile, content, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
async function copyMindmapIfExists(sourceId: string, targetId: string) {
|
||||
const srcDir = path.join(documentsBaseDir, sourceId);
|
||||
const destDir = path.join(documentsBaseDir, targetId);
|
||||
try {
|
||||
const entries = await fs.readdir(srcDir);
|
||||
const mindmapFiles = entries.filter((name) => name === "mindmap.json" || /^mindmap-.+\\.json$/i.test(name));
|
||||
if (mindmapFiles.length === 0) return;
|
||||
await fs.mkdir(destDir, { recursive: true });
|
||||
await Promise.all(
|
||||
mindmapFiles.map(async (name) => {
|
||||
try {
|
||||
const buf = await fs.readFile(path.join(srcDir, name));
|
||||
await fs.writeFile(path.join(destDir, name), buf);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// 源不存在则忽略
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTitle(title: string | null): string {
|
||||
const safe = title?.trim();
|
||||
return safe && safe.length > 0 ? safe : "无标题";
|
||||
}
|
||||
|
||||
function parseStoragePath(fileUrl: string): { bucket: string; path: string } | null {
|
||||
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 p = segments.slice(objectIdx + 3).join("/");
|
||||
return p ? { bucket, path: p } : null;
|
||||
}
|
||||
if (segments[objectIdx + 1] === "sign") {
|
||||
const bucket = segments[objectIdx + 2];
|
||||
const p = segments.slice(objectIdx + 3).join("/");
|
||||
return p ? { bucket, path: p } : null;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getChildrenSorted(childrenByParent: Map<string | null, DocRow[]>, parentId: string | null): DocRow[] {
|
||||
const list = childrenByParent.get(parentId) ?? [];
|
||||
return [...list].sort((a, b) => {
|
||||
const orderA = a.sort_order ?? Number.MAX_SAFE_INTEGER;
|
||||
const orderB = b.sort_order ?? Number.MAX_SAFE_INTEGER;
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
const timeA = new Date(a.created_at ?? 0).getTime();
|
||||
const timeB = new Date(b.created_at ?? 0).getTime();
|
||||
return timeA - timeB;
|
||||
});
|
||||
}
|
||||
|
||||
function replaceAssetRefsInContent(content: Json | null, assetMap: Map<string, { id: string; signedUrl: string }>, newDocId: string): Json | null {
|
||||
if (!content) return content;
|
||||
|
||||
const transform = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(transform);
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
|
||||
const obj = value as Record<string, unknown>;
|
||||
const next: Record<string, unknown> = {};
|
||||
Object.keys(obj).forEach((key) => {
|
||||
next[key] = transform(obj[key]);
|
||||
});
|
||||
|
||||
if (next.type === "media" && next.props && typeof next.props === "object") {
|
||||
const props = next.props as Record<string, unknown>;
|
||||
const oldId = typeof props.assetId === "string" ? props.assetId : null;
|
||||
if (oldId && assetMap.has(oldId)) {
|
||||
const mapped = assetMap.get(oldId)!;
|
||||
props.assetId = mapped.id;
|
||||
props.fileUrl = mapped.signedUrl;
|
||||
props.thumbnailUrl = mapped.signedUrl;
|
||||
props.documentId = newDocId;
|
||||
next.props = props;
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
return transform(content) as Json;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { client } = await getAuthedConvexClient();
|
||||
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),
|
||||
}));
|
||||
|
||||
const payload = (await request.json()) as CopyTreePayload;
|
||||
if (!payload?.items?.length) {
|
||||
return NextResponse.json({ error: "缺少 items" }, { status: 400 });
|
||||
}
|
||||
|
||||
const normalizedItems = payload.items.filter((it) => it?.documentId);
|
||||
if (normalizedItems.length === 0) {
|
||||
return NextResponse.json({ error: "items 为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
const targetParentId = payload.targetParentId ?? null;
|
||||
let workspaceId: string | null = null;
|
||||
|
||||
if (targetParentId) {
|
||||
const targetDoc = await client.query(api.documents.getMeta, { id: targetParentId });
|
||||
if (!targetDoc) {
|
||||
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||
if (normalizedItems.length === 0) {
|
||||
return NextResponse.json({ error: "items 为空" }, { status: 400 });
|
||||
}
|
||||
workspaceId = targetDoc.workspace_id;
|
||||
}
|
||||
|
||||
const sourceIds = Array.from(new Set(normalizedItems.map((it) => it.documentId)));
|
||||
const firstMeta = await client.query(api.documents.getMeta, { id: sourceIds[0] });
|
||||
if (!firstMeta) {
|
||||
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
const normalizedTargetParentId = payload.targetParentId?.trim() || null;
|
||||
let workspaceId: string | null = null;
|
||||
|
||||
if (!workspaceId) {
|
||||
workspaceId = firstMeta.workspace_id;
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||
}
|
||||
|
||||
const wid = workspaceId;
|
||||
|
||||
const allDocs = await client.query(api.documents.listAllForCopy, {
|
||||
workspaceId: wid,
|
||||
});
|
||||
|
||||
const sourceById = new Map<string, DocRow>();
|
||||
(allDocs as unknown as DocRow[]).forEach((d) => sourceById.set(d.id, d));
|
||||
|
||||
const missing = sourceIds.find((id) => !sourceById.has(id));
|
||||
if (missing) {
|
||||
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
|
||||
const childrenByParent = new Map<string | null, DocRow[]>();
|
||||
(allDocs as unknown as DocRow[]).forEach((doc) => {
|
||||
const list = childrenByParent.get(doc.parent_id) ?? [];
|
||||
list.push(doc);
|
||||
childrenByParent.set(doc.parent_id, list);
|
||||
});
|
||||
|
||||
const existingTitleSetByParent = new Map<string | null, Set<string>>();
|
||||
const seedTitleSet = (parent: string | null) => {
|
||||
if (existingTitleSetByParent.has(parent)) return;
|
||||
const titles = new Set<string>();
|
||||
(childrenByParent.get(parent) ?? []).forEach((d) => titles.add(normalizeTitle(d.title)));
|
||||
existingTitleSetByParent.set(parent, titles);
|
||||
};
|
||||
seedTitleSet(targetParentId);
|
||||
|
||||
const newIdByOldId = new Map<string, string>();
|
||||
const copyQueue: Array<{ old: DocRow; newParentId: string | null; parentKey: string | null }> = [];
|
||||
|
||||
const enqueueTree = (root: DocRow, newParent: string | null, recursive: boolean) => {
|
||||
const visit = (node: DocRow, parentNewId: string | null, parentKey: string | null) => {
|
||||
const newId =
|
||||
typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
newIdByOldId.set(node.id, newId);
|
||||
copyQueue.push({ old: node, newParentId: parentNewId, parentKey });
|
||||
if (!recursive) return;
|
||||
const children = getChildrenSorted(childrenByParent, node.id);
|
||||
children.forEach((child) => visit(child, newId, newId));
|
||||
};
|
||||
visit(root, newParent, newParent);
|
||||
};
|
||||
|
||||
normalizedItems.forEach((item) => {
|
||||
const doc = sourceById.get(item.documentId) ?? null;
|
||||
if (doc) {
|
||||
enqueueTree(doc, targetParentId, Boolean(item.recursive));
|
||||
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 (copyQueue.length === 0) {
|
||||
return NextResponse.json({ error: "没有可复制的页面" }, { status: 400 });
|
||||
}
|
||||
|
||||
const insertedDocs: Array<{ oldId: string; newId: string }> = [];
|
||||
|
||||
for (const item of copyQueue) {
|
||||
const newId = newIdByOldId.get(item.old.id)!;
|
||||
const parentId = item.newParentId;
|
||||
|
||||
if (!existingTitleSetByParent.has(parentId)) {
|
||||
seedTitleSet(parentId);
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||
}
|
||||
const titleSet = existingTitleSetByParent.get(parentId) ?? new Set<string>();
|
||||
existingTitleSetByParent.set(parentId, titleSet);
|
||||
|
||||
const newTitle = makeUniqueTitle(normalizeTitle(item.old.title), titleSet);
|
||||
titleSet.add(newTitle);
|
||||
|
||||
await client.mutation(api.documents.create, {
|
||||
id: newId,
|
||||
workspaceId: wid,
|
||||
parentId,
|
||||
accessScope: (item.old.access_scope ?? "private") as "private" | "shared" | "public",
|
||||
title: newTitle,
|
||||
content: item.old.content ?? [],
|
||||
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 ensureDocumentScaffold(newId, newTitle);
|
||||
await client.mutation(api.mindmaps.copyByDocument, {
|
||||
sourceDocId: item.old.id,
|
||||
targetDocId: newId,
|
||||
});
|
||||
insertedDocs.push({ oldId: item.old.id, newId });
|
||||
}
|
||||
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: insertedDocs.map((d) => ({ oldId: d.oldId, newId: d.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);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = (await request.json()) as CopyTreePayload;
|
||||
if (!payload?.items?.length) {
|
||||
return NextResponse.json({ error: "缺少 items" }, { status: 400 });
|
||||
}
|
||||
|
||||
const normalizedItems = payload.items.filter((it) => it?.documentId);
|
||||
if (normalizedItems.length === 0) {
|
||||
return NextResponse.json({ error: "items 为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
const targetParentId = payload.targetParentId ?? null;
|
||||
let workspaceId: string | null = null;
|
||||
let targetAccessScope: "private" | "shared" | "public" = "private";
|
||||
|
||||
if (targetParentId) {
|
||||
const { data: targetDoc, error } = await supabase
|
||||
.from("documents")
|
||||
.select("workspace_id,access_scope")
|
||||
.eq("id", targetParentId)
|
||||
.eq("user_id", session.user.id)
|
||||
.single();
|
||||
if (error || !targetDoc) {
|
||||
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
workspaceId = targetDoc.workspace_id;
|
||||
targetAccessScope = (targetDoc.access_scope ?? "private") as typeof targetAccessScope;
|
||||
}
|
||||
|
||||
const sourceIds = Array.from(new Set(normalizedItems.map((it) => it.documentId)));
|
||||
const { data: sourceDocs, error: sourceErr } = await supabase
|
||||
.from("documents")
|
||||
.select("id,title,parent_id,workspace_id,access_scope,sort_order,created_at,content")
|
||||
.in("id", sourceIds)
|
||||
.eq("user_id", session.user.id);
|
||||
if (sourceErr || !sourceDocs?.length) {
|
||||
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
|
||||
const sourceById = new Map<string, DocRow>();
|
||||
(sourceDocs as DocRow[]).forEach((d) => sourceById.set(d.id, d));
|
||||
|
||||
if (!workspaceId) {
|
||||
workspaceId = sourceDocs[0].workspace_id;
|
||||
}
|
||||
|
||||
const { data: allDocs, error: allErr } = await supabase
|
||||
.from("documents")
|
||||
.select("id,title,parent_id,workspace_id,access_scope,sort_order,created_at,content")
|
||||
.eq("workspace_id", workspaceId)
|
||||
.eq("user_id", session.user.id)
|
||||
.order("sort_order", { ascending: true, nullsFirst: false })
|
||||
.order("created_at", { ascending: true });
|
||||
if (allErr) {
|
||||
return NextResponse.json({ error: allErr.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const childrenByParent = new Map<string | null, DocRow[]>();
|
||||
(allDocs as DocRow[]).forEach((doc) => {
|
||||
const list = childrenByParent.get(doc.parent_id) ?? [];
|
||||
list.push(doc);
|
||||
childrenByParent.set(doc.parent_id, list);
|
||||
});
|
||||
|
||||
const existingTitleSetByParent = new Map<string | null, Set<string>>();
|
||||
const seedTitleSet = (parent: string | null) => {
|
||||
if (existingTitleSetByParent.has(parent)) return;
|
||||
const titles = new Set<string>();
|
||||
(childrenByParent.get(parent) ?? []).forEach((d) => titles.add(normalizeTitle(d.title)));
|
||||
existingTitleSetByParent.set(parent, titles);
|
||||
};
|
||||
seedTitleSet(targetParentId);
|
||||
|
||||
const newIdByOldId = new Map<string, string>();
|
||||
const copyQueue: Array<{ old: DocRow; newParentId: string | null; parentKey: string | null }> = [];
|
||||
|
||||
const enqueueTree = (root: DocRow, newParent: string | null, recursive: boolean) => {
|
||||
const visit = (node: DocRow, parentNewId: string | null, parentKey: string | null) => {
|
||||
const newId = typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
newIdByOldId.set(node.id, newId);
|
||||
copyQueue.push({ old: node, newParentId: parentNewId, parentKey });
|
||||
if (!recursive) return;
|
||||
const children = getChildrenSorted(childrenByParent, node.id);
|
||||
children.forEach((child) => visit(child, newId, newId));
|
||||
};
|
||||
visit(root, newParent, newParent);
|
||||
};
|
||||
|
||||
normalizedItems.forEach((item) => {
|
||||
const doc = sourceById.get(item.documentId) ?? (allDocs as DocRow[]).find((d) => d.id === item.documentId) ?? null;
|
||||
if (doc) {
|
||||
enqueueTree(doc, targetParentId, Boolean(item.recursive));
|
||||
}
|
||||
});
|
||||
|
||||
if (copyQueue.length === 0) {
|
||||
return NextResponse.json({ error: "没有可复制的页面" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 计算根级 sort_order 起点
|
||||
const siblingQuery = supabase
|
||||
.from("documents")
|
||||
.select("id", { head: true, count: "exact" })
|
||||
.eq("workspace_id", workspaceId);
|
||||
if (targetParentId) {
|
||||
siblingQuery.eq("parent_id", targetParentId);
|
||||
} else {
|
||||
siblingQuery.is("parent_id", null);
|
||||
}
|
||||
const { count: rawSiblingCount } = await siblingQuery;
|
||||
const siblingCount = rawSiblingCount ?? 0;
|
||||
const nextSortByParent = new Map<string | null, number>([[targetParentId, siblingCount]]);
|
||||
|
||||
const insertedDocs: Array<{ oldId: string; newId: string }> = [];
|
||||
|
||||
for (const item of copyQueue) {
|
||||
const newId = newIdByOldId.get(item.old.id)!;
|
||||
const parentId = item.newParentId;
|
||||
|
||||
if (!existingTitleSetByParent.has(parentId)) {
|
||||
if (newIdByOldId.has(parentId ?? "")) {
|
||||
existingTitleSetByParent.set(parentId, new Set());
|
||||
} else {
|
||||
seedTitleSet(parentId);
|
||||
}
|
||||
}
|
||||
const titleSet = existingTitleSetByParent.get(parentId) ?? new Set<string>();
|
||||
existingTitleSetByParent.set(parentId, titleSet);
|
||||
|
||||
const newTitle = makeUniqueTitle(normalizeTitle(item.old.title), titleSet);
|
||||
|
||||
const currentSort = nextSortByParent.get(parentId) ?? 0;
|
||||
nextSortByParent.set(parentId, currentSort + 1);
|
||||
|
||||
const { error: insertError } = await supabase.from("documents").insert({
|
||||
id: newId,
|
||||
user_id: session.user.id,
|
||||
workspace_id: workspaceId,
|
||||
parent_id: parentId,
|
||||
access_scope: (item.old.access_scope ?? targetAccessScope) as typeof targetAccessScope,
|
||||
title: newTitle,
|
||||
content: item.old.content ?? { blocks: [] },
|
||||
sort_order: currentSort,
|
||||
});
|
||||
|
||||
if (insertError) {
|
||||
return NextResponse.json({ error: insertError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
await ensureDocumentScaffold(newId, newTitle);
|
||||
await copyMindmapIfExists(item.old.id, newId);
|
||||
insertedDocs.push({ oldId: item.old.id, newId });
|
||||
}
|
||||
|
||||
// 复制附件并回写 content 中的 assetId
|
||||
const oldDocIds = insertedDocs.map((d) => d.oldId);
|
||||
const { data: assets, error: assetErr } = await supabase
|
||||
.from("media_assets")
|
||||
.select("id,workspace_id,document_id,asset_type,file_name,file_size,mime_type,file_url,thumbnail_url,bucket,storage_path")
|
||||
.in("document_id", oldDocIds)
|
||||
.eq("workspace_id", workspaceId)
|
||||
.is("deleted_at", null);
|
||||
|
||||
if (assetErr) {
|
||||
return NextResponse.json({ error: assetErr.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const assetMapByOldDoc = new Map<string, Map<string, { id: string; signedUrl: string }>>();
|
||||
for (const asset of (assets as AssetRow[] | null) ?? []) {
|
||||
const mappedDocId = newIdByOldId.get(asset.document_id);
|
||||
if (!mappedDocId) continue;
|
||||
|
||||
const sourceLocation =
|
||||
asset.storage_path
|
||||
? { bucket: asset.bucket ?? DEFAULT_DOC_BUCKET, path: asset.storage_path }
|
||||
: asset.file_url
|
||||
? parseStoragePath(asset.file_url)
|
||||
: null;
|
||||
if (!sourceLocation) continue;
|
||||
|
||||
const fileName = (asset.file_name ?? "附件").replace(/[\\/]/g, "_");
|
||||
const newAssetId =
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
const targetPath = `${workspaceId}/${mappedDocId}/${newAssetId}-${fileName}`;
|
||||
const bucket = sourceLocation.bucket || DEFAULT_DOC_BUCKET;
|
||||
|
||||
const map = assetMapByOldDoc.get(asset.document_id) ?? new Map<string, { id: string; signedUrl: string }>();
|
||||
assetMapByOldDoc.set(asset.document_id, map);
|
||||
|
||||
const copyRes = await supabase.storage.from(bucket).copy(sourceLocation.path, targetPath);
|
||||
if (copyRes.error) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { data: signed } = await supabase.storage.from(bucket).createSignedUrl(targetPath, 60 * 60 * 24 * 7);
|
||||
const signedUrl = signed?.signedUrl ?? "";
|
||||
|
||||
const { error: insertAssetErr } = await supabase.from("media_assets").insert({
|
||||
id: newAssetId,
|
||||
workspace_id: workspaceId,
|
||||
document_id: mappedDocId,
|
||||
asset_type: asset.asset_type ?? "file",
|
||||
file_name: asset.file_name ?? fileName,
|
||||
file_size: asset.file_size ?? null,
|
||||
mime_type: asset.mime_type ?? null,
|
||||
bucket,
|
||||
storage_path: targetPath,
|
||||
file_url: signedUrl,
|
||||
thumbnail_url: signedUrl,
|
||||
created_by: session.user.id,
|
||||
});
|
||||
if (!insertAssetErr) {
|
||||
map.set(asset.id, { id: newAssetId, signedUrl });
|
||||
}
|
||||
}
|
||||
|
||||
for (const pair of insertedDocs) {
|
||||
const map = assetMapByOldDoc.get(pair.oldId);
|
||||
if (!map || map.size === 0) continue;
|
||||
const source = (allDocs as DocRow[]).find((d) => d.id === pair.oldId);
|
||||
if (!source) continue;
|
||||
const newContent = replaceAssetRefsInContent(source.content, map, pair.newId);
|
||||
const { error: updateErr } = await supabase
|
||||
.from("documents")
|
||||
.update({ content: newContent })
|
||||
.eq("id", pair.newId)
|
||||
.eq("user_id", session.user.id);
|
||||
if (updateErr) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
items: insertedDocs.map((d) => ({ oldId: d.oldId, newId: d.newId })),
|
||||
});
|
||||
*/
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -1,160 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
function makeId(): string {
|
||||
return typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
type CreateChildPayload = {
|
||||
parentId: string | null;
|
||||
title?: string;
|
||||
blocks?: unknown;
|
||||
};
|
||||
import { executeDocumentCreateChildBridgeCommand } from "@/lib/documents/page-command-adapter";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
|
||||
const { parentId, title, blocks }: CreateChildPayload = await request.json();
|
||||
|
||||
if (typeof parentId === "undefined") {
|
||||
return NextResponse.json({ error: "缺少 parentId" }, { status: 400 });
|
||||
}
|
||||
|
||||
let workspaceId: string;
|
||||
let accessScope: "private" | "shared" | "public" = "private";
|
||||
|
||||
if (parentId) {
|
||||
const parentDoc = await client.query(api.documents.getMeta, { id: parentId });
|
||||
if (!parentDoc) {
|
||||
return NextResponse.json({ error: "父页面不存在" }, { status: 404 });
|
||||
}
|
||||
workspaceId = parentDoc.workspace_id;
|
||||
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
|
||||
} else {
|
||||
const ensured = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||
fallbackName: auth.name ?? auth.email ?? "我的空间",
|
||||
workspaceIdIfCreate: makeId(),
|
||||
});
|
||||
workspaceId = ensured.activeWorkspaceId;
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedTitle = title && title.trim().length > 0 ? title.trim() : "未命名页面";
|
||||
const contentPayload = Array.isArray(blocks) ? blocks : [];
|
||||
const pageId = makeId();
|
||||
|
||||
const created = await client.mutation(api.documents.create, {
|
||||
id: pageId,
|
||||
workspaceId,
|
||||
parentId: parentId ?? null,
|
||||
title: resolvedTitle,
|
||||
accessScope,
|
||||
content: contentPayload,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
pageId: created.id,
|
||||
title: created.title ?? resolvedTitle,
|
||||
});
|
||||
return executeDocumentCreateChildBridgeCommand(request);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录无法创建页面" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { parentId, title, blocks }: CreateChildPayload = await request.json();
|
||||
|
||||
if (typeof parentId === "undefined") {
|
||||
return NextResponse.json({ error: "缺少 parentId" }, { status: 400 });
|
||||
}
|
||||
|
||||
let workspaceId: string;
|
||||
let accessScope: "private" | "shared" | "public" = "private";
|
||||
|
||||
if (parentId) {
|
||||
const { data: parentDoc, error: parentError } = await supabase
|
||||
.from("documents")
|
||||
.select("workspace_id,access_scope")
|
||||
.eq("id", parentId)
|
||||
.single();
|
||||
|
||||
if (parentError || !parentDoc) {
|
||||
return NextResponse.json({ error: "父页面不存在" }, { status: 404 });
|
||||
}
|
||||
workspaceId = parentDoc.workspace_id;
|
||||
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
|
||||
} else {
|
||||
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间");
|
||||
workspaceId = await resolveActiveWorkspaceId(supabase, session.user.id);
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const siblingQuery = supabase
|
||||
.from("documents")
|
||||
.select("id", { head: true, count: "exact" })
|
||||
.eq("workspace_id", workspaceId);
|
||||
|
||||
if (parentId) {
|
||||
siblingQuery.eq("parent_id", parentId);
|
||||
} else {
|
||||
siblingQuery.is("parent_id", null);
|
||||
}
|
||||
|
||||
const { count: rawSiblingCount, error: countError } = await siblingQuery;
|
||||
const siblingCount = rawSiblingCount ?? 0;
|
||||
|
||||
if (countError) {
|
||||
return NextResponse.json({ error: countError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const resolvedTitle =
|
||||
title && title.trim().length > 0 ? title.trim() : "未命名页面";
|
||||
|
||||
const contentPayload = Array.isArray(blocks) ? blocks : [];
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("documents")
|
||||
.insert({
|
||||
user_id: session.user.id,
|
||||
parent_id: parentId,
|
||||
workspace_id: workspaceId,
|
||||
access_scope: accessScope,
|
||||
title: resolvedTitle,
|
||||
content: contentPayload,
|
||||
sort_order: siblingCount,
|
||||
})
|
||||
.select("id,title")
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
return NextResponse.json(
|
||||
{ error: error?.message ?? "创建子页面失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
pageId: data.id,
|
||||
title: data.title ?? resolvedTitle,
|
||||
});
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,45 +1,34 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { Json } from "@/types/supabase";
|
||||
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
|
||||
const documentsBaseDir = getDocumentsBaseDir();
|
||||
|
||||
async function ensureDocumentScaffold(id: string, title: string | null) {
|
||||
const folder = path.join(documentsBaseDir, id);
|
||||
const indexFile = path.join(folder, "index.md");
|
||||
await fs.mkdir(folder, { recursive: true });
|
||||
try {
|
||||
await fs.access(indexFile);
|
||||
} catch {
|
||||
const safeTitle = title && title.trim() ? title.trim() : "无标题";
|
||||
const content = `# ${safeTitle}\n`;
|
||||
await fs.writeFile(indexFile, content, "utf8");
|
||||
}
|
||||
}
|
||||
import {
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentCommandEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
executePageLifecycleBridgeCommand,
|
||||
type DocumentCreatePayload,
|
||||
} from "@/lib/documents/page-command-adapter";
|
||||
import { ensureDocumentScaffold } from "@/lib/documents/page-lifecycle-side-effects";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
if (isConvexEnabled()) {
|
||||
return await handleCreateRequestConvex(request);
|
||||
}
|
||||
return await handleCreateRequest(request);
|
||||
return await handleCreateRequest();
|
||||
} catch (error) {
|
||||
console.error("创建页面失败", error);
|
||||
const message = error instanceof Error ? error.message : "创建页面失败,请稍后再试";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : "创建页面失败,请稍后再试";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateRequestConvex(request: Request) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
|
||||
const { parentId }: { parentId?: string | null } = await request.json();
|
||||
|
||||
const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||
@@ -48,7 +37,6 @@ async function handleCreateRequestConvex(request: Request) {
|
||||
});
|
||||
|
||||
let workspaceId: string | null = null;
|
||||
let parentContent: Json | null = null;
|
||||
let accessScope: "private" | "shared" | "public" = "private";
|
||||
|
||||
if (parentId) {
|
||||
@@ -62,8 +50,6 @@ async function handleCreateRequestConvex(request: Request) {
|
||||
|
||||
workspaceId = parentDoc.workspace_id;
|
||||
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
|
||||
const parentContentRes = await client.query(api.documents.getContent, { id: parentId });
|
||||
parentContent = (parentContentRes?.content as Json | null) ?? null;
|
||||
} else {
|
||||
workspaceId = workspaceBootstrap.activeWorkspaceId || null;
|
||||
}
|
||||
@@ -72,156 +58,62 @@ async function handleCreateRequestConvex(request: Request) {
|
||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const data = await client.mutation(api.documents.create, {
|
||||
id,
|
||||
workspaceId,
|
||||
parentId: parentId ?? null,
|
||||
title: "无标题",
|
||||
accessScope,
|
||||
content: [],
|
||||
});
|
||||
|
||||
// 为文件树创建本地目录和 index.md
|
||||
if (data?.id) {
|
||||
await ensureDocumentScaffold(data.id, data.title ?? "无标题");
|
||||
}
|
||||
|
||||
if (parentId && data) {
|
||||
const existingBlocks = extractBlocksFromContent(parentContent);
|
||||
const pageReferenceBlock = {
|
||||
id: randomUUID(),
|
||||
type: "pageReference",
|
||||
props: {
|
||||
pageId: data.id,
|
||||
title: data.title ?? "无标题",
|
||||
},
|
||||
};
|
||||
const nextBlocks = [...existingBlocks, pageReferenceBlock as Json];
|
||||
const payload = composeContentWithBlocks(parentContent, nextBlocks);
|
||||
|
||||
await client.mutation(api.documents.updateContent, {
|
||||
id: parentId,
|
||||
content: payload,
|
||||
try {
|
||||
const id = randomUUID();
|
||||
const normalizedWorkspaceId = workspaceId.trim();
|
||||
const bridgeContext = await buildDocumentBridgeContext({
|
||||
request,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
});
|
||||
const result = await executePageLifecycleBridgeCommand<
|
||||
DocumentCreatePayload,
|
||||
{
|
||||
id: string;
|
||||
title?: string | null;
|
||||
parent_id?: string | null;
|
||||
sort_order?: number | null;
|
||||
workspace_id?: string;
|
||||
access_scope?: "private" | "shared" | "public";
|
||||
is_template?: boolean;
|
||||
}
|
||||
>({
|
||||
context: bridgeContext,
|
||||
envelope: buildDocumentCommandEnvelope({
|
||||
name: "documents.create",
|
||||
payload: {
|
||||
documentId: id,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
parentId: parentId?.trim() || null,
|
||||
title: "无标题",
|
||||
accessScope,
|
||||
content: [],
|
||||
},
|
||||
context: bridgeContext,
|
||||
target: {
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
pageId: id,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
await ensureDocumentScaffold(result.result.id, result.result.title ?? "无标题");
|
||||
|
||||
return NextResponse.json({
|
||||
...result.result,
|
||||
meta: {
|
||||
requestId: result.requestId,
|
||||
traceId: result.traceId,
|
||||
commandId: result.commandId,
|
||||
commandName: result.commandName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateRequest(request: Request) {
|
||||
async function handleCreateRequest() {
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { parentId }: { parentId?: string | null } = await request.json();
|
||||
|
||||
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间");
|
||||
|
||||
let workspaceId: string | null = null;
|
||||
let parentContent: Json | null = null;
|
||||
let accessScope: "private" | "shared" | "public" = "private";
|
||||
|
||||
if (parentId) {
|
||||
const { data: parentDoc, error: parentError } = await supabase
|
||||
.from("documents")
|
||||
.select("workspace_id,access_scope,content,user_id")
|
||||
.eq("id", parentId)
|
||||
.eq("user_id", session.user.id)
|
||||
.single();
|
||||
|
||||
if (parentError || !parentDoc) {
|
||||
return NextResponse.json({ error: "父页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
workspaceId = parentDoc.workspace_id;
|
||||
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
|
||||
parentContent = parentDoc.content;
|
||||
} else {
|
||||
workspaceId = await resolveActiveWorkspaceId(supabase, session.user.id);
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||
}
|
||||
|
||||
const siblingQuery = supabase
|
||||
.from("documents")
|
||||
.select("id", { head: true, count: "exact" })
|
||||
.eq("workspace_id", workspaceId);
|
||||
|
||||
if (parentId) {
|
||||
siblingQuery.eq("parent_id", parentId);
|
||||
} else {
|
||||
siblingQuery.is("parent_id", null);
|
||||
}
|
||||
|
||||
const { count: rawSiblingCount, error: countError } = await siblingQuery;
|
||||
const siblingCount = rawSiblingCount ?? 0;
|
||||
|
||||
if (countError) {
|
||||
return NextResponse.json({ error: countError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("documents")
|
||||
.insert({
|
||||
user_id: session.user.id,
|
||||
parent_id: parentId ?? null,
|
||||
workspace_id: workspaceId,
|
||||
title: "无标题",
|
||||
content: { blocks: [] },
|
||||
access_scope: accessScope,
|
||||
sort_order: siblingCount,
|
||||
})
|
||||
.select(
|
||||
"id,title,parent_id,sort_order,is_starred,created_at,updated_at,workspace_id,access_scope,is_template",
|
||||
)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
// 为文件树创建本地目录和 index.md
|
||||
if (data?.id) {
|
||||
await ensureDocumentScaffold(data.id, data.title ?? "无标题");
|
||||
}
|
||||
|
||||
if (parentId && data) {
|
||||
const existingBlocks = extractBlocksFromContent(parentContent);
|
||||
const pageReferenceBlock = {
|
||||
id: randomUUID(),
|
||||
type: "pageReference",
|
||||
props: {
|
||||
pageId: data.id,
|
||||
title: data.title ?? "无标题",
|
||||
},
|
||||
};
|
||||
const nextBlocks = [...existingBlocks, pageReferenceBlock as Json];
|
||||
const payload = composeContentWithBlocks(parentContent, nextBlocks);
|
||||
const timestamp = new Date().toISOString();
|
||||
const { error: parentUpdateError } = await supabase
|
||||
.from("documents")
|
||||
.update({ content: payload, updated_at: timestamp })
|
||||
.eq("id", parentId)
|
||||
.eq("user_id", session.user.id);
|
||||
|
||||
if (parentUpdateError) {
|
||||
return NextResponse.json({ error: parentUpdateError.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
*/
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -1,52 +1,59 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import {
|
||||
assertDocumentId,
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentCommandEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
executePageLifecycleBridgeCommand,
|
||||
type DocumentDeletePayload,
|
||||
} from "@/lib/documents/page-command-adapter";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { documentId } = await request.json();
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
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);
|
||||
}
|
||||
const { client } = await getAuthedConvexClient();
|
||||
await client.mutation(api.documents.softDelete, { id: documentId });
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { documentId } = await request.json();
|
||||
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.update({
|
||||
deleted_at: new Date().toISOString(),
|
||||
deleted_by: session.user.id,
|
||||
})
|
||||
.eq("id", documentId)
|
||||
.eq("user_id", session.user.id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
*/
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -2,165 +2,96 @@ import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
|
||||
import {
|
||||
assertDocumentId,
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentCommandEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
executePageLifecycleBridgeCommand,
|
||||
type DocumentDuplicatePayload,
|
||||
} from "@/lib/documents/page-command-adapter";
|
||||
import {
|
||||
copyMindmapFilesIfExists,
|
||||
ensureDocumentScaffold,
|
||||
} from "@/lib/documents/page-lifecycle-side-effects";
|
||||
|
||||
interface DuplicatePayload {
|
||||
documentId: string;
|
||||
}
|
||||
|
||||
const documentsBaseDir = getDocumentsBaseDir();
|
||||
|
||||
async function ensureDocumentScaffold(id: string, title: string | null) {
|
||||
const folder = path.join(documentsBaseDir, id);
|
||||
const indexFile = path.join(folder, "index.md");
|
||||
await fs.mkdir(folder, { recursive: true });
|
||||
const safeTitle = title && title.trim() ? title.trim() : "无标题";
|
||||
const content = `# ${safeTitle}\n`;
|
||||
await fs.writeFile(indexFile, content, "utf8");
|
||||
}
|
||||
|
||||
async function copyMindmapIfExists(sourceId: string, targetId: string) {
|
||||
try {
|
||||
const srcDir = path.join(documentsBaseDir, sourceId);
|
||||
const destDir = path.join(documentsBaseDir, targetId);
|
||||
const entries = await fs.readdir(srcDir);
|
||||
const mindmapFiles = entries.filter((name) => name === "mindmap.json" || /^mindmap-.+\\.json$/i.test(name));
|
||||
if (mindmapFiles.length === 0) return;
|
||||
await fs.mkdir(destDir, { recursive: true });
|
||||
await Promise.all(
|
||||
mindmapFiles.map(async (name) => {
|
||||
try {
|
||||
const buf = await fs.readFile(path.join(srcDir, name));
|
||||
await fs.writeFile(path.join(destDir, name), buf);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// 如果源不存在则忽略
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { client } = await getAuthedConvexClient();
|
||||
try {
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const { documentId }: DuplicatePayload = await request.json();
|
||||
const normalizedDocumentId = assertDocumentId(documentId);
|
||||
|
||||
const { documentId }: DuplicatePayload = await request.json();
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { id: normalizedDocumentId });
|
||||
if (!sourceDoc) {
|
||||
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
||||
}
|
||||
|
||||
const fallbackTitle =
|
||||
sourceDoc.title?.trim() && sourceDoc.title.trim().length > 0 ? sourceDoc.title.trim() : "无标题";
|
||||
const duplicatedTitle = `${fallbackTitle} 副本`;
|
||||
const newId =
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
const normalizedWorkspaceId = sourceDoc.workspace_id?.trim() || null;
|
||||
const bridgeContext = await buildDocumentBridgeContext({
|
||||
request,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
});
|
||||
const result = await executePageLifecycleBridgeCommand<
|
||||
DocumentDuplicatePayload,
|
||||
{
|
||||
id: string;
|
||||
title?: string | null;
|
||||
parent_id?: string | null;
|
||||
sort_order?: number | null;
|
||||
}
|
||||
>({
|
||||
context: bridgeContext,
|
||||
envelope: buildDocumentCommandEnvelope({
|
||||
name: "documents.duplicate",
|
||||
payload: {
|
||||
sourceDocumentId: normalizedDocumentId,
|
||||
newDocumentId: newId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
title: duplicatedTitle,
|
||||
},
|
||||
context: bridgeContext,
|
||||
target: {
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
pageId: newId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await ensureDocumentScaffold(result.result.id, result.result.title ?? duplicatedTitle);
|
||||
await copyMindmapFilesIfExists(normalizedDocumentId, result.result.id);
|
||||
|
||||
return NextResponse.json({
|
||||
id: result.result.id,
|
||||
title: result.result.title ?? duplicatedTitle,
|
||||
parent_id: result.result.parent_id ?? null,
|
||||
sort_order: result.result.sort_order ?? null,
|
||||
meta: {
|
||||
requestId: result.requestId,
|
||||
traceId: result.traceId,
|
||||
commandId: result.commandId,
|
||||
commandName: result.commandName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||
if (!sourceDoc) {
|
||||
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
||||
}
|
||||
|
||||
const fallbackTitle =
|
||||
sourceDoc.title?.trim() && sourceDoc.title.trim().length > 0 ? sourceDoc.title.trim() : "无标题";
|
||||
const duplicatedTitle = `${fallbackTitle} 副本`;
|
||||
|
||||
const newId = typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
const duplicated = await client.mutation(api.documents.duplicate, {
|
||||
sourceId: documentId,
|
||||
newId,
|
||||
title: duplicatedTitle,
|
||||
});
|
||||
|
||||
await ensureDocumentScaffold(duplicated.id, duplicated.title ?? duplicatedTitle);
|
||||
await client.mutation(api.mindmaps.copyByDocument, {
|
||||
sourceDocId: documentId,
|
||||
targetDocId: duplicated.id,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
id: duplicated.id,
|
||||
title: duplicated.title ?? duplicatedTitle,
|
||||
parent_id: duplicated.parent_id ?? null,
|
||||
sort_order: duplicated.sort_order ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { documentId }: DuplicatePayload = await request.json();
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data: sourceDoc, error: sourceError } = await supabase
|
||||
.from("documents")
|
||||
.select("id,title,content,parent_id,workspace_id,access_scope")
|
||||
.eq("id", documentId)
|
||||
.eq("user_id", session.user.id)
|
||||
.single();
|
||||
|
||||
if (sourceError || !sourceDoc) {
|
||||
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
||||
}
|
||||
|
||||
const siblingQuery = supabase
|
||||
.from("documents")
|
||||
.select("id", { head: true, count: "exact" })
|
||||
.eq("workspace_id", sourceDoc.workspace_id);
|
||||
|
||||
if (sourceDoc.parent_id) {
|
||||
siblingQuery.eq("parent_id", sourceDoc.parent_id);
|
||||
} else {
|
||||
siblingQuery.is("parent_id", null);
|
||||
}
|
||||
|
||||
const { count: rawSiblingCount, error: countError } = await siblingQuery;
|
||||
const siblingCount = rawSiblingCount ?? 0;
|
||||
if (countError) {
|
||||
return NextResponse.json({ error: countError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const fallbackTitle =
|
||||
sourceDoc.title?.trim() && sourceDoc.title.trim().length > 0
|
||||
? sourceDoc.title.trim()
|
||||
: "无标题";
|
||||
const duplicatedTitle = `${fallbackTitle} 副本`;
|
||||
|
||||
const { data: duplicated, error: duplicateError } = await supabase
|
||||
.from("documents")
|
||||
.insert({
|
||||
user_id: session.user.id,
|
||||
workspace_id: sourceDoc.workspace_id,
|
||||
parent_id: sourceDoc.parent_id,
|
||||
access_scope: sourceDoc.access_scope ?? "private",
|
||||
title: duplicatedTitle,
|
||||
content: sourceDoc.content,
|
||||
sort_order: siblingCount,
|
||||
})
|
||||
.select("id,title,parent_id,sort_order")
|
||||
.single();
|
||||
|
||||
if (duplicateError || !duplicated) {
|
||||
return NextResponse.json(
|
||||
{ error: duplicateError?.message ?? "复制失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
await ensureDocumentScaffold(duplicated.id, duplicated.title ?? duplicatedTitle);
|
||||
await copyMindmapIfExists(sourceDoc.id, duplicated.id);
|
||||
|
||||
return NextResponse.json(duplicated);
|
||||
*/
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -1,133 +1,11 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { Json } from "@/types/supabase";
|
||||
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
interface EmbedPayload {
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
}
|
||||
import { executeDocumentEmbedBridgeCommand } from "@/lib/documents/page-command-adapter";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
|
||||
const { sourceId, targetId }: EmbedPayload = await request.json();
|
||||
|
||||
if (!sourceId || !targetId) {
|
||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { id: sourceId });
|
||||
if (!sourceDoc) {
|
||||
return NextResponse.json({ error: "原始页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
|
||||
const targetContent = await client.query(api.documents.getContent, { id: targetId });
|
||||
if (!targetContent) {
|
||||
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
const targetMeta = await client.query(api.documents.getMeta, { id: targetId });
|
||||
|
||||
const currentBlocks = extractBlocksFromContent(targetContent.content);
|
||||
const anchorId = (targetMeta as any)?.embed_default_block_id as string | null | undefined;
|
||||
const anchorIndex =
|
||||
typeof anchorId === "string" && anchorId.trim()
|
||||
? currentBlocks.findIndex((b) => typeof b === "object" && b !== null && (b as any).id === anchorId)
|
||||
: -1;
|
||||
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : currentBlocks.length;
|
||||
|
||||
const nextBlocks: Json[] = [
|
||||
...currentBlocks.slice(0, insertIndex),
|
||||
{
|
||||
id: randomUUID(),
|
||||
type: "pageReference",
|
||||
props: {
|
||||
pageId: sourceDoc.id,
|
||||
title: sourceDoc.title ?? "无标题",
|
||||
},
|
||||
},
|
||||
...currentBlocks.slice(insertIndex),
|
||||
];
|
||||
|
||||
const payload: Json = composeContentWithBlocks(targetContent.content, nextBlocks);
|
||||
|
||||
await client.mutation(api.documents.updateContent, {
|
||||
id: targetId,
|
||||
content: payload,
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
return executeDocumentEmbedBridgeCommand(request);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { sourceId, targetId }: EmbedPayload = await request.json();
|
||||
|
||||
if (!sourceId || !targetId) {
|
||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data: sourceDoc, error: sourceError } = await supabase
|
||||
.from("documents")
|
||||
.select("id,title,user_id")
|
||||
.eq("id", sourceId)
|
||||
.eq("user_id", session.user.id)
|
||||
.single();
|
||||
|
||||
if (sourceError || !sourceDoc) {
|
||||
return NextResponse.json({ error: "原始页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
|
||||
const { data: targetDoc, error: targetError } = await supabase
|
||||
.from("documents")
|
||||
.select("id,content,user_id")
|
||||
.eq("id", targetId)
|
||||
.eq("user_id", session.user.id)
|
||||
.single();
|
||||
|
||||
if (targetError || !targetDoc) {
|
||||
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
|
||||
const currentBlocks = extractBlocksFromContent(targetDoc.content);
|
||||
const nextBlocks: Json[] = [
|
||||
...currentBlocks,
|
||||
{
|
||||
id: randomUUID(),
|
||||
type: "pageReference",
|
||||
props: {
|
||||
pageId: sourceDoc.id,
|
||||
title: sourceDoc.title ?? "无标题",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const payload: Json = composeContentWithBlocks(targetDoc.content, nextBlocks);
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from("documents")
|
||||
.update({ content: payload })
|
||||
.eq("id", targetDoc.id)
|
||||
.eq("user_id", session.user.id);
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: updateError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,69 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { executeDocumentEmptyTrashBridgeCommand } from "@/lib/documents/page-command-adapter";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface EmptyTrashPayload {
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { workspaceId }: EmptyTrashPayload = await request.json();
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
const { client } = await getAuthedConvexClient();
|
||||
await client.mutation(api.documents.emptyTrashByWorkspace, { workspaceId });
|
||||
return NextResponse.json({ success: true });
|
||||
return executeDocumentEmptyTrashBridgeCommand(request);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { workspaceId }: EmptyTrashPayload = await request.json();
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data: membership, error: membershipError } = await supabase
|
||||
.from("workspace_members")
|
||||
.select("workspace_id")
|
||||
.eq("workspace_id", workspaceId)
|
||||
.eq("user_id", session.user.id)
|
||||
.limit(1);
|
||||
|
||||
if (membershipError) {
|
||||
return NextResponse.json({ error: membershipError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
if (!membership || membership.length === 0) {
|
||||
return NextResponse.json({ error: "无权操作该工作空间" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.delete()
|
||||
.eq("workspace_id", workspaceId)
|
||||
.eq("user_id", session.user.id)
|
||||
.not("deleted_at", "is", null);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,55 +1,67 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import {
|
||||
assertDocumentId,
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentCommandEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
executePageLifecycleBridgeCommand,
|
||||
type DocumentMovePayload,
|
||||
} from "@/lib/documents/page-command-adapter";
|
||||
|
||||
interface MovePayload {
|
||||
documentId: string;
|
||||
parentId?: string | null;
|
||||
position: number;
|
||||
workspaceId?: string | null;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { documentId, parentId = null, position }: MovePayload = await request.json();
|
||||
const sortOrder = Number.isFinite(position) ? Math.floor(position) : 0;
|
||||
const { client } = await getAuthedConvexClient();
|
||||
await client.mutation(api.documents.move, {
|
||||
id: documentId,
|
||||
parentId,
|
||||
sortOrder,
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
try {
|
||||
const { documentId, parentId = null, position, workspaceId }: MovePayload = await request.json();
|
||||
const normalizedDocumentId = assertDocumentId(documentId);
|
||||
const normalizedWorkspaceId = workspaceId?.trim() || null;
|
||||
const sortOrder = Number.isFinite(position) ? Math.floor(position) : 0;
|
||||
const bridgeContext = await buildDocumentBridgeContext({
|
||||
request,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
});
|
||||
const result = await executePageLifecycleBridgeCommand<DocumentMovePayload, { ok: boolean }>({
|
||||
context: bridgeContext,
|
||||
envelope: buildDocumentCommandEnvelope({
|
||||
name: "documents.move",
|
||||
payload: {
|
||||
documentId: normalizedDocumentId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
parentId: parentId?.trim() || null,
|
||||
sortOrder,
|
||||
},
|
||||
context: bridgeContext,
|
||||
target: {
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
pageId: normalizedDocumentId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
meta: {
|
||||
requestId: result.requestId,
|
||||
traceId: result.traceId,
|
||||
commandId: result.commandId,
|
||||
commandName: result.commandName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { documentId, parentId = null, position }: MovePayload = await request.json();
|
||||
const sortOrder = Number.isFinite(position) ? Math.floor(position) : 0;
|
||||
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.update({
|
||||
parent_id: parentId,
|
||||
sort_order: sortOrder,
|
||||
})
|
||||
.eq("id", documentId)
|
||||
.eq("user_id", session.user.id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
*/
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -1,49 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { executeDocumentPurgeBridgeCommand } from "@/lib/documents/page-command-adapter";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { documentId } = await request.json();
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
const { client } = await getAuthedConvexClient();
|
||||
await client.mutation(api.documents.purge, { id: documentId });
|
||||
return NextResponse.json({ success: true });
|
||||
return executeDocumentPurgeBridgeCommand(request);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { documentId } = await request.json();
|
||||
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.delete()
|
||||
.eq("id", documentId)
|
||||
.eq("user_id", session.user.id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,54 +1,59 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import {
|
||||
assertDocumentId,
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentCommandEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
executePageLifecycleBridgeCommand,
|
||||
type DocumentRestorePayload,
|
||||
} from "@/lib/documents/page-command-adapter";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { documentId } = await request.json();
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
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);
|
||||
}
|
||||
const { client } = await getAuthedConvexClient();
|
||||
await client.mutation(api.documents.restore, { id: documentId });
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { documentId } = await request.json();
|
||||
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.update({
|
||||
deleted_at: null,
|
||||
deleted_by: null,
|
||||
parent_id: null,
|
||||
access_scope: "private",
|
||||
})
|
||||
.eq("id", documentId)
|
||||
.eq("user_id", session.user.id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
*/
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/convex/enabled", () => ({
|
||||
isConvexEnabled: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/page-command-adapter", () => ({
|
||||
executeDocumentCreateChildBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
|
||||
executeDocumentEmbedBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
|
||||
executeDocumentTemplateBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
|
||||
executeDocumentEmptyTrashBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
|
||||
executeDocumentPurgeBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
|
||||
}));
|
||||
|
||||
import { POST as postCreateChild } from "@/app/api/documents/create-child/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 {
|
||||
executeDocumentCreateChildBridgeCommand,
|
||||
executeDocumentEmbedBridgeCommand,
|
||||
executeDocumentTemplateBridgeCommand,
|
||||
executeDocumentEmptyTrashBridgeCommand,
|
||||
executeDocumentPurgeBridgeCommand,
|
||||
} from "@/lib/documents/page-command-adapter";
|
||||
|
||||
describe("documents route adapters", () => {
|
||||
it("creates child route delegates to unified adapter", async () => {
|
||||
await postCreateChild(new Request("http://localhost/api/documents/create-child", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ parentId: null }),
|
||||
}));
|
||||
expect(executeDocumentCreateChildBridgeCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("embed route delegates to unified adapter", async () => {
|
||||
await postEmbed(new Request("http://localhost/api/documents/embed", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sourceId: "doc_1", targetId: "doc_2" }),
|
||||
}));
|
||||
expect(executeDocumentEmbedBridgeCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("template route delegates to unified adapter", async () => {
|
||||
await postTemplate(new Request("http://localhost/api/documents/template", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ documentId: "doc_1", isTemplate: true }),
|
||||
}));
|
||||
expect(executeDocumentTemplateBridgeCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("empty trash route delegates to unified adapter", async () => {
|
||||
await postEmptyTrash(new Request("http://localhost/api/documents/empty-trash", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ workspaceId: "ws_1" }),
|
||||
}));
|
||||
expect(executeDocumentEmptyTrashBridgeCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("purge route delegates to unified adapter", async () => {
|
||||
await postPurge(new Request("http://localhost/api/documents/purge", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ documentId: "doc_1" }),
|
||||
}));
|
||||
expect(executeDocumentPurgeBridgeCommand).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -58,3 +58,5 @@ export async function POST(request: Request) {
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -1,26 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
type TemplatePayload = {
|
||||
documentId: string;
|
||||
isTemplate: boolean;
|
||||
};
|
||||
import { executeDocumentTemplateBridgeCommand } from "@/lib/documents/page-command-adapter";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { documentId, isTemplate }: TemplatePayload = await request.json();
|
||||
if (!documentId || typeof isTemplate !== "boolean") {
|
||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { client } = await getAuthedConvexClient();
|
||||
await client.mutation(api.documents.setTemplate, { id: documentId, isTemplate });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
return executeDocumentTemplateBridgeCommand(request);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
|
||||
@@ -60,3 +60,5 @@ export async function POST(request: Request) {
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -2,7 +2,8 @@ import { NextResponse } from "next/server";
|
||||
import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig";
|
||||
import { loadLocalAiConfig } from "@/lib/ai/localAiConfig";
|
||||
import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat";
|
||||
import { applyMindmapOps, type MindmapOp, type MindmapTreeNode, type NodeRef } from "@/lib/mindmap/mindmapOps";
|
||||
import { buildDocumentBridgeContextWithActor } from "@/lib/documents/bridge";
|
||||
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
|
||||
import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig";
|
||||
import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat";
|
||||
import { applyMindmapOps, type MindmapOp, type MindmapTreeNode, type NodeRef } from "@/lib/mindmap/mindmapOps";
|
||||
import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
|
||||
import { buildDocumentBridgeContextWithActor } from "@/lib/documents/bridge";
|
||||
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
|
||||
import { readMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
|
||||
|
||||
type RequestPayload = {
|
||||
documentId: string;
|
||||
@@ -81,9 +82,9 @@ const searchSearxng = async (q: string, count = 5): Promise<SearxResult[]> => {
|
||||
return mapped;
|
||||
};
|
||||
|
||||
const coerceOpsFromAiJson = (raw: Record<string, unknown>): MindmapOp[] => {
|
||||
const coerceOpsFromAiJson = (raw: Record<string, unknown>) => {
|
||||
const ops = (raw as any)?.ops;
|
||||
return Array.isArray(ops) ? (ops as MindmapOp[]) : [];
|
||||
return Array.isArray(ops) ? ops : [];
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@@ -294,16 +295,58 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(baseData, fixed);
|
||||
await writeMindmapLocal(payload.documentId, payload.mindmapId, nextData, doc.title ?? "无标题");
|
||||
const context = buildDocumentBridgeContextWithActor({
|
||||
request,
|
||||
actor: {
|
||||
actorType: "service",
|
||||
actorId: "mindmap-expand-node",
|
||||
sessionId: null,
|
||||
},
|
||||
workspaceId: (doc as any).workspace_id ?? null,
|
||||
source: {
|
||||
channel: "mindmap-expand-route",
|
||||
client: "wolai-frontend",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeRustBridgeTool<{
|
||||
ok: boolean;
|
||||
applied?: number;
|
||||
errors?: string[];
|
||||
ops?: unknown[];
|
||||
data?: unknown;
|
||||
meta?: unknown;
|
||||
}>({
|
||||
context,
|
||||
toolName: "mindmap_expand_node",
|
||||
invocationKind: "command",
|
||||
args: {
|
||||
documentId: payload.documentId,
|
||||
mindmapId: payload.mindmapId,
|
||||
targetUid: payload.targetUid,
|
||||
instruction: payload.instruction ?? "",
|
||||
ops: fixed,
|
||||
searchResults: searxResults.map((r) => ({
|
||||
title: r.title,
|
||||
url: r.url,
|
||||
snippet: r.snippet ?? "",
|
||||
})),
|
||||
reason: payload.instruction ?? "",
|
||||
},
|
||||
data: {
|
||||
data: baseData,
|
||||
source: "mindmap-expand-node",
|
||||
},
|
||||
mode: "result",
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
providerUsed: "online",
|
||||
applied,
|
||||
errors,
|
||||
ops: fixed,
|
||||
data: nextData,
|
||||
applied: (result.result as any)?.applied ?? 0,
|
||||
errors: (result.result as any)?.errors ?? [],
|
||||
ops: (result.result as any)?.ops ?? fixed,
|
||||
data: (result.result as any)?.data ?? baseData,
|
||||
meta: {
|
||||
finishReason,
|
||||
searched: useSearx,
|
||||
|
||||
@@ -4,6 +4,8 @@ import path from "path";
|
||||
import os from "os";
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { buildDocumentBridgeContextWithActor } from "@/lib/documents/bridge";
|
||||
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
|
||||
import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig";
|
||||
import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
@@ -877,6 +879,7 @@ export async function POST(request: Request) {
|
||||
|
||||
let pdfTitle = "文档";
|
||||
let pdfUrlForLink: ((p: number) => string) | null = null;
|
||||
let pageLinkPattern = "#page={page}";
|
||||
let cleanupDir: string | null = null;
|
||||
let pdfFile: string | null = null;
|
||||
|
||||
@@ -893,6 +896,7 @@ export async function POST(request: Request) {
|
||||
pdfFile = file;
|
||||
pdfTitle = testName.replace(/\.pdf$/i, "");
|
||||
pdfUrlForLink = (p) => `/api/mindmap-ai/test-pdf?name=${encodeURIComponent(testName)}#page=${p}`;
|
||||
pageLinkPattern = `/api/mindmap-ai/test-pdf?name=${encodeURIComponent(testName)}#page={page}`;
|
||||
} else if (payload.source.kind === "url") {
|
||||
const safe = safeFetchableUrl(payload.source.fileUrl);
|
||||
if (!safe) {
|
||||
@@ -903,6 +907,7 @@ export async function POST(request: Request) {
|
||||
pdfFile = downloaded.file;
|
||||
pdfTitle = payload.source.title?.trim() || "文档";
|
||||
pdfUrlForLink = (p) => `${safe}#page=${p}`;
|
||||
pageLinkPattern = `${safe}#page={page}`;
|
||||
} else {
|
||||
return NextResponse.json({ error: "不支持的 source.kind" }, { status: 400 });
|
||||
}
|
||||
@@ -1104,13 +1109,61 @@ export async function POST(request: Request) {
|
||||
]);
|
||||
}
|
||||
|
||||
const mindmapData = plan
|
||||
? buildMindmapFromPlan(pdfTitle, plan, linkForPage)
|
||||
: buildMindmapTree(pdfTitle, outline ?? [], linkForPage);
|
||||
const finalOutline = plan
|
||||
? plan.chapters.flatMap((chapter, chapterIndex) => [
|
||||
{
|
||||
title: chapter.title,
|
||||
level: 1,
|
||||
page: chapter.page,
|
||||
},
|
||||
...((chapter.sections ?? []).map((section) => ({
|
||||
title: section.title,
|
||||
level: 2,
|
||||
page: section.page,
|
||||
}))),
|
||||
])
|
||||
: (outline ?? []);
|
||||
|
||||
const context = buildDocumentBridgeContextWithActor({
|
||||
request,
|
||||
actor: {
|
||||
actorType: "service",
|
||||
actorId: "mindmap-outline-to-mindmap",
|
||||
sessionId: null,
|
||||
},
|
||||
workspaceId: null,
|
||||
source: {
|
||||
channel: "mindmap-outline-route",
|
||||
client: "wolai-frontend",
|
||||
},
|
||||
});
|
||||
const payload = {
|
||||
documentId: "mindmap-outline-to-mindmap",
|
||||
mindmapId: "mindmap-outline-to-mindmap",
|
||||
rootTitle: pdfTitle,
|
||||
pageLinkPattern,
|
||||
outline: finalOutline,
|
||||
};
|
||||
const result = await executeRustBridgeTool<{
|
||||
ok: boolean;
|
||||
data?: unknown;
|
||||
meta?: { title?: string | null; outlineCount?: number | null } | null;
|
||||
}>({
|
||||
context,
|
||||
toolName: "mindmap_outline_to_mindmap",
|
||||
invocationKind: "command",
|
||||
args: payload,
|
||||
data: payload,
|
||||
mode: "result",
|
||||
});
|
||||
const mindmapData = (result as any)?.data ?? {
|
||||
data: { text: pdfTitle },
|
||||
children: [],
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
mindmapData,
|
||||
outline: plan ? null : outline,
|
||||
outline: null,
|
||||
plan,
|
||||
candidates,
|
||||
tocEntries,
|
||||
@@ -1122,6 +1175,7 @@ export async function POST(request: Request) {
|
||||
onlineAttempted,
|
||||
onlineSucceeded,
|
||||
onlineError,
|
||||
outlineCount: Array.isArray(finalOutline) ? finalOutline.length : 0,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { buildDocumentBridgeContextWithActor } from "@/lib/documents/bridge";
|
||||
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -17,6 +19,28 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const context = buildDocumentBridgeContextWithActor({
|
||||
request,
|
||||
actor: {
|
||||
actorType: "service",
|
||||
actorId: "mindmap-trash-empty",
|
||||
sessionId: null,
|
||||
},
|
||||
workspaceId,
|
||||
source: {
|
||||
channel: "mindmap-trash-route",
|
||||
client: "wolai-frontend",
|
||||
},
|
||||
});
|
||||
await executeRustBridgeTool({
|
||||
context,
|
||||
toolName: "mindmap_empty_trash",
|
||||
invocationKind: "command",
|
||||
args: { workspaceId },
|
||||
data: { workspaceId, source: "mindmap-trash-empty" },
|
||||
mode: "result",
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await client.mutation(api.mindmaps.emptyTrashByWorkspace, { workspaceId });
|
||||
return NextResponse.json({ ok: true, removed: result?.deletedCount ?? 0 });
|
||||
@@ -27,4 +51,3 @@ export async function POST(request: Request) {
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,18 @@ import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { buildMindmapRouteMeta } from "@/lib/mindmap/mindmapRouteMeta";
|
||||
import {
|
||||
buildDocumentBridgeContextWithActor,
|
||||
buildDocumentCommandEnvelope,
|
||||
buildDocumentQueryEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
executeRustBridgeMutationTransport,
|
||||
executeRustBridgeQueryTransport,
|
||||
resolveRustBridgeCommandPlan,
|
||||
resolveRustBridgeQueryPlan,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
|
||||
const defaultMindmapData = {
|
||||
data: { text: "中心主题" },
|
||||
@@ -17,23 +29,62 @@ export async function GET(
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const res = await client.query(api.mindmaps.get, { docId, mindmapId });
|
||||
return NextResponse.json({
|
||||
data: res?.data ?? defaultMindmapData,
|
||||
source: "convex",
|
||||
meta: {
|
||||
...buildMindmapRouteMeta(request, {
|
||||
workspaceId: res?.meta?.workspace_id ?? null,
|
||||
try {
|
||||
const context = buildDocumentBridgeContextWithActor({
|
||||
request,
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: auth.userId,
|
||||
sessionId: null,
|
||||
},
|
||||
workspaceId: null,
|
||||
source: {
|
||||
channel: "mindmap-route",
|
||||
client: "wolai-frontend",
|
||||
},
|
||||
});
|
||||
const envelope = buildDocumentQueryEnvelope({
|
||||
name: "mindmaps.get",
|
||||
payload: {
|
||||
documentId: docId,
|
||||
mindmapId,
|
||||
ownerUserId: auth.userId,
|
||||
}),
|
||||
exists: Boolean(res?.meta?.exists),
|
||||
deletedAt: res?.meta?.deleted_at ?? null,
|
||||
createdAt: res?.meta?.created_at ?? null,
|
||||
updatedAt: res?.meta?.updated_at ?? null,
|
||||
},
|
||||
});
|
||||
workspaceId: null,
|
||||
},
|
||||
});
|
||||
const plan = await resolveRustBridgeQueryPlan({ context, envelope });
|
||||
const res = await executeRustBridgeQueryTransport<{
|
||||
ok?: boolean;
|
||||
data?: unknown;
|
||||
meta?: {
|
||||
workspace_id?: string | null;
|
||||
exists?: boolean;
|
||||
deleted_at?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
} | null;
|
||||
}>({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
return NextResponse.json({
|
||||
data: res?.data ?? defaultMindmapData,
|
||||
source: "convex",
|
||||
meta: {
|
||||
...buildMindmapRouteMeta(request, {
|
||||
workspaceId: res?.meta?.workspace_id ?? null,
|
||||
documentId: docId,
|
||||
mindmapId,
|
||||
ownerUserId: auth.userId,
|
||||
}),
|
||||
exists: Boolean(res?.meta?.exists),
|
||||
deletedAt: res?.meta?.deleted_at ?? null,
|
||||
createdAt: res?.meta?.created_at ?? null,
|
||||
updatedAt: res?.meta?.updated_at ?? null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
@@ -53,11 +104,44 @@ export async function POST(
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await client.mutation(api.mindmaps.put, {
|
||||
docId,
|
||||
mindmapId,
|
||||
data: data ?? defaultMindmapData,
|
||||
...(typeof createOnly === "boolean" ? { createOnly } : {}),
|
||||
const context = buildDocumentBridgeContextWithActor({
|
||||
request,
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: auth.userId,
|
||||
sessionId: null,
|
||||
},
|
||||
workspaceId: null,
|
||||
source: {
|
||||
channel: "mindmap-route",
|
||||
client: "wolai-frontend",
|
||||
},
|
||||
});
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "mindmaps.put",
|
||||
payload: {
|
||||
documentId: docId,
|
||||
mindmapId,
|
||||
data: data ?? defaultMindmapData,
|
||||
createOnly: typeof createOnly === "boolean" ? createOnly : false,
|
||||
},
|
||||
context,
|
||||
target: {
|
||||
workspaceId: null,
|
||||
pageId: docId,
|
||||
blockId: mindmapId,
|
||||
},
|
||||
reason: "mindmap-route:put",
|
||||
refs: ["task-032", "mindmap-route"],
|
||||
});
|
||||
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
|
||||
const result = await executeRustBridgeMutationTransport<{
|
||||
ok?: boolean;
|
||||
workspace_id?: string | null;
|
||||
updated_at?: string | null;
|
||||
}>({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
return NextResponse.json({
|
||||
...(result ?? { ok: true }),
|
||||
@@ -72,7 +156,7 @@ export async function POST(
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,18 @@ import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import {
|
||||
buildDocumentBridgeContextWithActor,
|
||||
buildDocumentCommandEnvelope,
|
||||
buildDocumentQueryEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
executeRustBridgeMutationTransport,
|
||||
executeRustBridgeQueryTransport,
|
||||
resolveRustBridgeCommandPlan,
|
||||
resolveRustBridgeQueryPlan,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
import { buildMindmapRouteMeta } from "@/lib/mindmap/mindmapRouteMeta";
|
||||
|
||||
const defaultMindmapData = {
|
||||
@@ -9,6 +21,10 @@ const defaultMindmapData = {
|
||||
children: [],
|
||||
};
|
||||
|
||||
function getLegacyMindmapId(docId: string) {
|
||||
return `legacy-${docId}`;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ docId: string }> },
|
||||
@@ -17,24 +33,64 @@ export async function GET(
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const mindmapId = `legacy-${docId}`;
|
||||
const res = await client.query(api.mindmaps.get, { docId, mindmapId });
|
||||
return NextResponse.json({
|
||||
data: res?.data ?? defaultMindmapData,
|
||||
source: "convex",
|
||||
meta: {
|
||||
...buildMindmapRouteMeta(request, {
|
||||
workspaceId: res?.meta?.workspace_id ?? null,
|
||||
const mindmapId = getLegacyMindmapId(docId);
|
||||
|
||||
try {
|
||||
const context = buildDocumentBridgeContextWithActor({
|
||||
request,
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: auth.userId,
|
||||
sessionId: null,
|
||||
},
|
||||
workspaceId: null,
|
||||
source: {
|
||||
channel: "mindmap-route",
|
||||
client: "wolai-frontend",
|
||||
},
|
||||
});
|
||||
const envelope = buildDocumentQueryEnvelope({
|
||||
name: "mindmaps.get",
|
||||
payload: {
|
||||
documentId: docId,
|
||||
mindmapId,
|
||||
ownerUserId: auth.userId,
|
||||
}),
|
||||
exists: Boolean(res?.meta?.exists),
|
||||
deletedAt: res?.meta?.deleted_at ?? null,
|
||||
createdAt: res?.meta?.created_at ?? null,
|
||||
updatedAt: res?.meta?.updated_at ?? null,
|
||||
},
|
||||
});
|
||||
workspaceId: null,
|
||||
},
|
||||
});
|
||||
const plan = await resolveRustBridgeQueryPlan({ context, envelope });
|
||||
const res = await executeRustBridgeQueryTransport<{
|
||||
ok?: boolean;
|
||||
data?: unknown;
|
||||
meta?: {
|
||||
workspace_id?: string | null;
|
||||
exists?: boolean;
|
||||
deleted_at?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
} | null;
|
||||
}>({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
return NextResponse.json({
|
||||
data: res?.data ?? defaultMindmapData,
|
||||
source: "convex",
|
||||
meta: {
|
||||
...buildMindmapRouteMeta(request, {
|
||||
workspaceId: res?.meta?.workspace_id ?? null,
|
||||
documentId: docId,
|
||||
mindmapId,
|
||||
ownerUserId: auth.userId,
|
||||
}),
|
||||
exists: Boolean(res?.meta?.exists),
|
||||
deletedAt: res?.meta?.deleted_at ?? null,
|
||||
createdAt: res?.meta?.created_at ?? null,
|
||||
updatedAt: res?.meta?.updated_at ?? null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
@@ -48,25 +104,67 @@ export async function POST(
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const mindmapId = `legacy-${docId}`;
|
||||
const payload = (await request.json().catch(() => ({}))) as { data?: unknown };
|
||||
const result = await client.mutation(api.mindmaps.put, {
|
||||
docId,
|
||||
mindmapId,
|
||||
data: payload.data ?? defaultMindmapData,
|
||||
});
|
||||
return NextResponse.json({
|
||||
...(result ?? { ok: true }),
|
||||
meta: {
|
||||
...buildMindmapRouteMeta(request, {
|
||||
workspaceId: result?.workspace_id ?? null,
|
||||
const mindmapId = getLegacyMindmapId(docId);
|
||||
const { data, createOnly } = (await request.json().catch(() => ({ data: null }))) as {
|
||||
data?: unknown;
|
||||
createOnly?: boolean;
|
||||
};
|
||||
|
||||
try {
|
||||
const context = buildDocumentBridgeContextWithActor({
|
||||
request,
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: auth.userId,
|
||||
sessionId: null,
|
||||
},
|
||||
workspaceId: null,
|
||||
source: {
|
||||
channel: "mindmap-route",
|
||||
client: "wolai-frontend",
|
||||
},
|
||||
});
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "mindmaps.put",
|
||||
payload: {
|
||||
documentId: docId,
|
||||
mindmapId,
|
||||
ownerUserId: auth.userId,
|
||||
}),
|
||||
updatedAt: result?.updated_at ?? null,
|
||||
},
|
||||
});
|
||||
data: data ?? defaultMindmapData,
|
||||
createOnly: typeof createOnly === "boolean" ? createOnly : false,
|
||||
},
|
||||
context,
|
||||
target: {
|
||||
workspaceId: null,
|
||||
pageId: docId,
|
||||
blockId: mindmapId,
|
||||
},
|
||||
reason: "mindmap-route:put",
|
||||
refs: ["task-032", "mindmap-route"],
|
||||
});
|
||||
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
|
||||
const result = await executeRustBridgeMutationTransport<{
|
||||
ok?: boolean;
|
||||
workspace_id?: string | null;
|
||||
updated_at?: string | null;
|
||||
}>({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
return NextResponse.json({
|
||||
...(result ?? { ok: true }),
|
||||
meta: {
|
||||
...buildMindmapRouteMeta(request, {
|
||||
workspaceId: result?.workspace_id ?? null,
|
||||
documentId: docId,
|
||||
mindmapId,
|
||||
ownerUserId: auth.userId,
|
||||
}),
|
||||
updatedAt: result?.updated_at ?? null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockQuery = vi.fn();
|
||||
const mockMutation = vi.fn();
|
||||
const mockExecuteMediaAssetWritebackBridgeCommand = vi.fn();
|
||||
const mockPrepareOnlyOfficeCallback = vi.fn();
|
||||
|
||||
vi.mock("@/lib/auth/authContext", () => ({
|
||||
HttpError: class HttpError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
requireAuthContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-utils", () => ({
|
||||
apiErrorResponse: vi.fn((message: string, status = 500, details?: unknown) => ({
|
||||
message,
|
||||
status,
|
||||
details,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/convex/enabled", () => ({
|
||||
isConvexEnabled: () => true,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/convex/server", () => ({
|
||||
getConvexHttpClient: () => ({
|
||||
query: mockQuery,
|
||||
mutation: mockMutation,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/convex/api", () => ({
|
||||
api: {
|
||||
mediaAssets: {
|
||||
getById: { name: "mediaAssets.getById" },
|
||||
generateUploadUrl: { name: "mediaAssets.generateUploadUrl" },
|
||||
replaceStorageFromUpload: { name: "mediaAssets.replaceStorageFromUpload" },
|
||||
},
|
||||
documents: {
|
||||
getPermissionForUser: { name: "documents.getPermissionForUser" },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/onlyoffice/internal-url", () => ({
|
||||
resolveOnlyOfficeInternalUrl: vi.fn().mockResolvedValue("http://127.0.0.1:8082"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/bridge", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/documents/bridge")>("@/lib/documents/bridge");
|
||||
return {
|
||||
...actual,
|
||||
buildDocumentBridgeContextWithActor: vi.fn((input) => ({
|
||||
requestId: "req_test_onlyoffice_callback",
|
||||
traceId: "trace_test_onlyoffice_callback",
|
||||
deploymentId: null,
|
||||
projectId: null,
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
actor: input.actor,
|
||||
source: input.source ?? { channel: "onlyoffice-callback", client: "vitest" },
|
||||
tenantId: null,
|
||||
authToken: null,
|
||||
idempotencyKey: input.idempotencyKey ?? null,
|
||||
validateOnly: false,
|
||||
dryRun: false,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/documents/media-asset-command-adapter", () => ({
|
||||
executeMediaAssetWritebackBridgeCommand: mockExecuteMediaAssetWritebackBridgeCommand,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/onlyoffice/rust-adapter", () => ({
|
||||
prepareOnlyOfficeCallback: mockPrepareOnlyOfficeCallback,
|
||||
}));
|
||||
|
||||
describe("/api/onlyoffice/callback route", () => {
|
||||
beforeEach(() => {
|
||||
mockQuery.mockReset();
|
||||
mockMutation.mockReset();
|
||||
mockExecuteMediaAssetWritebackBridgeCommand.mockReset();
|
||||
mockPrepareOnlyOfficeCallback.mockReset();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("uses media asset bridge adapter for callback writeback", async () => {
|
||||
const { POST } = await import("./route");
|
||||
|
||||
mockQuery
|
||||
.mockResolvedValueOnce({
|
||||
id: "asset_1",
|
||||
document_id: "doc_1",
|
||||
workspace_id: "ws_1",
|
||||
mime_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
permission: "edit",
|
||||
});
|
||||
mockMutation.mockResolvedValueOnce("http://127.0.0.1:3210/api/upload");
|
||||
mockExecuteMediaAssetWritebackBridgeCommand.mockResolvedValue({
|
||||
requestId: "req_test_onlyoffice_callback",
|
||||
traceId: "trace_test_onlyoffice_callback",
|
||||
commandId: "cmd_test_onlyoffice_callback",
|
||||
commandName: "media.assets.replace_storage",
|
||||
});
|
||||
mockPrepareOnlyOfficeCallback.mockResolvedValue({
|
||||
shouldWrite: true,
|
||||
downloadUrl: "http://127.0.0.1:8082/cache/files/output.docx",
|
||||
idempotencyKey: "doc_key_1",
|
||||
locator: {
|
||||
assetId: "asset_1",
|
||||
workspaceId: "ws_1",
|
||||
documentId: "doc_1",
|
||||
},
|
||||
session: {
|
||||
sessionId: "onlyoffice-callback",
|
||||
assetId: "asset_1",
|
||||
workspaceId: "ws_1",
|
||||
documentId: "doc_1",
|
||||
userId: "user_1",
|
||||
},
|
||||
});
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "http://127.0.0.1:8082/cache/files/output.docx") {
|
||||
return new Response(Buffer.from("docx-binary"), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document" },
|
||||
});
|
||||
}
|
||||
if (url === "http://127.0.0.1:3210/api/upload") {
|
||||
return Response.json({ storageId: "storage_new_1" }, { status: 200 });
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const request = new Request(
|
||||
"http://127.0.0.1:3001/api/onlyoffice/callback?assetId=asset_1&userId=user_1",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
status: 2,
|
||||
key: "doc_key_1",
|
||||
url: "http://127.0.0.1:8082/cache/files/output.docx",
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const response = await POST(request);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload).toEqual({ error: 0 });
|
||||
expect(mockMutation).toHaveBeenCalledTimes(1);
|
||||
expect(mockMutation.mock.calls[0]?.[1]).toEqual({ userId: "user_1" });
|
||||
expect(mockExecuteMediaAssetWritebackBridgeCommand).toHaveBeenCalledTimes(1);
|
||||
expect(mockExecuteMediaAssetWritebackBridgeCommand).toHaveBeenCalledWith({
|
||||
client: expect.any(Object),
|
||||
context: expect.objectContaining({
|
||||
workspaceId: "ws_1",
|
||||
actor: {
|
||||
actorType: "service",
|
||||
actorId: "user_1",
|
||||
sessionId: "onlyoffice-callback",
|
||||
},
|
||||
source: {
|
||||
channel: "onlyoffice-callback",
|
||||
client: "onlyoffice-document-server",
|
||||
},
|
||||
idempotencyKey: "doc_key_1",
|
||||
}),
|
||||
envelope: expect.objectContaining({
|
||||
name: "media.assets.replace_storage",
|
||||
payload: {
|
||||
assetId: "asset_1",
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
storageId: "storage_new_1",
|
||||
userId: "user_1",
|
||||
},
|
||||
target: {
|
||||
workspaceId: "ws_1",
|
||||
pageId: "doc_1",
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { resolveOnlyOfficeInternalUrl } from "@/lib/onlyoffice/internal-url";
|
||||
import { prepareOnlyOfficeCallback } from "@/lib/onlyoffice/rust-adapter";
|
||||
import {
|
||||
buildDocumentBridgeContextWithActor,
|
||||
buildDocumentCommandEnvelope,
|
||||
@@ -45,61 +46,32 @@ const normalizeSecret = (raw: string) => {
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const tryRewriteOnlyOfficeDownloadUrl = (raw: string, onlyofficeInternalUrl: string) => {
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
const onlyofficeInternalUrl = await resolveOnlyOfficeInternalUrl();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const assetId = searchParams.get("assetId") || "";
|
||||
|
||||
// 说明:如果回传的是通过 /onlyoffice-server 访问的地址,
|
||||
// 服务端下载时优先改写为本机 ONLYOFFICE_INTERNAL_URL(避免绕公网/证书问题)。
|
||||
const prefix = "/onlyoffice-server";
|
||||
if (u.pathname.startsWith(prefix)) {
|
||||
const nextPath = u.pathname.slice(prefix.length).replace(/^\/+/, "");
|
||||
return `${onlyofficeInternalUrl}/${nextPath}${u.search}`;
|
||||
// 说明:ONLYOFFICE 回调由文档服务器触发,不一定携带用户态;这里提供一个可选的共享密钥校验。
|
||||
// 若未配置 ONLYOFFICE_CALLBACK_SECRET,则保持兼容不校验。
|
||||
if (ONLYOFFICE_CALLBACK_SECRET) {
|
||||
const got = normalizeSecret(searchParams.get("token") || "");
|
||||
const expected = normalizeSecret(ONLYOFFICE_CALLBACK_SECRET);
|
||||
if (!got || got !== expected) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
return raw;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
};
|
||||
const body = (await request.json().catch(() => null)) as OnlyOfficeCallbackBody | null;
|
||||
if (!body) {
|
||||
return NextResponse.json({ error: 0 });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const onlyofficeInternalUrl = await resolveOnlyOfficeInternalUrl();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const assetId = searchParams.get("assetId") || "";
|
||||
|
||||
// 说明:ONLYOFFICE 回调由文档服务器触发,不一定携带用户态;这里提供一个可选的共享密钥校验。
|
||||
// 若未配置 ONLYOFFICE_CALLBACK_SECRET,则保持兼容不校验。
|
||||
if (ONLYOFFICE_CALLBACK_SECRET) {
|
||||
const got = normalizeSecret(searchParams.get("token") || "");
|
||||
const expected = normalizeSecret(ONLYOFFICE_CALLBACK_SECRET);
|
||||
if (!got || got !== expected) {
|
||||
if (!assetId) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => null)) as OnlyOfficeCallbackBody | null;
|
||||
if (!body) {
|
||||
return NextResponse.json({ error: 0 });
|
||||
}
|
||||
|
||||
const status = Number(body.status ?? -1);
|
||||
// 说明:仅在文档需要保存时处理(2=ready for saving;6=force save)。
|
||||
if (status !== 2 && status !== 6) {
|
||||
return NextResponse.json({ error: 0 });
|
||||
}
|
||||
|
||||
if (!assetId) {
|
||||
// 说明:缺少 assetId 无法定位存储路径,返回非 0 让 ONLYOFFICE 显示保存失败。
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
if (!body.url) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
try {
|
||||
if (isConvexEnabled()) {
|
||||
// 说明:Convex 模式下,保存写回 Convex Files,并更新 media_assets.storage_id/file_url。
|
||||
// 优先使用前端在 /onlyoffice 页面透传的 userId;避免固定 dev-user 导致 workspace membership 校验失败。
|
||||
const userId =
|
||||
@@ -118,6 +90,22 @@ export async function POST(request: Request) {
|
||||
const workspaceId = String(asset.workspace_id || "").trim() || null;
|
||||
const mimeType = String(asset.mime_type || "").trim() || "application/octet-stream";
|
||||
|
||||
const prepared = await prepareOnlyOfficeCallback({
|
||||
request,
|
||||
assetId,
|
||||
documentId,
|
||||
workspaceId,
|
||||
userId,
|
||||
sessionId: "onlyoffice-callback",
|
||||
status: Number(body.status ?? -1),
|
||||
url: body.url ?? null,
|
||||
key: body.key ?? null,
|
||||
onlyofficeInternalUrl,
|
||||
});
|
||||
if (!prepared.shouldWrite) {
|
||||
return NextResponse.json({ error: 0 });
|
||||
}
|
||||
|
||||
// 只读权限:不允许通过 ONLYOFFICE 回调写回
|
||||
try {
|
||||
const perm = (await client.query(api.documents.getPermissionForUser, {
|
||||
@@ -131,7 +119,10 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
const downloadUrl = tryRewriteOnlyOfficeDownloadUrl(body.url, onlyofficeInternalUrl);
|
||||
const downloadUrl = String(prepared.downloadUrl || "").trim();
|
||||
if (!downloadUrl) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
const upstream = await fetch(downloadUrl, { method: "GET", redirect: "follow" });
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
@@ -172,7 +163,7 @@ export async function POST(request: Request) {
|
||||
channel: "onlyoffice-callback",
|
||||
client: "onlyoffice-document-server",
|
||||
},
|
||||
idempotencyKey: String(body.key || assetId || "").trim() || null,
|
||||
idempotencyKey: String(prepared.idempotencyKey || assetId || "").trim() || null,
|
||||
});
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "media.assets.replace_storage",
|
||||
@@ -199,11 +190,12 @@ export async function POST(request: Request) {
|
||||
});
|
||||
|
||||
return NextResponse.json({ error: 0 });
|
||||
} catch (error) {
|
||||
// 说明:避免异常导致 ONLYOFFICE 重试/阻塞(例如 Convex 未部署新 mutation)。
|
||||
console.error("[onlyoffice/callback] convex writeback failed:", error);
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 1 });
|
||||
} catch (error) {
|
||||
// 说明:避免异常导致 ONLYOFFICE 重试/阻塞(例如 Convex 未部署新 mutation)。
|
||||
console.error("[onlyoffice/callback] convex writeback failed:", error);
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
@@ -1,41 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import crypto from "crypto";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { HttpError } from "@/lib/auth/authContext";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { resolveOnlyOfficeInternalUrl } from "@/lib/onlyoffice/internal-url";
|
||||
import { documentBridgeErrorResponse } from "@/lib/documents/bridge";
|
||||
import { prepareOnlyOfficeForcesave } from "@/lib/onlyoffice/rust-adapter";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const base64Url = (input: Buffer | string) =>
|
||||
Buffer.from(input)
|
||||
.toString("base64")
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
|
||||
const signHs256 = (payload: unknown, secret: string) => {
|
||||
const header = { alg: "HS256", typ: "JWT" };
|
||||
const headerPart = base64Url(JSON.stringify(header));
|
||||
const payloadPart = base64Url(JSON.stringify(payload));
|
||||
const signingInput = `${headerPart}.${payloadPart}`;
|
||||
const signature = crypto.createHmac("sha256", secret).update(signingInput).digest();
|
||||
return `${signingInput}.${base64Url(signature)}`;
|
||||
};
|
||||
|
||||
const normalizeSecret = (raw: string) => {
|
||||
const trimmed = String(raw || "").trim();
|
||||
if (!trimmed) return "";
|
||||
// 兼容用户把 .env 的值写成 "xxx" / 'xxx'
|
||||
if (
|
||||
(trimmed.startsWith("\"") && trimmed.endsWith("\"") && trimmed.length >= 2) ||
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2)
|
||||
) {
|
||||
return trimmed.slice(1, -1).trim();
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const onlyofficeInternalUrl = await resolveOnlyOfficeInternalUrl();
|
||||
let auth;
|
||||
@@ -72,70 +44,43 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "无权修改(只读共享的文件)" }, { status: 403 });
|
||||
}
|
||||
|
||||
const payload = { c: "forcesave", key, userdata: `asset:${assetId}` };
|
||||
const secret = normalizeSecret(process.env.ONLYOFFICE_JWT_SECRET || "");
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const tryJson = async (res: Response) => (await res.json().catch(() => null)) as any;
|
||||
|
||||
try {
|
||||
// 优先按文档推荐:使用 /command + token
|
||||
if (secret) {
|
||||
const token = signHs256(payload, secret);
|
||||
const r = await fetch(`${onlyofficeInternalUrl}/command`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
const j = await tryJson(r);
|
||||
if (r.ok && Number(j?.error ?? 0) === 0) {
|
||||
return NextResponse.json({ ok: true, via: "command", result: j });
|
||||
}
|
||||
|
||||
// 兜底:部分环境可能暴露 /forcesave 直连接口
|
||||
const r2 = await fetch(`${onlyofficeInternalUrl}/forcesave`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const j2 = await tryJson(r2);
|
||||
if (r2.ok && Number(j2?.error ?? 0) === 0) {
|
||||
return NextResponse.json({ ok: true, via: "forcesave", result: j2 });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "触发 forcesave 失败", detail: { command: j, forcesave: j2 } },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
// JWT 未启用:尝试 /forcesave 直连
|
||||
const r = await fetch(`${onlyofficeInternalUrl}/forcesave`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
const prepared = await prepareOnlyOfficeForcesave({
|
||||
request,
|
||||
assetId,
|
||||
key,
|
||||
onlyofficeInternalUrl,
|
||||
secret: process.env.ONLYOFFICE_JWT_SECRET || "",
|
||||
actorId: auth.userId,
|
||||
documentId: docId,
|
||||
workspaceId: String((asset as any).workspace_id || "").trim() || null,
|
||||
});
|
||||
const j = await tryJson(r);
|
||||
if (r.ok && Number(j?.error ?? 0) === 0) {
|
||||
return NextResponse.json({ ok: true, via: "forcesave", result: j });
|
||||
}
|
||||
|
||||
// 最后兜底:部分部署可能仍接受不带 token 的 /command(不保证)
|
||||
const r2 = await fetch(`${onlyofficeInternalUrl}/command`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const j2 = await tryJson(r2);
|
||||
if (r2.ok && Number(j2?.error ?? 0) === 0) {
|
||||
return NextResponse.json({ ok: true, via: "command_no_token", result: j2 });
|
||||
const errors: Record<string, unknown> = {};
|
||||
for (const outbound of prepared.requests) {
|
||||
const response = await fetch(outbound.url, {
|
||||
method: outbound.method,
|
||||
headers: Object.fromEntries(outbound.headers.map((item) => [item.name, item.value])),
|
||||
body: outbound.bodyJson,
|
||||
});
|
||||
const payload = await tryJson(response);
|
||||
if (response.ok && Number(payload?.error ?? 0) === 0) {
|
||||
return NextResponse.json({ ok: true, via: outbound.via, result: payload });
|
||||
}
|
||||
errors[outbound.via] = payload;
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "触发 forcesave 失败", detail: { forcesave: j, command: j2 } },
|
||||
{ error: "触发 forcesave 失败", detail: errors },
|
||||
{ status: 502 },
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "DocumentBridgeError") {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
console.error("[onlyoffice/forcesave] failed:", error);
|
||||
return NextResponse.json({ error: "触发 forcesave 失败" }, { status: 502 });
|
||||
}
|
||||
|
||||
@@ -1,236 +1,66 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { documentBridgeErrorResponse } from "@/lib/documents/bridge";
|
||||
import { prepareOnlyOfficeProxyRequest } from "@/lib/onlyoffice/rust-adapter";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { isIP } from "node:net";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const isLocalHostname = (hostname: string) =>
|
||||
hostname === "127.0.0.1" || hostname === "localhost" || hostname === "host.docker.internal";
|
||||
|
||||
const isPrivateIpv4 = (hostname: string) => {
|
||||
if (isIP(hostname) !== 4) return false;
|
||||
const parts = hostname.split(".").map((v) => Number(v));
|
||||
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return true;
|
||||
const [a, b] = parts;
|
||||
// 10.0.0.0/8
|
||||
if (a === 10) return true;
|
||||
// 127.0.0.0/8
|
||||
if (a === 127) return true;
|
||||
// 169.254.0.0/16
|
||||
if (a === 169 && b === 254) return true;
|
||||
// 172.16.0.0/12
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
// 192.168.0.0/16
|
||||
if (a === 192 && b === 168) return true;
|
||||
// 0.0.0.0/8
|
||||
if (a === 0) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const tryParseOriginHost = (raw?: string) => {
|
||||
const value = String(raw || "").trim();
|
||||
if (!value) return null;
|
||||
try {
|
||||
if (/^https?:\/\//i.test(value)) {
|
||||
const u = new URL(value);
|
||||
return { hostname: u.hostname, port: u.port || "" };
|
||||
}
|
||||
return { hostname: value, port: "" };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const decodeBase64UrlToUtf8 = (input: string) => {
|
||||
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padLen = (4 - (normalized.length % 4)) % 4;
|
||||
const padded = normalized + "=".repeat(padLen);
|
||||
return Buffer.from(padded, "base64").toString("utf8");
|
||||
};
|
||||
|
||||
const tryParseOriginUrl = (raw?: string) => {
|
||||
const value = String(raw || "").trim();
|
||||
if (!value) return null;
|
||||
try {
|
||||
if (/^https?:\/\//i.test(value)) return new URL(value);
|
||||
// 说明:仅给 host:port 的写法一个默认协议(http)
|
||||
return new URL(`http://${value}`);
|
||||
} catch {
|
||||
return null;
|
||||
const toHeaders = (items: Array<{ name: string; value: string }>) => {
|
||||
const headers = new Headers();
|
||||
for (const item of items) {
|
||||
if (!item.name || !item.value) continue;
|
||||
headers.set(item.name, item.value);
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
const handle = async (request: Request, method: "GET" | "HEAD") => {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const encoded = searchParams.get("u");
|
||||
|
||||
if (!encoded) {
|
||||
return NextResponse.json({ error: "缺少 u" }, { status: 400 });
|
||||
}
|
||||
|
||||
let targetUrl: string;
|
||||
try {
|
||||
targetUrl = decodeBase64UrlToUtf8(encoded);
|
||||
|
||||
new URL(targetUrl);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "u 不是有效的 base64url URL" }, { status: 400 });
|
||||
}
|
||||
|
||||
const runtimeCfg = getMnoteRuntimeConfig();
|
||||
let target: URL;
|
||||
try {
|
||||
target = new URL(targetUrl);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "u 不是有效的 URL" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
||||
return NextResponse.json({ error: "仅支持 http/https URL" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 安全:避免把该接口变成通用 SSRF 代理。
|
||||
// 当前仅用于代理 Supabase Storage 的签名 URL(以及可能的 storage 回源 host override)。
|
||||
const supa = tryParseOriginHost(runtimeCfg.supabaseUrl);
|
||||
const supaInternalOrigin = tryParseOriginUrl(
|
||||
runtimeCfg.supabaseInternalUrl || process.env.SUPABASE_INTERNAL_URL,
|
||||
);
|
||||
const storageOverride = tryParseOriginHost(runtimeCfg.onlyofficeStorageHostOverride);
|
||||
const convexOrigin = tryParseOriginUrl(process.env.CONVEX_SELF_HOSTED_URL ?? process.env.NEXT_PUBLIC_CONVEX_URL);
|
||||
|
||||
const isSupabasePath =
|
||||
target.pathname.startsWith("/storage/v1/") ||
|
||||
target.pathname.startsWith("/auth/v1/") ||
|
||||
target.pathname.startsWith("/rest/v1/") ||
|
||||
target.pathname.startsWith("/functions/v1/") ||
|
||||
target.pathname.startsWith("/realtime/v1/");
|
||||
|
||||
// 说明:历史数据里可能残留旧的 Supabase 域名(例如 supabase.aichem.dpdns.org)。
|
||||
// 为了保证 ONLYOFFICE 拉取文件稳定,这里统一把 Supabase 典型路径的 host 改为当前配置的 Supabase,
|
||||
// 并优先使用“内部 HTTP”回源,避免 Node fetch 因自签证书失败。
|
||||
if (isSupabasePath) {
|
||||
if (supaInternalOrigin) {
|
||||
target.protocol = supaInternalOrigin.protocol;
|
||||
target.host = supaInternalOrigin.host;
|
||||
targetUrl = target.toString();
|
||||
} else {
|
||||
const supaPublicOrigin = tryParseOriginUrl(runtimeCfg.supabaseUrl);
|
||||
if (supaPublicOrigin) {
|
||||
target.protocol = supaPublicOrigin.protocol;
|
||||
target.host = supaPublicOrigin.host;
|
||||
targetUrl = target.toString();
|
||||
}
|
||||
const { searchParams } = new URL(request.url);
|
||||
const encodedUrl = String(searchParams.get("u") || "").trim();
|
||||
if (!encodedUrl) {
|
||||
return NextResponse.json({ error: "缺少 u" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const allowedHostnames = new Set<string>();
|
||||
const allowedPortsByHostname = new Map<string, Set<string>>();
|
||||
const prepared = await prepareOnlyOfficeProxyRequest({
|
||||
request,
|
||||
encodedUrl,
|
||||
method,
|
||||
range: request.headers.get("range"),
|
||||
runtimeConfig: getMnoteRuntimeConfig(),
|
||||
});
|
||||
|
||||
const addAllowed = (hostname: string, port: string) => {
|
||||
allowedHostnames.add(hostname);
|
||||
if (!allowedPortsByHostname.has(hostname)) allowedPortsByHostname.set(hostname, new Set<string>());
|
||||
if (port) allowedPortsByHostname.get(hostname)!.add(port);
|
||||
};
|
||||
const upstream = await fetch(prepared.targetUrl, {
|
||||
method,
|
||||
headers: toHeaders(prepared.forwardHeaders),
|
||||
redirect: "follow",
|
||||
});
|
||||
|
||||
if (supa?.hostname) {
|
||||
addAllowed(supa.hostname, supa.port);
|
||||
// 如果 Supabase 配置就是本地/内网回源(例如 host.docker.internal/127.0.0.1),则允许这些 hostname,
|
||||
// 但仍然限制端口只能是 Supabase URL 的端口,避免误用。
|
||||
if (isLocalHostname(supa.hostname)) {
|
||||
addAllowed("127.0.0.1", supa.port);
|
||||
addAllowed("localhost", supa.port);
|
||||
addAllowed("host.docker.internal", supa.port);
|
||||
const headers = new Headers(upstream.headers);
|
||||
headers.delete("set-cookie");
|
||||
|
||||
if (method === "HEAD") {
|
||||
return new Response(null, {
|
||||
status: upstream.status,
|
||||
statusText: upstream.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (supaInternalOrigin?.hostname) {
|
||||
addAllowed(supaInternalOrigin.hostname, supaInternalOrigin.port || "");
|
||||
if (isLocalHostname(supaInternalOrigin.hostname)) {
|
||||
addAllowed("127.0.0.1", supaInternalOrigin.port || "");
|
||||
addAllowed("localhost", supaInternalOrigin.port || "");
|
||||
addAllowed("host.docker.internal", supaInternalOrigin.port || "");
|
||||
}
|
||||
}
|
||||
|
||||
if (storageOverride?.hostname) {
|
||||
addAllowed(storageOverride.hostname, storageOverride.port);
|
||||
}
|
||||
|
||||
if (convexOrigin?.hostname) {
|
||||
addAllowed(convexOrigin.hostname, convexOrigin.port || "");
|
||||
if (isLocalHostname(convexOrigin.hostname)) {
|
||||
addAllowed("127.0.0.1", convexOrigin.port || "");
|
||||
addAllowed("localhost", convexOrigin.port || "");
|
||||
addAllowed("host.docker.internal", convexOrigin.port || "");
|
||||
}
|
||||
}
|
||||
|
||||
// 说明:默认禁止代理到内网/私有地址;但如果该 hostname 被显式配置为允许(例如 Docker bridge/host 回源),则放行。
|
||||
if (isPrivateIpv4(target.hostname) && !isLocalHostname(target.hostname) && !allowedHostnames.has(target.hostname)) {
|
||||
return NextResponse.json({ error: "禁止访问内网/私有地址" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (allowedHostnames.size > 0 && !allowedHostnames.has(target.hostname)) {
|
||||
return NextResponse.json({ error: "禁止代理到非允许的主机" }, { status: 403 });
|
||||
}
|
||||
|
||||
const allowedPorts = allowedPortsByHostname.get(target.hostname);
|
||||
if (allowedPorts && allowedPorts.size > 0) {
|
||||
const port = target.port || (target.protocol === "https:" ? "443" : "80");
|
||||
if (!allowedPorts.has(port)) {
|
||||
return NextResponse.json({ error: "禁止代理到该端口" }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
// 说明:
|
||||
// - Supabase Storage 的 signedUrl 会携带 `?token=...`,而 ONLYOFFICE 会把它当作
|
||||
// 自己的 JWT token 参数去校验,导致报 “文档安全令牌的格式不正确/invalid signature”。
|
||||
// - 这里用反向代理把 signedUrl 藏在 `u=...` 里(参数名不叫 token),ONLYOFFICE 拉取的
|
||||
// URL 就不会出现 `token` 参数,从而避免冲突。
|
||||
const forwardHeaders = new Headers();
|
||||
const range = request.headers.get("range");
|
||||
if (range) forwardHeaders.set("range", range);
|
||||
|
||||
// 兼容部分 Supabase/Kong 配置:某些环境下下载 Storage 资源仍要求 apikey header。
|
||||
if (
|
||||
runtimeCfg.supabaseAnonKey &&
|
||||
((supa?.hostname && target.hostname === supa.hostname) ||
|
||||
(supaInternalOrigin?.hostname && target.hostname === supaInternalOrigin.hostname))
|
||||
) {
|
||||
forwardHeaders.set("apikey", runtimeCfg.supabaseAnonKey);
|
||||
}
|
||||
|
||||
const upstream = await fetch(targetUrl, {
|
||||
method,
|
||||
headers: forwardHeaders,
|
||||
redirect: "follow",
|
||||
});
|
||||
|
||||
const headers = new Headers(upstream.headers);
|
||||
// 避免把上游 cookie 透传给文档服务器
|
||||
headers.delete("set-cookie");
|
||||
|
||||
// 说明:ONLYOFFICE 可能会对 document.url 发起 HEAD 预检(获取 content-length/etag)。
|
||||
// 若不支持 HEAD,会导致编辑器报 “下载失败”。
|
||||
if (method === "HEAD") {
|
||||
return new Response(null, {
|
||||
return new Response(upstream.body, {
|
||||
status: upstream.status,
|
||||
statusText: upstream.statusText,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
|
||||
return new Response(upstream.body, {
|
||||
status: upstream.status,
|
||||
statusText: upstream.statusText,
|
||||
headers,
|
||||
});
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return handle(request, "GET");
|
||||
return await handle(request, "GET");
|
||||
}
|
||||
|
||||
export async function HEAD(request: Request) {
|
||||
return handle(request, "HEAD");
|
||||
return await handle(request, "HEAD");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockSignOnlyOfficeConfig = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api-utils", () => ({
|
||||
apiErrorResponse: vi.fn((message: string, status = 500, details?: unknown) => ({
|
||||
message,
|
||||
status,
|
||||
details,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/onlyoffice/rust-adapter", () => ({
|
||||
signOnlyOfficeConfig: mockSignOnlyOfficeConfig,
|
||||
}));
|
||||
|
||||
describe("/api/onlyoffice/sign route", () => {
|
||||
beforeEach(() => {
|
||||
mockSignOnlyOfficeConfig.mockReset();
|
||||
});
|
||||
|
||||
it("delegates signing to rust adapter", async () => {
|
||||
const { POST } = await import("./route");
|
||||
mockSignOnlyOfficeConfig.mockResolvedValue({
|
||||
token: "token_all",
|
||||
documentToken: "token_doc",
|
||||
editorConfigToken: "token_editor",
|
||||
});
|
||||
|
||||
const response = await POST(
|
||||
new Request("http://127.0.0.1:3001/api/onlyoffice/sign", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
config: {
|
||||
document: { title: "A" },
|
||||
editorConfig: { mode: "edit" },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload).toEqual({
|
||||
token: "token_all",
|
||||
documentToken: "token_doc",
|
||||
editorConfigToken: "token_editor",
|
||||
});
|
||||
expect(mockSignOnlyOfficeConfig).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,52 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import crypto from "crypto";
|
||||
import { signOnlyOfficeConfig } from "@/lib/onlyoffice/rust-adapter";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const base64Url = (input: Buffer | string) =>
|
||||
Buffer.from(input)
|
||||
.toString("base64")
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
|
||||
const signHs256 = (payload: unknown, secret: string) => {
|
||||
const header = { alg: "HS256", typ: "JWT" };
|
||||
const headerPart = base64Url(JSON.stringify(header));
|
||||
const payloadPart = base64Url(JSON.stringify(payload));
|
||||
const signingInput = `${headerPart}.${payloadPart}`;
|
||||
const signature = crypto.createHmac("sha256", secret).update(signingInput).digest();
|
||||
return `${signingInput}.${base64Url(signature)}`;
|
||||
};
|
||||
|
||||
const normalizeSecret = (raw: string) => {
|
||||
const trimmed = String(raw || "").trim();
|
||||
if (!trimmed) return "";
|
||||
// 兼容用户把 .env 的值写成 "xxx" / 'xxx'
|
||||
if (
|
||||
(trimmed.startsWith("\"") && trimmed.endsWith("\"") && trimmed.length >= 2) ||
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2)
|
||||
) {
|
||||
return trimmed.slice(1, -1).trim();
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const secret = normalizeSecret(process.env.ONLYOFFICE_JWT_SECRET || "");
|
||||
const body = (await request.json().catch(() => null)) as { config?: any } | null;
|
||||
if (!body?.config) {
|
||||
return NextResponse.json({ error: "缺少 config" }, { status: 400 });
|
||||
}
|
||||
if (!secret) {
|
||||
// 兼容:如果 ONLYOFFICE 没开启 JWT,可以不需要 token。
|
||||
return NextResponse.json({ token: null, documentToken: null, editorConfigToken: null });
|
||||
}
|
||||
try {
|
||||
const body = (await request.json().catch(() => null)) as { config?: Record<string, unknown> } | null;
|
||||
if (!body?.config) {
|
||||
return NextResponse.json({ error: "缺少 config" }, { status: 400 });
|
||||
}
|
||||
|
||||
const config = body.config;
|
||||
const token = signHs256(config, secret);
|
||||
const documentToken = config?.document ? signHs256(config.document, secret) : null;
|
||||
const editorConfigToken = config?.editorConfig ? signHs256(config.editorConfig, secret) : null;
|
||||
const result = await signOnlyOfficeConfig({
|
||||
request,
|
||||
config: body.config,
|
||||
secret: process.env.ONLYOFFICE_JWT_SECRET || "",
|
||||
});
|
||||
|
||||
return NextResponse.json({ token, documentToken, editorConfigToken });
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "OnlyOffice 签名失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,84 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { HttpError } from "@/lib/auth/authContext";
|
||||
import type {
|
||||
DocumentSearchFilters,
|
||||
DocumentSearchRequest,
|
||||
DocumentSearchResponse,
|
||||
DocumentSearchResult,
|
||||
DocumentSearchTimeRange,
|
||||
} from "@/types/search";
|
||||
import { buildSnippet } from "@/lib/search/snippet";
|
||||
import { documentBridgeErrorResponse } from "@/lib/documents/bridge";
|
||||
import { executeDocumentSearchQuery } from "@/lib/search/search-query-adapter";
|
||||
import type { DocumentSearchRequest } from "@/types/search";
|
||||
|
||||
const MAX_LIMIT = 50;
|
||||
|
||||
const DEFAULT_FILTERS: DocumentSearchFilters = {
|
||||
titleOnly: false,
|
||||
exact: false,
|
||||
onlyCurrentPage: false,
|
||||
includeOcr: false,
|
||||
timeRange: "any",
|
||||
timeField: "updated",
|
||||
};
|
||||
|
||||
const TIME_RANGE_TO_MS: Record<DocumentSearchTimeRange, number | null> = {
|
||||
any: null,
|
||||
"7d": 1000 * 60 * 60 * 24 * 7,
|
||||
"30d": 1000 * 60 * 60 * 24 * 30,
|
||||
};
|
||||
|
||||
const TIME_FIELD_COLUMN = {
|
||||
updated: "updated_at",
|
||||
created: "created_at",
|
||||
} as const;
|
||||
|
||||
type RangeIso = { fromIso: string | null; toIso: string | null };
|
||||
|
||||
function parseDateToIso(value: string | undefined): string | null {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return null;
|
||||
const d = new Date(raw);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
function resolveCustomRangeIso(filters: DocumentSearchFilters): RangeIso {
|
||||
const fromIso = parseDateToIso(filters.customRange?.from);
|
||||
const toIso = parseDateToIso(filters.customRange?.to);
|
||||
return { fromIso, toIso };
|
||||
}
|
||||
|
||||
type MindmapNode = { data?: { text?: unknown }; children?: unknown[] };
|
||||
|
||||
function extractTextFromMindmapData(data: unknown, maxChars = 60000): string {
|
||||
const out: string[] = [];
|
||||
const pushText = (value: unknown) => {
|
||||
const s = typeof value === "string" ? value : null;
|
||||
if (!s) return;
|
||||
const trimmed = s.replace(/\s+/g, " ").trim();
|
||||
if (!trimmed) return;
|
||||
out.push(trimmed);
|
||||
};
|
||||
|
||||
const walk = (node: unknown) => {
|
||||
if (out.join("\n").length >= maxChars) return;
|
||||
if (!node || typeof node !== "object") return;
|
||||
const n = node as MindmapNode;
|
||||
pushText(n.data?.text);
|
||||
if (Array.isArray(n.children)) {
|
||||
for (const c of n.children) {
|
||||
walk(c);
|
||||
if (out.join("\n").length >= maxChars) return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(data);
|
||||
const joined = out.join("\n").trim();
|
||||
return joined.length > maxChars ? `${joined.slice(0, maxChars)}…` : joined;
|
||||
}
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
@@ -86,337 +13,23 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const payload = (await request.json()) as DocumentSearchRequest;
|
||||
const workspaceId = payload.workspaceId;
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const filters: DocumentSearchFilters = {
|
||||
...DEFAULT_FILTERS,
|
||||
...payload.filters,
|
||||
};
|
||||
|
||||
const limit = Math.min(payload.limit ?? 30, MAX_LIMIT);
|
||||
const normalizedQuery = payload.query?.trim() ?? "";
|
||||
const normalizedLower = normalizedQuery.toLowerCase();
|
||||
|
||||
const docs = await client.query(api.documents.listSearchDataByWorkspace, { workspaceId });
|
||||
const docMap = new Map(docs.map((d) => [d.id, d]));
|
||||
|
||||
const recentRows = await client.query(api.recents.listByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
limit: 10,
|
||||
});
|
||||
const recent: DocumentSearchResult[] = recentRows
|
||||
.map((r) => docMap.get(r.document_id))
|
||||
.filter((row): row is (typeof docs)[number] => Boolean(row))
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
title: row.title ?? "无标题",
|
||||
snippet: "",
|
||||
updatedAt: row.updated_at ?? null,
|
||||
createdAt: row.created_at ?? null,
|
||||
matchField: "recent",
|
||||
hasOcr: false,
|
||||
publicPath: `/documents/${row.id}`,
|
||||
score: 0,
|
||||
}));
|
||||
|
||||
if (!normalizedQuery) {
|
||||
const response: DocumentSearchResponse = { results: [], recent };
|
||||
return NextResponse.json(response);
|
||||
}
|
||||
|
||||
const timeRangeMs = TIME_RANGE_TO_MS[filters.timeRange];
|
||||
const timeField = TIME_FIELD_COLUMN[filters.timeField];
|
||||
const boundaryIso =
|
||||
typeof timeRangeMs === "number"
|
||||
? new Date(Date.now() - timeRangeMs).toISOString()
|
||||
: null;
|
||||
const { fromIso, toIso } = resolveCustomRangeIso(filters);
|
||||
|
||||
const eligibleDocs = [...docs]
|
||||
.sort((a, b) => {
|
||||
const ta = a.updated_at ?? a.created_at ?? "";
|
||||
const tb = b.updated_at ?? b.created_at ?? "";
|
||||
return tb.localeCompare(ta);
|
||||
})
|
||||
.filter((row) => {
|
||||
if (filters.onlyCurrentPage && payload.documentId) {
|
||||
if (row.id !== payload.documentId) return false;
|
||||
}
|
||||
|
||||
if (boundaryIso) {
|
||||
const ts = (row as any)?.[timeField] ?? null;
|
||||
if (!ts || typeof ts !== "string") return false;
|
||||
if (ts < boundaryIso) return false;
|
||||
}
|
||||
|
||||
if (fromIso || toIso) {
|
||||
const ts = (row as any)?.[timeField] ?? null;
|
||||
if (!ts || typeof ts !== "string") return false;
|
||||
if (fromIso && ts < fromIso) return false;
|
||||
if (toIso && ts > toIso) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const eligibleDocIds = new Set(eligibleDocs.map((d) => d.id));
|
||||
|
||||
type MatchInfo = {
|
||||
score: number;
|
||||
matchField: "title" | "content";
|
||||
snippet: string;
|
||||
hasOcr: boolean;
|
||||
};
|
||||
|
||||
const matches = new Map<string, MatchInfo>();
|
||||
|
||||
const upsertMatch = (docId: string, patch: Partial<MatchInfo>) => {
|
||||
const prev = matches.get(docId);
|
||||
if (!prev) {
|
||||
const score = typeof patch.score === "number" ? patch.score : 0;
|
||||
const matchField = patch.matchField ?? "content";
|
||||
const snippet = patch.snippet ?? "暂无正文内容";
|
||||
const hasOcr = Boolean(patch.hasOcr);
|
||||
matches.set(docId, { score, matchField, snippet, hasOcr });
|
||||
return;
|
||||
}
|
||||
|
||||
const next: MatchInfo = {
|
||||
score: typeof patch.score === "number" ? Math.max(prev.score, patch.score) : prev.score,
|
||||
matchField: patch.matchField ?? prev.matchField,
|
||||
snippet: patch.snippet ?? prev.snippet,
|
||||
hasOcr: prev.hasOcr || Boolean(patch.hasOcr),
|
||||
};
|
||||
|
||||
// 若现有是标题匹配,不用较低分覆盖文案。
|
||||
if (prev.matchField === "title" && next.matchField !== "title") {
|
||||
next.matchField = "title";
|
||||
next.snippet = prev.snippet;
|
||||
}
|
||||
|
||||
// 若新分数更高,则用新片段(除标题外)
|
||||
if (typeof patch.score === "number" && patch.score > prev.score && prev.matchField !== "title") {
|
||||
next.snippet = patch.snippet ?? next.snippet;
|
||||
next.matchField = patch.matchField ?? next.matchField;
|
||||
}
|
||||
|
||||
matches.set(docId, next);
|
||||
};
|
||||
|
||||
for (const row of eligibleDocs) {
|
||||
const title = (row.title ?? "无标题").trim() || "无标题";
|
||||
const titleLower = title.toLowerCase();
|
||||
const hitTitle = filters.exact ? titleLower === normalizedLower : titleLower.includes(normalizedLower);
|
||||
if (hitTitle) {
|
||||
upsertMatch(row.id, {
|
||||
score: 3,
|
||||
matchField: "title",
|
||||
snippet: buildSnippet(title, normalizedQuery),
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.titleOnly) continue;
|
||||
|
||||
const rawText = String(row.raw_text ?? "").trim();
|
||||
if (rawText) {
|
||||
const hitContent = rawText.toLowerCase().includes(normalizedLower);
|
||||
if (hitContent) {
|
||||
upsertMatch(row.id, {
|
||||
score: 2,
|
||||
matchField: "content",
|
||||
snippet: buildSnippet(rawText, normalizedQuery),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!filters.titleOnly) {
|
||||
// 思维导图(默认纳入“正文”搜索)
|
||||
const mindmaps = await client
|
||||
.query(api.mindmaps.listByWorkspace, { workspaceId, includeDeleted: false })
|
||||
.catch(() => []);
|
||||
for (const m of mindmaps) {
|
||||
const docId = String(m.document_id ?? "").trim();
|
||||
if (!docId || !eligibleDocIds.has(docId)) continue;
|
||||
if (!docMap.has(docId)) continue;
|
||||
|
||||
const text = extractTextFromMindmapData(m.data ?? null);
|
||||
if (!text) continue;
|
||||
if (!text.toLowerCase().includes(normalizedLower)) continue;
|
||||
|
||||
upsertMatch(docId, {
|
||||
score: 1.6,
|
||||
matchField: "content",
|
||||
snippet: buildSnippet(`思维导图:${text}`, normalizedQuery),
|
||||
});
|
||||
}
|
||||
|
||||
// Luckysheet 在线表格(标题 + 行内容)
|
||||
const [tables, tableRows] = await Promise.all([
|
||||
client
|
||||
.query(api.tables.listByWorkspaceForSearch, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
includeArchived: false,
|
||||
limit: 3000,
|
||||
})
|
||||
.catch(() => []),
|
||||
client
|
||||
.query(api.tables.listRowsByWorkspaceForSearch, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
limit: 8000,
|
||||
})
|
||||
.catch(() => []),
|
||||
]);
|
||||
|
||||
const tableTitleById = new Map<string, string>();
|
||||
for (const t of tables) {
|
||||
if (!eligibleDocIds.has(t.document_id)) continue;
|
||||
tableTitleById.set(t.id, String(t.title ?? "").trim() || "未命名表格");
|
||||
|
||||
const hitTableTitle = String(t.title ?? "").toLowerCase().includes(normalizedLower);
|
||||
if (hitTableTitle && docMap.has(t.document_id)) {
|
||||
upsertMatch(t.document_id, {
|
||||
score: 1.5,
|
||||
matchField: "content",
|
||||
snippet: buildSnippet(`表格:${t.title}`, normalizedQuery),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const r of tableRows) {
|
||||
const docId = String(r.document_id ?? "").trim();
|
||||
if (!docId || !eligibleDocIds.has(docId)) continue;
|
||||
if (!docMap.has(docId)) continue;
|
||||
const rowHash = String(r.row_hash ?? "").trim();
|
||||
if (!rowHash) continue;
|
||||
if (!rowHash.toLowerCase().includes(normalizedLower)) continue;
|
||||
|
||||
const tableTitle = tableTitleById.get(String(r.table_id ?? "").trim()) ?? "未命名表格";
|
||||
upsertMatch(docId, {
|
||||
score: 1.4,
|
||||
matchField: "content",
|
||||
snippet: buildSnippet(`表格:${tableTitle}\n${rowHash}`, normalizedQuery),
|
||||
});
|
||||
}
|
||||
|
||||
// 附件/图片:默认搜索文件名;勾选“搜索附件内容”后再纳入 OCR/解析文本。
|
||||
const assets = await client
|
||||
.query(api.mediaAssets.listSearchDataByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
includeDeleted: false,
|
||||
limit: 5000,
|
||||
})
|
||||
.catch(() => []);
|
||||
|
||||
const toEnqueueExtract: { id: string; mime: string | null; name: string | null; status: string | null; type: string | null }[] = [];
|
||||
|
||||
for (const a of assets) {
|
||||
const docId = String(a.document_id ?? "").trim();
|
||||
if (!docId || !eligibleDocIds.has(docId)) continue;
|
||||
if (!docMap.has(docId)) continue;
|
||||
|
||||
const fileName = String(a.file_name ?? "").trim();
|
||||
if (fileName && fileName.toLowerCase().includes(normalizedLower)) {
|
||||
upsertMatch(docId, {
|
||||
score: 1.2,
|
||||
matchField: "content",
|
||||
snippet: buildSnippet(`附件:${fileName}`, normalizedQuery),
|
||||
});
|
||||
}
|
||||
|
||||
if (!filters.includeOcr) continue;
|
||||
|
||||
// 说明:勾选“搜索附件内容”时,若附件尚未生成 ocr_text,则后台排队解析(避免用户长期搜不到)。
|
||||
const ocrTextValue = String(a.ocr_text ?? "").trim();
|
||||
if (!ocrTextValue) {
|
||||
const assetType = String((a as any).asset_type ?? "").trim() || null;
|
||||
const mimeType = String((a as any).mime_type ?? "").toLowerCase().trim() || null;
|
||||
const ocrStatus = String((a as any).ocr_status ?? "").trim() || null;
|
||||
const nameLower = String(a.file_name ?? "").toLowerCase().trim() || null;
|
||||
|
||||
const supported =
|
||||
assetType === "file" &&
|
||||
(mimeType === "application/pdf" ||
|
||||
mimeType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
|
||||
mimeType === "application/vnd.openxmlformats-officedocument.presentationml.presentation" ||
|
||||
mimeType === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ||
|
||||
(nameLower ? [".pdf", ".docx", ".pptx", ".xlsx"].some((ext) => nameLower.endsWith(ext)) : false));
|
||||
|
||||
const busy = ocrStatus === "queued" || ocrStatus === "running";
|
||||
if (supported && !busy) {
|
||||
toEnqueueExtract.push({ id: a.id, mime: mimeType, name: a.file_name ?? null, status: ocrStatus, type: assetType });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ocrTextValue.toLowerCase().includes(normalizedLower)) continue;
|
||||
|
||||
upsertMatch(docId, {
|
||||
score: 1.7,
|
||||
matchField: "content",
|
||||
snippet: buildSnippet(`附件:${fileName || a.id}\n${ocrTextValue}`, normalizedQuery),
|
||||
hasOcr: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (toEnqueueExtract.length > 0) {
|
||||
// 说明:限制每次搜索触发的解析数量,避免请求变慢;剩余附件可继续通过下一次搜索逐步排队。
|
||||
await Promise.all(
|
||||
toEnqueueExtract
|
||||
.slice(0, 3)
|
||||
.map((item) =>
|
||||
client.mutation(api.mediaAssets.enqueueExtractText, { userId: auth.userId, id: item.id }).catch(() => null),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const results: DocumentSearchResult[] = [];
|
||||
for (const [docId, match] of matches.entries()) {
|
||||
const row = docMap.get(docId);
|
||||
if (!row) continue;
|
||||
results.push({
|
||||
id: row.id,
|
||||
title: row.title ?? "无标题",
|
||||
snippet: match.snippet,
|
||||
updatedAt: row.updated_at ?? null,
|
||||
createdAt: row.created_at ?? null,
|
||||
matchField: match.matchField,
|
||||
hasOcr: match.hasOcr,
|
||||
publicPath: `/documents/${row.id}`,
|
||||
score: match.score,
|
||||
});
|
||||
}
|
||||
|
||||
results.sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
const ta = a.updatedAt ?? a.createdAt ?? "";
|
||||
const tb = b.updatedAt ?? b.createdAt ?? "";
|
||||
const byTime = tb.localeCompare(ta);
|
||||
if (byTime) return byTime;
|
||||
return String(a.title ?? "").localeCompare(String(b.title ?? ""));
|
||||
const { response, meta } = await executeDocumentSearchQuery({
|
||||
request,
|
||||
payload,
|
||||
});
|
||||
|
||||
const limitedResults = results.slice(0, limit);
|
||||
|
||||
const response: DocumentSearchResponse = { results: limitedResults, recent };
|
||||
return NextResponse.json(response);
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
return NextResponse.json({ error: err.message }, { status: err.status });
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
"x-request-id": meta.requestId,
|
||||
"x-trace-id": meta.traceId,
|
||||
"x-query-name": meta.queryName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
const message = err instanceof Error ? err.message : "搜索失败,请稍后再试";
|
||||
console.error("[search/documents] failed:", err);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomUUID } from "crypto";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import {
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentQueryEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
executeRustBridgeQueryTransport,
|
||||
resolveRustBridgeQueryPlan,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
import {
|
||||
buildSidebarDatasetListQueryPayload,
|
||||
mapSidebarDatasetListQueryResultToInitialData,
|
||||
type SidebarDatasetListQueryResult,
|
||||
} from "@/lib/sidebar-data";
|
||||
import { loadSidebarDataFromConvex } from "@/lib/server/sidebar-data";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -19,14 +26,11 @@ export async function GET(request: Request) {
|
||||
|
||||
const url = new URL(request.url);
|
||||
const workspaceIdParam = url.searchParams.get("workspaceId");
|
||||
const {
|
||||
targetWorkspaceId,
|
||||
sidebarInitialData,
|
||||
} = await loadSidebarDataFromConvex({
|
||||
client,
|
||||
const bootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||
fallbackName: auth.email ?? auth.name ?? "我的空间",
|
||||
requestedWorkspaceId: workspaceIdParam,
|
||||
workspaceIdIfCreate: randomUUID(),
|
||||
});
|
||||
const targetWorkspaceId = workspaceIdParam?.trim() || bootstrap.activeWorkspaceId || null;
|
||||
|
||||
if (!targetWorkspaceId) {
|
||||
return NextResponse.json({ error: "暂无可用工作空间" }, { status: 404 });
|
||||
@@ -41,12 +45,18 @@ export async function GET(request: Request) {
|
||||
name: "sidebar.dataset.list",
|
||||
payload: buildSidebarDatasetListQueryPayload(targetWorkspaceId),
|
||||
});
|
||||
if (!sidebarInitialData) {
|
||||
return NextResponse.json({ error: "暂无可用工作空间" }, { status: 404 });
|
||||
}
|
||||
const plan = await resolveRustBridgeQueryPlan({
|
||||
context: bridgeContext,
|
||||
envelope,
|
||||
});
|
||||
const sidebarDataset = await executeRustBridgeQueryTransport<SidebarDatasetListQueryResult>({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
const result = mapSidebarDatasetListQueryResultToInitialData(sidebarDataset);
|
||||
|
||||
return NextResponse.json({
|
||||
...sidebarInitialData,
|
||||
...result,
|
||||
meta: {
|
||||
requestId: bridgeContext.requestId,
|
||||
traceId: bridgeContext.traceId,
|
||||
|
||||
Reference in New Issue
Block a user