0.2.1 onlyoffice修复

This commit is contained in:
liaibo
2026-01-17 10:12:53 +08:00
parent 94957dc361
commit 19907bccdc
102 changed files with 7188 additions and 186 deletions
@@ -1,10 +1,46 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import type { MediaAsset } from "@/types/media";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { api } from "@/lib/convex/api";
import { getConvexHttpClient } from "@/lib/convex/server";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
if (isConvexEnabled()) {
let auth;
try {
auth = requireAuthContext();
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: "未登录" }, { status: err.status });
}
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const workspaceId = searchParams.get("workspaceId");
const limit = Number.parseInt(searchParams.get("limit") ?? "12", 10);
const assetType = searchParams.get("assetType") ?? undefined;
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const client = getConvexHttpClient();
const items = await client.query(api.mediaAssets.listByWorkspace, {
userId: auth.userId,
workspaceId,
assetType,
includeDeleted: false,
limit: Number.isNaN(limit) ? 12 : limit,
});
return NextResponse.json({ items: (items ?? []) as MediaAsset[] });
}
const supabase = await createSupabaseRouteClient();
const {
data: { user },
@@ -46,6 +82,75 @@ export async function GET(request: Request) {
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
let auth;
try {
auth = requireAuthContext();
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: "未登录" }, { status: err.status });
}
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const payload = (await request.json()) as {
workspaceId: string;
documentId: string;
fileUrl: string;
thumbnailUrl?: string;
assetType?: string;
fileName?: string;
fileSize?: number;
mimeType?: string;
};
if (!payload.workspaceId || !payload.documentId || !payload.fileUrl) {
return NextResponse.json({ error: "参数不完整" }, { status: 400 });
}
const assetId =
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
const now = new Date().toISOString();
const asset: MediaAsset = {
id: assetId,
workspace_id: payload.workspaceId,
document_id: payload.documentId,
file_url: payload.fileUrl,
thumbnail_url: payload.thumbnailUrl ?? payload.fileUrl,
asset_type: payload.assetType ?? "image",
file_name: payload.fileName ?? null,
file_size: payload.fileSize ?? null,
mime_type: payload.mimeType ?? null,
ocr_text: null,
ocr_status: null,
created_at: now,
updated_at: now,
};
const client = getConvexHttpClient();
await client.mutation(api.mediaAssets.create, {
userId: auth.userId,
asset: {
id: asset.id,
workspace_id: asset.workspace_id,
document_id: asset.document_id,
asset_type: asset.asset_type,
file_url: asset.file_url,
thumbnail_url: asset.thumbnail_url,
bucket: null,
storage_path: null,
file_name: asset.file_name,
file_size: asset.file_size,
mime_type: asset.mime_type,
},
});
return NextResponse.json({ asset });
}
const supabase = await createSupabaseRouteClient();
const {
data: { user },
+149 -1
View File
@@ -1,9 +1,12 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { extname } from "path";
import { makeUniqueFileName } from "@/lib/file-tree/naming";
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { api } from "@/lib/convex/api";
import { getConvexHttpClient } from "@/lib/convex/server";
export const dynamic = "force-dynamic";
@@ -65,6 +68,151 @@ function sanitizeSubPath(input: string | undefined): string {
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
let auth;
try {
auth = requireAuthContext();
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: "未登录" }, { status: err.status });
}
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const payload = (await request.json()) as BatchPayload;
if (!payload?.action || !payload.assetIds?.length) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
}
const client = getConvexHttpClient();
const assets = (await client.query(api.mediaAssets.listByIds, {
userId: auth.userId,
ids: payload.assetIds,
})) as any[];
if (!assets?.length) {
return NextResponse.json({ error: "未找到附件" }, { status: 404 });
}
const nowIso = () => new Date().toISOString();
try {
switch (payload.action) {
case "delete": {
for (const a of assets) {
await client.mutation(api.mediaAssets.patchById, {
userId: auth.userId,
id: String(a.id),
patch: { deleted_at: nowIso(), deleted_by: auth.userId },
});
}
return NextResponse.json({ ok: true });
}
case "restore": {
for (const a of assets) {
await client.mutation(api.mediaAssets.patchById, {
userId: auth.userId,
id: String(a.id),
patch: { deleted_at: null, deleted_by: null, purged_at: null },
});
}
return NextResponse.json({ ok: true });
}
case "rename": {
if (payload.assetIds.length !== 1 || !payload.newName) {
return NextResponse.json({ error: "重命名需要单个文件与新名称" }, { status: 400 });
}
const asset = assets[0];
const currentName = String(asset.file_name ?? "");
const ext = currentName.includes(".") ? `.${currentName.split(".").pop()}` : "";
const newFileName = payload.newName.includes(".") || !ext ? payload.newName : `${payload.newName}${ext}`;
const safeName = newFileName.replace(/[\\/]/g, "_");
await client.mutation(api.mediaAssets.patchById, {
userId: auth.userId,
id: String(asset.id),
patch: { file_name: safeName },
});
return NextResponse.json({ ok: true });
}
case "copy":
case "move": {
if (!payload.targetDocumentId) {
return NextResponse.json({ error: "缺少目标页面" }, { status: 400 });
}
const targetDoc = await client.query(api.documents.getMeta, {
userId: auth.userId,
id: payload.targetDocumentId,
});
if (!targetDoc) {
return NextResponse.json({ error: "目标页面不存在" }, { status: 404 });
}
const existing = (await client.query(api.mediaAssets.listByDocument, {
userId: auth.userId,
documentId: payload.targetDocumentId,
limit: 500,
})) as any[];
const existingNames = new Set<string>(
(existing ?? []).map((r) => (r?.file_name ?? "").toString()).filter(Boolean),
);
const results: any[] = [];
for (const asset of assets) {
const fileName = makeUniqueFileName(asset.file_name ?? "附件", existingNames).replace(/[\\/]/g, "_");
const storageId = (asset as { storage_id?: string | null })?.storage_id ?? null;
if (!storageId) continue;
if (payload.action === "copy") {
const newId =
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
const created = await client.mutation(api.mediaAssets.createWithStorage, {
userId: auth.userId,
storageId: storageId as any,
asset: {
id: newId,
workspace_id: String(targetDoc.workspace_id),
document_id: String(payload.targetDocumentId),
asset_type: String(asset.asset_type ?? "file"),
file_name: fileName,
file_size: typeof asset.file_size === "number" ? asset.file_size : null,
mime_type: (asset.mime_type ?? null) as any,
},
});
results.push(created);
} else {
await client.mutation(api.mediaAssets.patchById, {
userId: auth.userId,
id: String(asset.id),
patch: {
workspace_id: String(targetDoc.workspace_id),
document_id: String(payload.targetDocumentId),
file_name: fileName,
},
});
results.push({ ...asset, workspace_id: String(targetDoc.workspace_id), document_id: String(payload.targetDocumentId), file_name: fileName });
}
}
return NextResponse.json({ items: results });
}
default:
return NextResponse.json({ error: "不支持的操作" }, { status: 400 });
}
} catch (err) {
const message = err instanceof Error ? err.message : "操作失败";
return NextResponse.json({ error: message }, { status: 500 });
}
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -1,5 +1,9 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { api } from "@/lib/convex/api";
import { getConvexHttpClient } from "@/lib/convex/server";
export const dynamic = "force-dynamic";
@@ -25,6 +29,32 @@ function makeExpiredDeletedAt(): string {
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
let auth;
try {
auth = requireAuthContext();
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: "未登录" }, { status: err.status });
}
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { workspaceId }: EmptyTrashPayload = await request.json().catch(() => ({}));
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const client = getConvexHttpClient();
const res = await client.mutation(api.mediaAssets.emptyTrashByWorkspace, {
userId: auth.userId,
workspaceId,
expiredDeletedAt: makeExpiredDeletedAt(),
});
return NextResponse.json({ success: true, ...(res as any) });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -85,4 +115,3 @@ export async function POST(request: Request) {
return NextResponse.json({ success: true, updated: assetIds.length });
}
@@ -1,8 +1,14 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { isConvexEnabled } from "@/lib/convex/enabled";
export async function POST(request: Request) {
if (isConvexEnabled()) {
// 说明:OCR 链路目前仍依赖 Supabase JWT/表结构,迁移阶段先显式禁用,避免 UI/接口误用。
return NextResponse.json({ error: "Convex 模式暂不支持 OCR" }, { status: 501 });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -1,5 +1,9 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { api } from "@/lib/convex/api";
import { getConvexHttpClient } from "@/lib/convex/server";
export const dynamic = "force-dynamic";
@@ -25,6 +29,32 @@ function makeExpiredDeletedAt(): string {
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
let auth;
try {
auth = requireAuthContext();
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: "未登录" }, { status: err.status });
}
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { assetId }: PurgePayload = await request.json().catch(() => ({}));
if (!assetId) {
return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
}
const client = getConvexHttpClient();
const res = await client.mutation(api.mediaAssets.purgeById, {
userId: auth.userId,
id: assetId,
expiredDeletedAt: makeExpiredDeletedAt(),
});
return NextResponse.json({ success: true, ...(res as any) });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -86,4 +116,3 @@ export async function POST(request: Request) {
return NextResponse.json({ success: true });
}
@@ -3,6 +3,10 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import supabaseAdmin from "@/lib/supabase/admin";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { api } from "@/lib/convex/api";
import { getConvexHttpClient } from "@/lib/convex/server";
export const dynamic = "force-dynamic";
@@ -61,6 +65,47 @@ const resolveAssetObjectLocation = async (params: {
};
export async function GET(request: Request) {
if (isConvexEnabled()) {
let auth;
try {
auth = requireAuthContext();
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: "未登录" }, { status: err.status });
}
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const assetId = searchParams.get("assetId");
if (!assetId) {
return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
}
const client = getConvexHttpClient();
const asset = await client.query(api.mediaAssets.getById, { userId: auth.userId, id: assetId });
if (!asset) {
return NextResponse.json({ error: "资源不存在" }, { status: 404 });
}
const refreshed = await client.mutation(api.mediaAssets.refreshUrl, { userId: auth.userId, id: assetId });
const signedUrl = (refreshed as { signedUrl?: string | null } | null)?.signedUrl ?? null;
if (!signedUrl) {
return NextResponse.json({ error: "生成签名链接失败" }, { status: 500 });
}
return NextResponse.json({
signedUrl,
asset: {
id: asset.id,
file_name: asset.file_name,
mime_type: asset.mime_type,
file_size: asset.file_size,
},
});
}
const supabase = await createSupabaseRouteClient();
const {
data: { user },
@@ -3,6 +3,8 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import supabaseAdmin from "@/lib/supabase/admin";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
export const dynamic = "force-dynamic";
@@ -92,6 +94,26 @@ const tryResolveExistingObjectPath = async (params: {
};
export async function GET(request: Request) {
if (isConvexEnabled()) {
// 说明:Convex + MinIO 模式下,这个接口目前主要用于“外链文件”走 ONLYOFFICE 的场景。
// 由于外链本身已经是可访问的 URL,这里只做最小透传。
try {
requireAuthContext();
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: "未登录" }, { status: err.status });
}
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const fileUrl = searchParams.get("fileUrl");
if (!fileUrl) {
return NextResponse.json({ error: "缺少 fileUrl" }, { status: 400 });
}
return NextResponse.json({ signedUrl: fileUrl });
}
// 说明:在 Cloudflare Tunnel 场景下,后端收到的 Host 可能是 localhost
// 但 ONLYOFFICE 文档服务器拉取 document.url 时必须使用公网可达的域名。
// 因此这里优先使用运行时配置(public/mnote-env.json / env)里的公网 Origin
@@ -4,6 +4,10 @@ import type { MediaAsset } from "@/types/media";
import { extname } from "path";
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { api } from "@/lib/convex/api";
import { getConvexHttpClient } from "@/lib/convex/server";
export const dynamic = "force-dynamic";
@@ -17,6 +21,86 @@ const resolveAssetType = (mime: string): "image" | "video" | "audio" | "file" =>
};
export async function POST(request: Request) {
if (isConvexEnabled()) {
let auth;
try {
auth = requireAuthContext();
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: "未登录" }, { status: err.status });
}
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get("file");
const workspaceId = String(formData.get("workspaceId") ?? "");
const documentId = String(formData.get("documentId") ?? "");
const mindmapIdRaw = String(formData.get("mindmapId") ?? "").trim();
if (!(file instanceof File) || !workspaceId || !documentId) {
return NextResponse.json({ error: "缺少必要参数" }, { status: 400 });
}
try {
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const assetId =
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
const assetType = resolveAssetType(file.type || "");
const client = getConvexHttpClient();
// 1) 获取 Convex 的上传 URL(短时有效)
const uploadUrl = await client.mutation(api.mediaAssets.generateUploadUrl, { userId: auth.userId });
if (!uploadUrl || typeof uploadUrl !== "string") {
return NextResponse.json({ error: "获取上传地址失败" }, { status: 500 });
}
// 2) 上传文件到 Convex Files
const uploadRes = await fetch(uploadUrl, {
method: "POST",
headers: { "Content-Type": file.type || "application/octet-stream" },
body: buffer,
});
if (!uploadRes.ok) {
const text = await uploadRes.text().catch(() => "");
return NextResponse.json({ error: `上传到 Convex 失败:${uploadRes.status} ${text}` }, { status: 500 });
}
const uploadJson = (await uploadRes.json().catch(() => null)) as { storageId?: string } | null;
const storageId = uploadJson?.storageId ?? "";
if (!storageId) {
return NextResponse.json({ error: "上传到 Convex 失败:缺少 storageId" }, { status: 500 });
}
// 3) 写入 Convex 的 media_assets 元数据(并记录 storageId
const created = await client.mutation(api.mediaAssets.createWithStorage, {
userId: auth.userId,
storageId: storageId as any,
asset: {
id: assetId,
workspace_id: workspaceId,
document_id: documentId,
asset_type: assetType,
file_name: file.name || null,
file_size: file.size,
mime_type: file.type || null,
},
});
const asset = created as unknown as MediaAsset;
return NextResponse.json({
asset,
mindmapUrl: `asset:${asset.id}`,
});
} catch (error) {
console.error(error);
return NextResponse.json({ error: "上传失败" }, { status: 500 });
}
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },