feat: 收口文档桥接与 OnlyOffice/Sidebar 回归

- 为 documents.save/meta/content、blocks.patch 与 sidebar.dataset.list 补齐 Rust 协议映射、共享契约与桥接执行器

- 对齐 BlockNote、Mindmap、OnlyOffice 的保存/路由元信息,并补真实浏览器回归脚本与 OnlyOffice 部署基线

- 忽略 Rust 本地构建产物与 Harness 调试状态文件,避免临时产物进入仓库历史
This commit is contained in:
lix-2026
2026-04-15 03:06:29 +08:00
parent 84a8454fa9
commit b33ffb99e7
51 changed files with 3260 additions and 379 deletions
@@ -44,6 +44,14 @@ export async function GET(request: Request) {
return NextResponse.json({
content: result.content ?? null,
revision:
typeof result.revision === "number" && Number.isInteger(result.revision)
? result.revision
: 0,
conflictDetectionKey:
typeof result.conflict_detection_key === "string" && result.conflict_detection_key.trim()
? result.conflict_detection_key
: `${documentId}:0`,
meta: {
requestId: bridgeContext.requestId,
traceId: bridgeContext.traceId,
@@ -1,58 +1,54 @@
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 { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
interface SavePayload {
documentId: string;
workspaceId?: string | null;
content: unknown;
}
import { executeSaveBridgeCommand } from "@/lib/documents/save-command-adapter";
import {
buildDocumentSavePayload,
type DocumentSavePayload,
} from "@/lib/documents/save-contract";
export async function POST(request: Request) {
if (isConvexEnabled()) {
try {
const { documentId, workspaceId, content }: SavePayload = await request.json();
const normalizedDocumentId = assertDocumentId(documentId);
const normalizedWorkspaceId = workspaceId?.trim() || null;
const body = await request.json() as Partial<DocumentSavePayload> & { content: unknown };
const normalizedDocumentId = assertDocumentId(body.documentId);
const payload = buildDocumentSavePayload({
documentId: normalizedDocumentId,
workspaceId: body.workspaceId,
revision: body.revision,
content: body.content as DocumentSavePayload["content"],
conflictDetectionKey: body.conflictDetectionKey,
});
const normalizedWorkspaceId = payload.workspaceId;
const bridgeContext = await buildDocumentBridgeContext({ request, workspaceId: normalizedWorkspaceId });
const envelope = buildDocumentCommandEnvelope({
name: "documents.save",
payload: {
documentId: normalizedDocumentId,
workspaceId: normalizedWorkspaceId,
content,
},
payload: payload satisfies DocumentSavePayload,
context: bridgeContext,
target: {
workspaceId: normalizedWorkspaceId,
pageId: normalizedDocumentId,
},
});
const { client } = await getAuthedConvexClient();
await client.mutation(api.documents.updateContent, {
id: normalizedDocumentId,
content: envelope.payload.content,
});
await recordBridgeCommandArtifacts({
const result = await executeSaveBridgeCommand({
context: bridgeContext,
envelope,
});
return NextResponse.json({
ok: true,
revision: result.revision,
conflictDetectionKey: result.conflictDetectionKey,
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) {
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import { buildMindmapRouteMeta } from "@/lib/mindmap/mindmapRouteMeta";
const defaultMindmapData = {
data: { text: "中心主题" },
@@ -9,15 +10,30 @@ const defaultMindmapData = {
};
export async function GET(
_req: Request,
request: Request,
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
) {
const { docId, mindmapId } = await params;
if (isConvexEnabled()) {
const { client } = await getAuthedConvexClient();
const { auth, client } = await getAuthedConvexClient();
const res = await client.query(api.mindmaps.get, { docId, mindmapId });
return NextResponse.json({ data: res?.data ?? defaultMindmapData, source: "convex" });
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,
},
});
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
@@ -30,7 +46,7 @@ export async function POST(
const { docId, mindmapId } = await params;
if (isConvexEnabled()) {
const { client } = await getAuthedConvexClient();
const { auth, client } = await getAuthedConvexClient();
const { data, createOnly } = (await request.json().catch(() => ({ data: null }))) as {
data?: unknown;
createOnly?: boolean;
@@ -43,7 +59,18 @@ export async function POST(
data: data ?? defaultMindmapData,
...(typeof createOnly === "boolean" ? { createOnly } : {}),
});
return NextResponse.json(result ?? { ok: true });
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 NextResponse.json({ error: (error as Error).message }, { status: 400 });
}
@@ -53,16 +80,27 @@ export async function POST(
}
export async function DELETE(
_req: Request,
request: Request,
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
) {
const { docId, mindmapId } = await params;
if (isConvexEnabled()) {
const { client } = await getAuthedConvexClient();
const { auth, client } = await getAuthedConvexClient();
try {
const result = await client.mutation(api.mindmaps.softDelete, { docId, mindmapId });
return NextResponse.json(result ?? { ok: true });
return NextResponse.json({
...(result ?? { ok: true }),
meta: {
...buildMindmapRouteMeta(request, {
workspaceId: result?.workspace_id ?? null,
documentId: docId,
mindmapId,
ownerUserId: auth.userId,
}),
deletedAt: result?.deleted_at ?? null,
},
});
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
}
@@ -78,7 +116,7 @@ export async function PATCH(
const { docId, mindmapId } = await params;
if (isConvexEnabled()) {
const { client } = await getAuthedConvexClient();
const { auth, client } = await getAuthedConvexClient();
const { action } = (await request.json().catch(() => ({}))) as { action?: string };
if (action !== "restore" && action !== "purge") {
return NextResponse.json({ error: "不支持的操作" }, { status: 400 });
@@ -87,10 +125,29 @@ export async function PATCH(
try {
if (action === "purge") {
const result = await client.mutation(api.mindmaps.purge, { docId, mindmapId });
return NextResponse.json(result ?? { ok: true });
return NextResponse.json({
...(result ?? { ok: true }),
meta: buildMindmapRouteMeta(request, {
workspaceId: result?.workspace_id ?? null,
documentId: docId,
mindmapId,
ownerUserId: auth.userId,
}),
});
}
const result = await client.mutation(api.mindmaps.restore, { docId, mindmapId });
return NextResponse.json(result ?? { ok: true });
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) {
const msg = (error as Error).message ?? "操作失败";
const status = msg.includes("未找到") ? 404 : 400;
@@ -100,4 +157,3 @@ export async function PATCH(
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import { buildMindmapRouteMeta } from "@/lib/mindmap/mindmapRouteMeta";
const defaultMindmapData = {
data: { text: "中心主题" },
@@ -9,16 +10,31 @@ const defaultMindmapData = {
};
export async function GET(
_req: Request,
request: Request,
{ params }: { params: Promise<{ docId: string }> },
) {
const { docId } = await params;
if (isConvexEnabled()) {
const { client } = await getAuthedConvexClient();
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" });
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,
},
});
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
@@ -31,7 +47,7 @@ export async function POST(
const { docId } = await params;
if (isConvexEnabled()) {
const { client } = await getAuthedConvexClient();
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, {
@@ -39,25 +55,46 @@ export async function POST(
mindmapId,
data: payload.data ?? defaultMindmapData,
});
return NextResponse.json(result ?? { ok: true });
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,
},
});
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
export async function DELETE(
_req: Request,
request: Request,
{ params }: { params: Promise<{ docId: string }> },
) {
const { docId } = await params;
if (isConvexEnabled()) {
const { client } = await getAuthedConvexClient();
const { auth, client } = await getAuthedConvexClient();
const mindmapId = `legacy-${docId}`;
const result = await client.mutation(api.mindmaps.softDelete, { docId, mindmapId });
return NextResponse.json(result ?? { ok: true });
return NextResponse.json({
...(result ?? { ok: true }),
meta: {
...buildMindmapRouteMeta(request, {
workspaceId: result?.workspace_id ?? null,
documentId: docId,
mindmapId,
ownerUserId: auth.userId,
}),
deletedAt: result?.deleted_at ?? null,
},
});
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
@@ -2,10 +2,10 @@ import { NextResponse } from "next/server";
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";
export const dynamic = "force-dynamic";
const ONLYOFFICE_INTERNAL_URL = (process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, "");
const ONLYOFFICE_CALLBACK_SECRET = String(process.env.ONLYOFFICE_CALLBACK_SECRET || "").trim();
type OnlyOfficeCallbackBody = {
@@ -27,7 +27,7 @@ const normalizeSecret = (raw: string) => {
return trimmed;
};
const tryRewriteOnlyOfficeDownloadUrl = (raw: string) => {
const tryRewriteOnlyOfficeDownloadUrl = (raw: string, onlyofficeInternalUrl: string) => {
try {
const u = new URL(raw);
@@ -36,7 +36,7 @@ const tryRewriteOnlyOfficeDownloadUrl = (raw: string) => {
const prefix = "/onlyoffice-server";
if (u.pathname.startsWith(prefix)) {
const nextPath = u.pathname.slice(prefix.length).replace(/^\/+/, "");
return `${ONLYOFFICE_INTERNAL_URL}/${nextPath}${u.search}`;
return `${onlyofficeInternalUrl}/${nextPath}${u.search}`;
}
return raw;
@@ -46,6 +46,7 @@ const tryRewriteOnlyOfficeDownloadUrl = (raw: string) => {
};
export async function POST(request: Request) {
const onlyofficeInternalUrl = await resolveOnlyOfficeInternalUrl();
const { searchParams } = new URL(request.url);
const assetId = searchParams.get("assetId") || "";
@@ -107,7 +108,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 1 });
}
const downloadUrl = tryRewriteOnlyOfficeDownloadUrl(body.url);
const downloadUrl = tryRewriteOnlyOfficeDownloadUrl(body.url, onlyofficeInternalUrl);
const upstream = await fetch(downloadUrl, { method: "GET", redirect: "follow" });
if (!upstream.ok) {
return NextResponse.json({ error: 1 });
@@ -3,11 +3,10 @@ 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";
export const dynamic = "force-dynamic";
const ONLYOFFICE_INTERNAL_URL = (process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, "");
const base64Url = (input: Buffer | string) =>
Buffer.from(input)
.toString("base64")
@@ -38,6 +37,7 @@ const normalizeSecret = (raw: string) => {
};
export async function POST(request: Request) {
const onlyofficeInternalUrl = await resolveOnlyOfficeInternalUrl();
let auth;
let client;
try {
@@ -82,7 +82,7 @@ export async function POST(request: Request) {
// 优先按文档推荐:使用 /command + token
if (secret) {
const token = signHs256(payload, secret);
const r = await fetch(`${ONLYOFFICE_INTERNAL_URL}/command`, {
const r = await fetch(`${onlyofficeInternalUrl}/command`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
@@ -93,7 +93,7 @@ export async function POST(request: Request) {
}
// 兜底:部分环境可能暴露 /forcesave 直连接口
const r2 = await fetch(`${ONLYOFFICE_INTERNAL_URL}/forcesave`, {
const r2 = await fetch(`${onlyofficeInternalUrl}/forcesave`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@@ -110,7 +110,7 @@ export async function POST(request: Request) {
}
// JWT 未启用:尝试 /forcesave 直连
const r = await fetch(`${ONLYOFFICE_INTERNAL_URL}/forcesave`, {
const r = await fetch(`${onlyofficeInternalUrl}/forcesave`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@@ -121,7 +121,7 @@ export async function POST(request: Request) {
}
// 最后兜底:部分部署可能仍接受不带 token 的 /command(不保证)
const r2 = await fetch(`${ONLYOFFICE_INTERNAL_URL}/command`, {
const r2 = await fetch(`${onlyofficeInternalUrl}/command`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@@ -140,4 +140,3 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "触发 forcesave 失败" }, { status: 502 });
}
}
+4 -2
View File
@@ -6,6 +6,9 @@ import {
buildDocumentQueryEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
buildSidebarDatasetListQueryPayload,
} from "@/lib/sidebar-data";
import { loadSidebarDataFromConvex } from "@/lib/server/sidebar-data";
export const dynamic = "force-dynamic";
@@ -21,7 +24,6 @@ export async function GET(request: Request) {
sidebarInitialData,
} = await loadSidebarDataFromConvex({
client,
userId: auth.userId,
fallbackName: auth.email ?? auth.name ?? "我的空间",
requestedWorkspaceId: workspaceIdParam,
});
@@ -37,7 +39,7 @@ export async function GET(request: Request) {
});
const envelope = buildDocumentQueryEnvelope({
name: "sidebar.dataset.list",
payload: { workspaceId: targetWorkspaceId },
payload: buildSidebarDatasetListQueryPayload(targetWorkspaceId),
});
if (!sidebarInitialData) {
return NextResponse.json({ error: "暂无可用工作空间" }, { status: 404 });