0.3.5 共享功能修复

This commit is contained in:
liaibo
2026-01-24 12:32:51 +08:00
parent 25923f308c
commit 3c3f407f4b
44 changed files with 3754 additions and 420 deletions
+4
View File
@@ -8,6 +8,7 @@
* @module
*/
import type * as _utils_attachmentExtract from "../_utils/attachmentExtract.js";
import type * as _utils_documentTree from "../_utils/documentTree.js";
import type * as _utils_id from "../_utils/id.js";
import type * as _utils_ingestJobs from "../_utils/ingestJobs.js";
@@ -19,6 +20,7 @@ import type * as documentGroupShares from "../documentGroupShares.js";
import type * as documentShares from "../documentShares.js";
import type * as documentStars from "../documentStars.js";
import type * as documents from "../documents.js";
import type * as groupInvitations from "../groupInvitations.js";
import type * as groupMembers from "../groupMembers.js";
import type * as groups from "../groups.js";
import type * as http from "../http.js";
@@ -39,6 +41,7 @@ import type {
} from "convex/server";
declare const fullApi: ApiFromModules<{
"_utils/attachmentExtract": typeof _utils_attachmentExtract;
"_utils/documentTree": typeof _utils_documentTree;
"_utils/id": typeof _utils_id;
"_utils/ingestJobs": typeof _utils_ingestJobs;
@@ -50,6 +53,7 @@ declare const fullApi: ApiFromModules<{
documentShares: typeof documentShares;
documentStars: typeof documentStars;
documents: typeof documents;
groupInvitations: typeof groupInvitations;
groupMembers: typeof groupMembers;
groups: typeof groups;
http: typeof http;
@@ -0,0 +1,250 @@
import JSZip from "jszip";
const MAX_TEXT_CHARS = 120_000;
function clampText(input: string, maxChars = MAX_TEXT_CHARS): string {
const trimmed = String(input || "").replace(/\s+\n/g, "\n").trim();
if (!trimmed) return "";
if (trimmed.length <= maxChars) return trimmed;
return `${trimmed.slice(0, maxChars)}`;
}
function decodeXmlEntities(input: string): string {
return input
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => {
const code = Number.parseInt(hex, 16);
if (!Number.isFinite(code)) return "";
return String.fromCodePoint(code);
})
.replace(/&#(\d+);/g, (_, dec) => {
const code = Number.parseInt(dec, 10);
if (!Number.isFinite(code)) return "";
return String.fromCodePoint(code);
});
}
function getExtension(fileName: string | null | undefined): string {
const raw = String(fileName ?? "").trim().toLowerCase();
const idx = raw.lastIndexOf(".");
if (idx === -1) return "";
return raw.slice(idx + 1).replace(/[^a-z0-9]+/g, "");
}
function detectKind(args: { mimeType?: string | null; fileName?: string | null }): "pdf" | "docx" | "pptx" | "xlsx" | null {
const mime = String(args.mimeType ?? "").toLowerCase().trim();
if (mime === "application/pdf") return "pdf";
if (mime === "application/vnd.openxmlformats-officedocument.wordprocessingml.document") return "docx";
if (mime === "application/vnd.openxmlformats-officedocument.presentationml.presentation") return "pptx";
if (mime === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") return "xlsx";
const ext = getExtension(args.fileName ?? null);
if (ext === "pdf") return "pdf";
if (ext === "docx") return "docx";
if (ext === "pptx") return "pptx";
if (ext === "xlsx") return "xlsx";
return null;
}
function extractTextFromXmlByTag(xml: string, tagName: string): string {
const out: string[] = [];
const re = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`, "g");
let m: RegExpExecArray | null;
while ((m = re.exec(xml))) {
const raw = m[1] ?? "";
const clean = decodeXmlEntities(raw).replace(/\s+/g, " ").trim();
if (clean) out.push(clean);
}
return out.join(" ").trim();
}
function extractDocxText(documentXml: string): string {
const paras = documentXml.split(/<w:p[\s>]/g);
const out: string[] = [];
for (const p of paras) {
const line: string[] = [];
const re = /<w:t[^>]*>([\s\S]*?)<\/w:t>/g;
let m: RegExpExecArray | null;
while ((m = re.exec(p))) {
const raw = m[1] ?? "";
const clean = decodeXmlEntities(raw).replace(/\s+/g, " ").trim();
if (clean) line.push(clean);
}
const joined = line.join("").trim();
if (joined) out.push(joined);
}
return out.join("\n").trim();
}
function extractPptxText(slideXmls: string[]): string {
const out: string[] = [];
for (const xml of slideXmls) {
const line = extractTextFromXmlByTag(xml, "a:t");
if (line) out.push(line);
}
return out.join("\n\n").trim();
}
function extractXlsxText(args: { sharedStringsXml: string | null; sheetXmls: string[] }): string {
const sharedStrings: string[] = [];
if (args.sharedStringsXml) {
const re = /<t[^>]*>([\s\S]*?)<\/t>/g;
let m: RegExpExecArray | null;
while ((m = re.exec(args.sharedStringsXml))) {
const raw = m[1] ?? "";
const clean = decodeXmlEntities(raw).replace(/\s+/g, " ").trim();
if (clean) sharedStrings.push(clean);
}
}
const out: string[] = [];
for (const xml of args.sheetXmls) {
const rows = xml.split(/<row[\s>]/g);
for (const r of rows) {
const cells: string[] = [];
// t="s" => sharedStrings indext="inlineStr" => <is><t>...;默认 => <v>number
const cellRe = /<c\b[^>]*?(?:t="([^"]+)")?[^>]*>([\s\S]*?)<\/c>/g;
let cm: RegExpExecArray | null;
while ((cm = cellRe.exec(r))) {
const t = (cm[1] ?? "").trim();
const body = cm[2] ?? "";
if (t === "inlineStr") {
const inline = extractTextFromXmlByTag(body, "t");
if (inline) cells.push(inline);
continue;
}
const vMatch = /<v>([\s\S]*?)<\/v>/.exec(body);
if (!vMatch) continue;
const rawV = decodeXmlEntities(String(vMatch[1] ?? "")).trim();
if (!rawV) continue;
if (t === "s") {
const idx = Number.parseInt(rawV, 10);
const s = Number.isFinite(idx) ? (sharedStrings[idx] ?? "") : "";
if (s) cells.push(s);
} else {
cells.push(rawV);
}
}
const line = cells.join(" ").replace(/\s+/g, " ").trim();
if (line) out.push(line);
}
}
return out.join("\n").trim();
}
export type AttachmentExtractOk = {
ok: true;
strategy: string;
text: string;
meta?: Record<string, unknown>;
};
export type AttachmentExtractResult =
| AttachmentExtractOk
| { ok: false; strategy: string; reason: string; meta?: Record<string, unknown> };
export async function extractTextFromAttachment(args: {
mimeType: string | null;
fileName: string | null;
bytes: ArrayBuffer;
}): Promise<AttachmentExtractResult> {
const kind = detectKind({ mimeType: args.mimeType, fileName: args.fileName });
if (!kind) {
return { ok: false, strategy: "unsupported", reason: "暂不支持该附件类型" };
}
if (kind === "pdf") {
try {
const mod = await import("pdfjs-dist/legacy/build/pdf.mjs");
const data = new Uint8Array(args.bytes);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const loadingTask = (mod as any).getDocument({ data });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pdf = await (loadingTask as any).promise;
const out: string[] = [];
const pages = Number(pdf?.numPages ?? 0) || 0;
for (let i = 1; i <= pages; i += 1) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const page = await (pdf as any).getPage(i);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const content = await (page as any).getTextContent();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const items = Array.isArray((content as any)?.items) ? (content as any).items : [];
const line = items
.map((it: any) => (typeof it?.str === "string" ? it.str : ""))
.join(" ")
.replace(/\s+/g, " ")
.trim();
if (line) out.push(line);
}
const text = clampText(out.join("\n\n"));
if (!text) {
return { ok: false, strategy: "pdfjs", reason: "未提取到可用文本", meta: { pages } };
}
return { ok: true, strategy: "pdfjs", text, meta: { pages } };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { ok: false, strategy: "pdfjs", reason: message };
}
}
try {
const zip = await JSZip.loadAsync(args.bytes);
if (kind === "docx") {
const file = zip.file("word/document.xml");
const xml = file ? await file.async("string") : "";
const text = clampText(extractDocxText(xml));
if (!text) return { ok: false, strategy: "docx.xml", reason: "未提取到可用文本" };
return { ok: true, strategy: "docx.xml", text };
}
if (kind === "pptx") {
const slideFiles = Object.keys(zip.files)
.filter((p) => /^ppt\/slides\/slide\d+\.xml$/i.test(p))
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const slideXmls: string[] = [];
for (const p of slideFiles) {
const f = zip.file(p);
if (!f) continue;
// eslint-disable-next-line no-await-in-loop
slideXmls.push(await f.async("string"));
}
const text = clampText(extractPptxText(slideXmls));
if (!text) return { ok: false, strategy: "pptx.xml", reason: "未提取到可用文本" };
return { ok: true, strategy: "pptx.xml", text, meta: { slides: slideXmls.length } };
}
if (kind === "xlsx") {
const sharedStringsXml = await (async () => {
const f = zip.file("xl/sharedStrings.xml");
if (!f) return null;
return await f.async("string");
})();
const sheetFiles = Object.keys(zip.files)
.filter((p) => /^xl\/worksheets\/sheet\d+\.xml$/i.test(p))
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const sheetXmls: string[] = [];
for (const p of sheetFiles) {
const f = zip.file(p);
if (!f) continue;
// eslint-disable-next-line no-await-in-loop
sheetXmls.push(await f.async("string"));
}
const text = clampText(extractXlsxText({ sharedStringsXml, sheetXmls }));
if (!text) return { ok: false, strategy: "xlsx.xml", reason: "未提取到可用文本" };
return { ok: true, strategy: "xlsx.xml", text, meta: { sheets: sheetXmls.length } };
}
return { ok: false, strategy: "unsupported", reason: "暂不支持该附件类型" };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { ok: false, strategy: "zip", reason: message };
}
}
@@ -79,6 +79,10 @@ export function ingestMediaAssetJobId(assetId: string): string {
return `ingest:media:${assetId}`;
}
export function extractMediaAssetTextJobId(assetId: string): string {
return `extract:media:${assetId}`;
}
export async function enqueueIngestDocumentJob(
ctx: MutationCtx,
args: { userId: string; documentId: string; debounceMs?: number },
@@ -123,3 +127,18 @@ export async function enqueueIngestMediaAssetJob(
});
return { id };
}
export async function enqueueExtractMediaAssetTextJob(
ctx: MutationCtx,
args: { userId: string; assetId: string; debounceMs?: number },
): Promise<{ id: string }> {
const id = extractMediaAssetTextJobId(args.assetId);
await upsertJob(ctx, {
id,
userId: args.userId,
type: "extract.media_asset_text",
payload: { assetId: args.assetId },
debounceMs: args.debounceMs,
});
return { id };
}
@@ -30,6 +30,15 @@ async function requireGroup(ctx: any, groupId: string) {
return group;
}
async function requireGroupMember(ctx: any, groupId: string, userId: string) {
const membership = await ctx.db
.query("group_members")
.withIndex("by_group_user", (q: any) => q.eq("group_id", groupId).eq("user_id", userId))
.first();
if (!membership) throw new Error("无权限(你不在该群组内)");
return membership;
}
export const listByDocument = query({
args: { documentId: v.string() },
handler: async (ctx, args) => {
@@ -63,6 +72,8 @@ export const listByDocument = query({
groupId: share.group_id,
groupName: group?.name ?? null,
includeDescendants: share.include_descendants,
disableDownload: Boolean((share as any).disable_download),
disableCopy: Boolean((share as any).disable_copy),
editableUserIds,
updatedAt: share.updated_at,
});
@@ -78,15 +89,20 @@ export const upsert = mutation({
groupId: v.string(),
includeDescendants: v.optional(v.boolean()),
editableUserIds: v.array(v.string()),
disableDownload: v.optional(v.boolean()),
disableCopy: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await requireOwnedDocument(ctx, args.documentId, userId);
const group = await requireGroup(ctx, args.groupId);
if (group.workspace_id !== doc.workspace_id) throw new Error("群组不属于当前工作空间");
await requireGroupMember(ctx, args.groupId, userId);
const ts = nowIso();
const includeDescendants = Boolean(args.includeDescendants);
const disableDownload = Boolean(args.disableDownload);
const disableCopy = Boolean(args.disableCopy);
const existingShare = await ctx.db
.query("document_group_shares")
@@ -96,6 +112,8 @@ export const upsert = mutation({
if (existingShare) {
await ctx.db.patch(existingShare._id, {
include_descendants: includeDescendants,
disable_download: disableDownload,
disable_copy: disableCopy,
updated_at: ts,
});
} else {
@@ -104,6 +122,8 @@ export const upsert = mutation({
group_id: args.groupId,
document_id: doc.id,
include_descendants: includeDescendants,
disable_download: disableDownload,
disable_copy: disableCopy,
created_by: userId,
created_at: ts,
updated_at: ts,
+144
View File
@@ -62,6 +62,8 @@ export const listByDocument = query({
username: user?.name ?? null,
permission: share.permission,
includeDescendants: share.include_descendants,
disableDownload: Boolean((share as any).disable_download),
disableCopy: Boolean((share as any).disable_copy),
createdAt: share.created_at,
updatedAt: share.updated_at,
});
@@ -77,6 +79,8 @@ export const upsert = mutation({
username: v.string(),
permission,
includeDescendants: v.optional(v.boolean()),
disableDownload: v.optional(v.boolean()),
disableCopy: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
@@ -86,6 +90,8 @@ export const upsert = mutation({
const ts = nowIso();
const includeDescendants = Boolean(args.includeDescendants);
const disableDownload = Boolean(args.disableDownload);
const disableCopy = Boolean(args.disableCopy);
// 确保对方成为 workspace member(否则无法看到该 workspace 的数据)
const existingMember = await ctx.db
@@ -113,6 +119,8 @@ export const upsert = mutation({
await ctx.db.patch(existingShare._id, {
permission: args.permission,
include_descendants: includeDescendants,
disable_download: disableDownload,
disable_copy: disableCopy,
updated_at: ts,
});
} else {
@@ -122,6 +130,8 @@ export const upsert = mutation({
shared_with_user_id: sharedWithUserId,
permission: args.permission,
include_descendants: includeDescendants,
disable_download: disableDownload,
disable_copy: disableCopy,
created_by: userId,
created_at: ts,
updated_at: ts,
@@ -210,3 +220,137 @@ export const listShareRootsByWorkspace = query({
};
},
});
export const listMyShareRoots = query({
args: {},
handler: async (ctx) => {
const userId = await requireUserId(ctx);
const incomingRows = await ctx.db
.query("document_shares")
.withIndex("by_shared_with_user", (q: any) => q.eq("shared_with_user_id", userId))
.collect();
// 说明:只统计“我共享出去的”(自己是 created_by);用于顶部共享面板展示摘要。
const outgoingRows = await ctx.db
.query("document_shares")
.withIndex("by_created_by", (q: any) => q.eq("created_by", userId))
.collect();
// 说明:去重/合并:同一页面共享给多个用户时,只展示一条,并累计 sharedWithCount。
const outgoingByKey = new Map<
string,
{
workspaceId: string;
documentId: string;
includeDescendants: boolean;
sharedWithCount: number;
updatedAt: string;
}
>();
for (const row of outgoingRows) {
const key = `${row.workspace_id}:${row.document_id}`;
const existing = outgoingByKey.get(key);
if (!existing) {
outgoingByKey.set(key, {
workspaceId: row.workspace_id,
documentId: row.document_id,
includeDescendants: Boolean(row.include_descendants),
sharedWithCount: 1,
updatedAt: String(row.updated_at ?? ""),
});
} else {
existing.includeDescendants = existing.includeDescendants || Boolean(row.include_descendants);
existing.sharedWithCount += 1;
const u = String(row.updated_at ?? "");
if (u && u.localeCompare(existing.updatedAt) > 0) {
existing.updatedAt = u;
}
}
}
const workspaceNameCache = new Map<string, string | null>();
const getWorkspaceName = async (workspaceId: string): Promise<string | null> => {
const cached = workspaceNameCache.get(workspaceId);
if (workspaceNameCache.has(workspaceId)) return cached ?? null;
const ws = await ctx.db
.query("workspaces")
.withIndex("by_workspace_id", (q: any) => q.eq("id", workspaceId))
.first();
const name = ws?.name ?? null;
workspaceNameCache.set(workspaceId, name);
return name;
};
const documentCache = new Map<
string,
{ exists: boolean; deleted: boolean; title: string | null }
>();
const getDocumentInfo = async (
documentId: string,
): Promise<{ exists: boolean; deleted: boolean; title: string | null }> => {
const cached = documentCache.get(documentId);
if (documentCache.has(documentId)) {
return cached ?? { exists: false, deleted: true, title: null };
}
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q: any) => q.eq("id", documentId))
.first();
const info = doc
? { exists: true, deleted: doc.deleted_at != null, title: (doc.title ?? null) as string | null }
: { exists: false, deleted: true, title: null };
documentCache.set(documentId, info);
return info;
};
const incoming = [];
for (const row of incomingRows) {
// 说明:若对方仅写入了 share 但没把我加入 workspace,则这里会被文档/页面接口挡住;
// 为避免面板出现“打不开的幽灵分享”,这里要求我确实是 workspace member。
const member = await ctx.db
.query("workspace_members")
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", row.workspace_id).eq("user_id", userId))
.first();
if (!member) continue;
const docInfo = await getDocumentInfo(row.document_id);
if (!docInfo.exists) continue;
if (docInfo.deleted) continue;
incoming.push({
workspaceId: row.workspace_id,
workspaceName: await getWorkspaceName(row.workspace_id),
documentId: row.document_id,
documentTitle: docInfo.title,
permission: row.permission,
includeDescendants: row.include_descendants,
createdBy: row.created_by,
updatedAt: row.updated_at,
});
}
const outgoing = [];
for (const v of outgoingByKey.values()) {
const docInfo = await getDocumentInfo(v.documentId);
if (!docInfo.exists) continue;
if (docInfo.deleted) continue;
outgoing.push({
workspaceId: v.workspaceId,
workspaceName: await getWorkspaceName(v.workspaceId),
documentId: v.documentId,
documentTitle: docInfo.title,
includeDescendants: v.includeDescendants,
sharedWithCount: v.sharedWithCount,
updatedAt: v.updatedAt,
});
}
incoming.sort((a: any, b: any) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? "")));
outgoing.sort((a: any, b: any) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? "")));
return { incoming, outgoing };
},
});
+5 -2
View File
@@ -103,7 +103,11 @@ async function resolveGroupSharePermission(ctx: any, doc: any, userId: string):
export const isStarred = query({
args: { documentId: v.string() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
// 退出登录/未登录时,客户端仍可能会发起 isStarred 查询(例如 Breadcrumb 仍在渲染)。
// 这里不要抛错,直接返回 false,避免把“未登录”作为运行时错误冒泡到前端。
const authUserId = await getAuthUserId(ctx);
if (authUserId === null) return false;
const userId = String(authUserId);
const doc = await ctx.db
.query("documents")
@@ -182,4 +186,3 @@ export const toggle = mutation({
return { ok: true, starred: true };
},
});
+250 -14
View File
@@ -4,6 +4,7 @@ import { getAuthUserId } from "@convex-dev/auth/server";
import { nowIso } from "./_utils/time";
import { collectSubtree } from "./_utils/documentTree";
import { enqueueIngestDocumentJob } from "./_utils/ingestJobs";
import { extractTextFromDocumentContent } from "./_utils/text";
const accessScope = v.union(v.literal("private"), v.literal("shared"), v.literal("public"));
@@ -17,6 +18,8 @@ async function requireUserId(ctx: any): Promise<string> {
type SharePermission = "read" | "edit";
type SharePolicy = { permission: SharePermission; disableDownload: boolean; disableCopy: boolean };
async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: string) {
const member = await ctx.db
.query("workspace_members")
@@ -28,13 +31,17 @@ async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: str
return member;
}
async function resolveSharePermission(ctx: any, doc: any, userId: string): Promise<SharePermission | null> {
async function resolveSharePolicy(ctx: any, doc: any, userId: string): Promise<SharePolicy | null> {
const direct = await ctx.db
.query("document_shares")
.withIndex("by_doc_user", (q: any) => q.eq("document_id", doc.id).eq("shared_with_user_id", userId))
.first();
if (direct) {
return direct.permission as SharePermission;
return {
permission: direct.permission as SharePermission,
disableDownload: Boolean((direct as any).disable_download),
disableCopy: Boolean((direct as any).disable_copy),
};
}
let parentId: string | null = doc.parent_id ?? null;
@@ -44,7 +51,11 @@ async function resolveSharePermission(ctx: any, doc: any, userId: string): Promi
.withIndex("by_doc_user", (q: any) => q.eq("document_id", parentId).eq("shared_with_user_id", userId))
.first();
if (parentShare && parentShare.include_descendants) {
return parentShare.permission as SharePermission;
return {
permission: parentShare.permission as SharePermission,
disableDownload: Boolean((parentShare as any).disable_download),
disableCopy: Boolean((parentShare as any).disable_copy),
};
}
const parent = await ctx.db
@@ -57,7 +68,12 @@ async function resolveSharePermission(ctx: any, doc: any, userId: string): Promi
return null;
}
async function resolveGroupSharePermission(ctx: any, doc: any, userId: string): Promise<SharePermission | null> {
async function resolveSharePermission(ctx: any, doc: any, userId: string): Promise<SharePermission | null> {
const policy = await resolveSharePolicy(ctx, doc, userId);
return policy?.permission ?? null;
}
async function resolveGroupSharePolicy(ctx: any, doc: any, userId: string): Promise<SharePolicy | null> {
const memberships = await ctx.db
.query("group_members")
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", doc.workspace_id).eq("user_id", userId))
@@ -77,6 +93,8 @@ async function resolveGroupSharePermission(ctx: any, doc: any, userId: string):
};
let best: SharePermission | null = null;
let disableDownload = false;
let disableCopy = false;
const checkDocId = async (documentId: string, requireIncludeDesc: boolean) => {
const shares = await ctx.db
@@ -89,23 +107,25 @@ async function resolveGroupSharePermission(ctx: any, doc: any, userId: string):
if (!groupIds.has(gid)) continue;
if (requireIncludeDesc && !s.include_descendants) continue;
disableDownload = disableDownload || Boolean((s as any).disable_download);
disableCopy = disableCopy || Boolean((s as any).disable_copy);
const perm = await permissionFromGroup(documentId, gid);
if (perm === "edit") {
best = "edit";
return true;
} else {
best = best ?? "read";
}
best = best ?? "read";
}
return false;
};
// 当前页面是否被群组公开
if (await checkDocId(doc.id, false)) return "edit";
await checkDocId(doc.id, false);
// 沿父链查找“包含子页面”的群组公开
let parentId: string | null = doc.parent_id ?? null;
for (let depth = 0; depth < 60 && parentId; depth += 1) {
if (await checkDocId(parentId, true)) return "edit";
await checkDocId(parentId, true);
const parent = await ctx.db
.query("documents")
@@ -114,7 +134,48 @@ async function resolveGroupSharePermission(ctx: any, doc: any, userId: string):
parentId = parent?.parent_id ?? null;
}
return best;
if (!best) return null;
return { permission: best, disableDownload, disableCopy };
}
async function resolveGroupSharePermission(ctx: any, doc: any, userId: string): Promise<SharePermission | null> {
const policy = await resolveGroupSharePolicy(ctx, doc, userId);
return policy?.permission ?? null;
}
async function purgeDocumentShareRelations(ctx: any, workspaceId: string, documentId: string) {
// 说明:页面被“永久删除/清空回收站”后,需要级联清理共享关系;
// 否则被分享者可能在“共享页面/公共页面”里看到幽灵条目(点开 404 / 无标题)。
const directShares = await ctx.db
.query("document_shares")
.withIndex("by_document", (q: any) => q.eq("document_id", documentId))
.collect();
for (const row of directShares) {
await ctx.db.delete(row._id);
}
const groupShares = await ctx.db
.query("document_group_shares")
.withIndex("by_document", (q: any) => q.eq("document_id", documentId))
.collect();
const touchedGroupIds = new Set<string>();
for (const row of groupShares) {
touchedGroupIds.add(String(row.group_id));
await ctx.db.delete(row._id);
}
// 删除该文档在相关群组下的“用户可编辑覆盖权限”
for (const groupId of touchedGroupIds) {
const perms = await ctx.db
.query("document_group_user_permissions")
.withIndex("by_workspace_group", (q: any) => q.eq("workspace_id", workspaceId).eq("group_id", groupId))
.collect();
for (const p of perms) {
if (String(p.document_id) !== documentId) continue;
await ctx.db.delete(p._id);
}
}
}
export const getMeta = query({
@@ -135,14 +196,18 @@ export const getMeta = query({
}
let canEdit = false;
let disableDownload = false;
let disableCopy = false;
if (doc.user_id === userId) {
canEdit = true;
} else if (doc.access_scope === "public") {
canEdit = false;
} else {
const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId));
if (!perm) return null;
canEdit = perm === "edit";
const policy = (await resolveSharePolicy(ctx, doc, userId)) ?? (await resolveGroupSharePolicy(ctx, doc, userId));
if (!policy) return null;
canEdit = policy.permission === "edit";
disableDownload = policy.disableDownload;
disableCopy = policy.disableCopy;
}
return {
id: doc.id,
@@ -150,6 +215,8 @@ export const getMeta = query({
workspace_id: doc.workspace_id,
access_scope: doc.access_scope,
can_edit: canEdit,
disable_download: disableDownload,
disable_copy: disableCopy,
title: doc.title ?? null,
parent_id: doc.parent_id ?? null,
created_at: doc.created_at,
@@ -168,6 +235,37 @@ export const getMeta = query({
},
});
export const getPermissionForUser = query({
args: { userId: v.string(), id: v.string() },
handler: async (ctx, args) => {
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) return null;
if (doc.deleted_at != null) return null;
// workspace 权限
await requireWorkspaceMember(ctx, doc.workspace_id, args.userId);
if (doc.user_id === args.userId) {
return { permission: "edit" as const, disableDownload: false, disableCopy: false };
}
if (doc.access_scope === "public") {
return { permission: "read" as const, disableDownload: false, disableCopy: false };
}
const policy = (await resolveSharePolicy(ctx, doc, args.userId)) ?? (await resolveGroupSharePolicy(ctx, doc, args.userId));
if (!policy) return null;
return {
permission: policy.permission === "edit" ? ("edit" as const) : ("read" as const),
disableDownload: policy.disableDownload,
disableCopy: policy.disableCopy,
};
},
});
export const getMetaForIngest = internalQuery({
args: { userId: v.string(), id: v.string() },
handler: async (ctx, args) => {
@@ -379,6 +477,134 @@ export const listByWorkspace = query({
},
});
export const listSearchDataByWorkspace = query({
args: { workspaceId: v.string() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
// workspace 权限
try {
await requireWorkspaceMember(ctx, args.workspaceId, userId);
} catch {
return [];
}
const docs = await ctx.db
.query("documents")
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.collect();
const alive = docs.filter((d) => d.deleted_at == null);
const shares = await ctx.db
.query("document_shares")
.withIndex("by_workspace_shared_with", (q: any) =>
q.eq("workspace_id", args.workspaceId).eq("shared_with_user_id", userId),
)
.collect();
const directShares = new Map<
string,
{ permission: SharePermission; includeDescendants: boolean }
>();
for (const s of shares) {
directShares.set(s.document_id, {
permission: s.permission as SharePermission,
includeDescendants: Boolean(s.include_descendants),
});
}
const groupMemberships = await ctx.db
.query("group_members")
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId))
.collect();
const groupIds = new Set<string>(groupMemberships.map((m: any) => String(m.group_id)));
const directGroupShares = new Map<string, { includeDescendants: boolean }>();
if (groupIds.size > 0) {
for (const gid of groupIds) {
const rows = await ctx.db
.query("document_group_shares")
.withIndex("by_workspace_group", (q: any) => q.eq("workspace_id", args.workspaceId).eq("group_id", gid))
.collect();
for (const r of rows) {
const existing = directGroupShares.get(r.document_id);
if (!existing) {
directGroupShares.set(r.document_id, { includeDescendants: Boolean(r.include_descendants) });
} else {
existing.includeDescendants = existing.includeDescendants || Boolean(r.include_descendants);
}
}
}
}
const parentById = new Map<string, string | null>();
for (const d of alive) {
parentById.set(d.id, d.parent_id ?? null);
}
const shareAccessCache = new Map<string, boolean>();
const canAccessByShare = (docId: string): boolean => {
const cached = shareAccessCache.get(docId);
if (typeof cached === "boolean") return cached;
if (directShares.has(docId)) {
shareAccessCache.set(docId, true);
return true;
}
let parentId = parentById.get(docId) ?? null;
for (let depth = 0; depth < 60 && parentId; depth += 1) {
const parentShare = directShares.get(parentId);
if (parentShare && parentShare.includeDescendants) {
shareAccessCache.set(docId, true);
return true;
}
parentId = parentById.get(parentId) ?? null;
}
shareAccessCache.set(docId, false);
return false;
};
const groupAccessCache = new Map<string, boolean>();
const canAccessByGroupShare = (docId: string): boolean => {
const cached = groupAccessCache.get(docId);
if (typeof cached === "boolean") return cached;
if (directGroupShares.has(docId)) {
groupAccessCache.set(docId, true);
return true;
}
let parentId = parentById.get(docId) ?? null;
for (let depth = 0; depth < 60 && parentId; depth += 1) {
const parentShare = directGroupShares.get(parentId);
if (parentShare && parentShare.includeDescendants) {
groupAccessCache.set(docId, true);
return true;
}
parentId = parentById.get(parentId) ?? null;
}
groupAccessCache.set(docId, false);
return false;
};
return alive
.filter((d) => {
if (d.user_id === userId) return true;
if (d.access_scope === "public") return true;
return canAccessByShare(d.id) || canAccessByGroupShare(d.id);
})
.map((d) => ({
id: d.id,
workspace_id: d.workspace_id,
title: d.title ?? null,
created_at: d.created_at ?? null,
updated_at: d.updated_at ?? null,
raw_text:
typeof d.raw_text === "string" && d.raw_text.trim()
? d.raw_text
: extractTextFromDocumentContent(d.content ?? null),
}));
},
});
export const listTrashedByWorkspace = query({
args: { workspaceId: v.string() },
handler: async (ctx, args) => {
@@ -426,6 +652,7 @@ export const create = mutation({
const title = (args.title ?? "无标题") || "无标题";
const content = typeof args.content === "undefined" ? [] : args.content;
const rawText = extractTextFromDocumentContent(content);
await ctx.db.insert("documents", {
id: args.id,
@@ -434,6 +661,7 @@ export const create = mutation({
parent_id: args.parentId,
title,
content,
raw_text: rawText,
access_scope: args.accessScope,
sort_order: sortOrder,
is_starred: false,
@@ -489,7 +717,8 @@ export const updateContent = mutation({
if (perm !== "edit") throw new Error("无权限");
}
const ts = nowIso();
await ctx.db.patch(doc._id, { content: args.content, updated_at: ts });
const rawText = extractTextFromDocumentContent(args.content);
await ctx.db.patch(doc._id, { content: args.content, raw_text: rawText, updated_at: ts });
// 说明:在 Convex 模式下,把"自动入库/LightRAG 触发"迁到 Convex jobs/actions。
// 采用 debounce,避免频繁保存时触发过多任务。
@@ -641,6 +870,9 @@ export const softDelete = mutation({
for (const item of subtree) {
if (item.deleted_at != null) continue;
await ctx.db.patch(item._id, { deleted_at: ts, deleted_by: userId, updated_at: ts });
// 说明:为了避免被分享者仍看到已删除页面(点开 404),软删除时也同步移除共享关系。
// 如需保留共享关系用于恢复后自动生效,可改为仅在 purge/emptyTrash 时清理。
await purgeDocumentShareRelations(ctx, doc.workspace_id, item.id);
moved += 1;
}
@@ -710,6 +942,7 @@ export const purge = mutation({
// 删除顺序对当前数据模型无强制要求,这里简单逐个删除即可。
for (const item of subtree) {
await purgeDocumentShareRelations(ctx, doc.workspace_id, item.id);
await ctx.db.delete(item._id);
}
@@ -739,6 +972,7 @@ export const emptyTrashByWorkspace = mutation({
const toDelete = docs.filter((d) => d.user_id === userId && d.deleted_at != null);
for (const d of toDelete) {
await purgeDocumentShareRelations(ctx, args.workspaceId, d.id);
await ctx.db.delete(d._id);
}
@@ -857,6 +1091,7 @@ export const duplicate = mutation({
const ts = nowIso();
const baseTitle = (source.title ?? "无标题").trim() ? (source.title ?? "无标题").trim() : "无标题";
const title = (args.title ?? `${baseTitle} 副本`) || `${baseTitle} 副本`;
const rawText = extractTextFromDocumentContent(source.content ?? []);
await ctx.db.insert("documents", {
id: args.newId,
@@ -865,6 +1100,7 @@ export const duplicate = mutation({
parent_id: source.parent_id,
title,
content: source.content ?? [],
raw_text: rawText,
access_scope: source.access_scope,
sort_order: sortOrder,
is_starred: false,
+241
View File
@@ -0,0 +1,241 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { getAuthUserId } from "@convex-dev/auth/server";
import { nowIso } from "./_utils/time";
import type { Id } from "./_generated/dataModel";
async function requireUserId(ctx: any): Promise<string> {
const userId = await getAuthUserId(ctx);
if (userId === null) throw new Error("未登录");
return String(userId);
}
async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: string) {
const member = await ctx.db
.query("workspace_members")
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", workspaceId).eq("user_id", userId))
.first();
if (!member) throw new Error("无权限");
return member;
}
async function requireGroup(ctx: any, groupId: string) {
const group = await ctx.db
.query("groups")
.withIndex("by_group_id", (q: any) => q.eq("id", groupId))
.first();
if (!group) throw new Error("群组不存在");
return group;
}
async function requireGroupOwner(ctx: any, groupId: string, userId: string) {
const membership = await ctx.db
.query("group_members")
.withIndex("by_group_user", (q: any) => q.eq("group_id", groupId).eq("user_id", userId))
.first();
if (!membership || membership.role !== "owner") throw new Error("无权限");
return membership;
}
function normalizeUsername(raw: string): string {
const username = String(raw ?? "").trim();
if (!username) throw new Error("用户名不能为空");
if (username.length < 2 || username.length > 32) throw new Error("用户名长度需为 2-32 个字符");
if (/\s/.test(username)) throw new Error("用户名不能包含空格");
return username;
}
async function lookupUserIdByUsername(ctx: any, rawUsername: string): Promise<string> {
const username = normalizeUsername(rawUsername);
const user = await ctx.db
.query("users")
.filter((q: any) => q.eq(q.field("name"), username))
.first();
if (!user) throw new Error("未找到该用户");
return String(user._id);
}
export const listMineByWorkspace = query({
args: { workspaceId: v.string() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const invites = await ctx.db
.query("group_invitations")
.withIndex("by_workspace_invited_user", (q: any) =>
q.eq("workspace_id", args.workspaceId).eq("invited_user_id", userId),
)
.collect();
// 最新的排前面
invites.sort((a: any, b: any) => String(b.updated_at ?? "").localeCompare(String(a.updated_at ?? "")));
const result = [];
for (const inv of invites) {
const group = await ctx.db
.query("groups")
.withIndex("by_group_id", (q: any) => q.eq("id", inv.group_id))
.first();
const inviter = await ctx.db.get(inv.invited_by as Id<"users">);
result.push({
groupId: inv.group_id,
groupName: group?.name ?? null,
invitedByUserId: inv.invited_by,
invitedByUsername: inviter?.name ?? null,
createdAt: inv.created_at,
updatedAt: inv.updated_at,
});
}
return result;
},
});
export const listMine = query({
args: {},
handler: async (ctx) => {
const userId = await requireUserId(ctx);
const invites = await ctx.db
.query("group_invitations")
.withIndex("by_invited_user", (q: any) => q.eq("invited_user_id", userId))
.collect();
invites.sort((a: any, b: any) => String(b.updated_at ?? "").localeCompare(String(a.updated_at ?? "")));
const result = [];
for (const inv of invites) {
const group = await ctx.db
.query("groups")
.withIndex("by_group_id", (q: any) => q.eq("id", inv.group_id))
.first();
const workspace = await ctx.db
.query("workspaces")
.withIndex("by_workspace_id", (q: any) => q.eq("id", inv.workspace_id))
.first();
const inviter = await ctx.db.get(inv.invited_by as Id<"users">);
result.push({
workspaceId: inv.workspace_id,
workspaceName: workspace?.name ?? null,
groupId: inv.group_id,
groupName: group?.name ?? null,
invitedByUserId: inv.invited_by,
invitedByUsername: inviter?.name ?? null,
createdAt: inv.created_at,
updatedAt: inv.updated_at,
});
}
return result;
},
});
export const inviteByUsername = mutation({
args: { groupId: v.string(), username: v.string() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const group = await requireGroup(ctx, args.groupId);
await requireWorkspaceMember(ctx, group.workspace_id, userId);
await requireGroupOwner(ctx, args.groupId, userId);
const targetUserId = await lookupUserIdByUsername(ctx, args.username);
if (targetUserId === userId) throw new Error("不能邀请自己");
const existingMember = await ctx.db
.query("group_members")
.withIndex("by_group_user", (q: any) => q.eq("group_id", args.groupId).eq("user_id", targetUserId))
.first();
if (existingMember) {
return { ok: true };
}
const ts = nowIso();
const existingInvite = await ctx.db
.query("group_invitations")
.withIndex("by_group_user", (q: any) => q.eq("group_id", args.groupId).eq("invited_user_id", targetUserId))
.first();
if (existingInvite) {
await ctx.db.patch(existingInvite._id, { invited_by: userId, updated_at: ts });
return { ok: true };
}
await ctx.db.insert("group_invitations", {
workspace_id: group.workspace_id,
group_id: args.groupId,
invited_user_id: targetUserId,
invited_by: userId,
created_at: ts,
updated_at: ts,
});
return { ok: true };
},
});
export const accept = mutation({
args: { groupId: v.string() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const group = await requireGroup(ctx, args.groupId);
await requireWorkspaceMember(ctx, group.workspace_id, userId).catch(async () => {
// 说明:允许被邀请者尚未加入 workspace,接受邀请时自动加入。
await ctx.db.insert("workspace_members", {
workspace_id: group.workspace_id,
user_id: userId,
role: "member",
is_default: false,
created_at: nowIso(),
});
});
const invite = await ctx.db
.query("group_invitations")
.withIndex("by_group_user", (q: any) => q.eq("group_id", args.groupId).eq("invited_user_id", userId))
.first();
if (!invite) {
return { ok: true };
}
const existingMember = await ctx.db
.query("group_members")
.withIndex("by_group_user", (q: any) => q.eq("group_id", args.groupId).eq("user_id", userId))
.first();
const ts = nowIso();
if (!existingMember) {
await ctx.db.insert("group_members", {
workspace_id: group.workspace_id,
group_id: args.groupId,
user_id: userId,
role: "member",
created_at: ts,
});
}
await ctx.db.delete(invite._id);
return { ok: true };
},
});
export const decline = mutation({
args: { groupId: v.string() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const group = await requireGroup(ctx, args.groupId);
await requireWorkspaceMember(ctx, group.workspace_id, userId).catch(() => {
// 即使没进 workspace,也可以拒绝邀请(删除邀请记录)。
});
const invite = await ctx.db
.query("group_invitations")
.withIndex("by_group_user", (q: any) => q.eq("group_id", args.groupId).eq("invited_user_id", userId))
.first();
if (invite) {
await ctx.db.delete(invite._id);
}
return { ok: true };
},
});
+1 -41
View File
@@ -90,46 +90,7 @@ export const listByGroup = query({
export const inviteByUsername = mutation({
args: { groupId: v.string(), username: v.string() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const group = await requireGroup(ctx, args.groupId);
await requireWorkspaceMember(ctx, group.workspace_id, userId);
await requireGroupOwner(ctx, args.groupId, userId);
const targetUserId = await lookupUserIdByUsername(ctx, args.username);
if (targetUserId === userId) throw new Error("不能邀请自己");
// 确保对方为 workspace member
const existingWorkspaceMember = await ctx.db
.query("workspace_members")
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", group.workspace_id).eq("user_id", targetUserId))
.first();
if (!existingWorkspaceMember) {
await ctx.db.insert("workspace_members", {
workspace_id: group.workspace_id,
user_id: targetUserId,
role: "member",
is_default: false,
created_at: nowIso(),
});
}
const existing = await ctx.db
.query("group_members")
.withIndex("by_group_user", (q: any) => q.eq("group_id", args.groupId).eq("user_id", targetUserId))
.first();
if (existing) {
return { ok: true };
}
await ctx.db.insert("group_members", {
workspace_id: group.workspace_id,
group_id: args.groupId,
user_id: targetUserId,
role: "member",
created_at: nowIso(),
});
return { ok: true };
throw new Error("接口已废弃:请改用 groupInvitations.inviteByUsername(支持对方接受/拒绝)。");
},
});
@@ -152,4 +113,3 @@ export const removeMember = mutation({
return { ok: true };
},
});
+44 -1
View File
@@ -2,6 +2,7 @@ import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { getAuthUserId } from "@convex-dev/auth/server";
import { nowIso } from "./_utils/time";
import type { Id } from "./_generated/dataModel";
async function requireUserId(ctx: any): Promise<string> {
const userId = await getAuthUserId(ctx);
@@ -48,6 +49,49 @@ export const listByWorkspace = query({
},
});
export const listMineByWorkspace = query({
args: { workspaceId: v.string() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
await requireWorkspaceMember(ctx, args.workspaceId, userId);
const memberships = await ctx.db
.query("group_members")
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId))
.collect();
const groupsById = new Map<string, any>();
for (const m of memberships) {
const group = await ctx.db
.query("groups")
.withIndex("by_group_id", (q: any) => q.eq("id", m.group_id))
.first();
if (group) {
groupsById.set(String(group.id), {
id: group.id,
workspace_id: group.workspace_id,
name: group.name,
created_by: group.created_by,
created_at: group.created_at,
updated_at: group.updated_at,
my_role: m.role,
});
}
}
const rows = Array.from(groupsById.values());
rows.sort((a: any, b: any) => String(a.created_at ?? "").localeCompare(String(b.created_at ?? "")));
// 额外:带上当前用户用户名,前端可用
const me = await ctx.db.get(userId as Id<"users">);
return {
me: { userId, username: me?.name ?? null },
groups: rows,
};
},
});
export const create = mutation({
args: {
id: v.string(),
@@ -159,4 +203,3 @@ export const remove = mutation({
return { ok: true };
},
});
+100
View File
@@ -5,6 +5,7 @@ import { api, internal } from "./_generated/api";
import { lightragIngestText } from "./_utils/lightrag";
import { extractTextFromDocumentContent, extractTextFromMindmapData } from "./_utils/text";
import { enqueueIngestDocumentJob, enqueueIngestMediaAssetJob, enqueueIngestMindmapJob } from "./_utils/ingestJobs";
import { extractTextFromAttachment } from "./_utils/attachmentExtract";
export const get = query({
args: { userId: v.string(), id: v.string() },
@@ -199,6 +200,105 @@ export const run = internalAction({
return;
}
if (job.type === "extract.media_asset_text") {
const assetId = String(job.payload?.assetId ?? "").trim();
if (!assetId) throw new Error("缺少 assetId");
const asset = await ctx.runQuery(api.mediaAssets.getById, { userId: job.user_id, id: assetId });
if (!asset) throw new Error("资源不存在或无权限");
// 说明:只处理 file 类型的常见附件(pdf/docx/pptx/xlsx)。
if (String(asset.asset_type ?? "") !== "file") {
await ctx.runMutation(api.mediaAssets.patchById, {
userId: job.user_id,
id: assetId,
patch: {
ocr_status: "skipped",
ocr_payload: { reason: "non_file_asset" },
ocr_strategy: "attachment_extract",
},
});
await ctx.runMutation(internal.jobs.finishSuccess, {
id: args.id,
result: { ok: true, kind: "extract_media_asset_text", assetId, skipped: true, reason: "non_file_asset" },
});
return;
}
const fileSize = typeof asset.file_size === "number" ? asset.file_size : null;
const maxBytes = 25 * 1024 * 1024;
if (typeof fileSize === "number" && fileSize > maxBytes) {
await ctx.runMutation(api.mediaAssets.patchById, {
userId: job.user_id,
id: assetId,
patch: {
ocr_status: "failed",
ocr_payload: { error: `文件过大(${fileSize} bytes),暂不解析`, maxBytes },
ocr_strategy: "attachment_extract",
},
});
await ctx.runMutation(internal.jobs.finishFailure, {
id: args.id,
error: "文件过大,暂不解析",
});
return;
}
// 说明:Convex Files 的 getUrl 可能过期,先刷新并获取当前可用链接。
const refreshed = await ctx.runMutation(api.mediaAssets.refreshUrl, { userId: job.user_id, id: assetId });
const url = String((refreshed as any)?.signedUrl ?? asset.file_url ?? "").trim();
if (!url) throw new Error("缺少可用文件链接");
const res = await fetch(url);
if (!res.ok) {
throw new Error(`下载附件失败:${res.status}`);
}
const bytes = await res.arrayBuffer();
const extracted = await extractTextFromAttachment({
mimeType: (asset as any).mime_type ?? null,
fileName: (asset as any).file_name ?? null,
bytes,
});
if (!extracted.ok) {
await ctx.runMutation(api.mediaAssets.patchById, {
userId: job.user_id,
id: assetId,
patch: {
ocr_status: "failed",
ocr_payload: { error: extracted.reason, meta: extracted.meta ?? null },
ocr_strategy: extracted.strategy,
},
});
await ctx.runMutation(internal.jobs.finishFailure, { id: args.id, error: extracted.reason });
return;
}
await ctx.runMutation(api.mediaAssets.patchById, {
userId: job.user_id,
id: assetId,
patch: {
ocr_text: extracted.text,
ocr_status: "completed",
ocr_payload: extracted.meta ?? null,
ocr_strategy: extracted.strategy,
},
});
await ctx.runMutation(internal.jobs.finishSuccess, {
id: args.id,
result: {
ok: true,
kind: "extract_media_asset_text",
assetId,
strategy: extracted.strategy,
chars: extracted.text.length,
},
});
return;
}
throw new Error(`未知任务类型:${job.type}`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
+137 -3
View File
@@ -1,7 +1,7 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { nowIso } from "./_utils/time";
import { enqueueIngestMediaAssetJob } from "./_utils/ingestJobs";
import { enqueueExtractMediaAssetTextJob, enqueueIngestMediaAssetJob } from "./_utils/ingestJobs";
import type { MutationCtx, QueryCtx } from "./_generated/server";
async function assertWorkspaceMember(ctx: QueryCtx | MutationCtx, userId: string, workspaceId: string) {
@@ -15,6 +15,26 @@ async function assertWorkspaceMember(ctx: QueryCtx | MutationCtx, userId: string
return membership;
}
function shouldExtractAttachmentText(args: {
assetType?: string | null;
mimeType?: string | null;
fileName?: string | null;
}): boolean {
if (String(args.assetType ?? "") !== "file") return false;
const mime = String(args.mimeType ?? "").toLowerCase().trim();
if (mime === "application/pdf") return true;
if (mime === "application/vnd.openxmlformats-officedocument.wordprocessingml.document") return true;
if (mime === "application/vnd.openxmlformats-officedocument.presentationml.presentation") return true;
if (mime === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") return true;
const name = String(args.fileName ?? "").toLowerCase().trim();
if (name.endsWith(".pdf")) return true;
if (name.endsWith(".docx")) return true;
if (name.endsWith(".pptx")) return true;
if (name.endsWith(".xlsx")) return true;
return false;
}
export const getById = query({
args: { userId: v.string(), id: v.string() },
handler: async (ctx, args) => {
@@ -76,6 +96,44 @@ export const listByWorkspace = query({
},
});
export const listSearchDataByWorkspace = query({
args: {
userId: v.string(),
workspaceId: v.string(),
includeDeleted: v.optional(v.boolean()),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
const includeDeleted = Boolean(args.includeDeleted);
const limit = Math.max(1, Math.min(5000, Math.floor(args.limit ?? 5000)));
const rows = await ctx.db
.query("media_assets")
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.order("desc")
.take(limit * 2);
return rows
.filter((r) => (includeDeleted ? true : !r.deleted_at))
.filter((r) => !r.purged_at)
.slice(0, limit)
.map((r) => ({
id: r.id,
workspace_id: r.workspace_id,
document_id: r.document_id,
asset_type: r.asset_type,
file_name: r.file_name ?? null,
mime_type: r.mime_type ?? null,
ocr_text: r.ocr_text ?? null,
ocr_status: r.ocr_status ?? null,
created_at: r.created_at ?? null,
updated_at: r.updated_at ?? null,
}));
},
});
export const listByDocument = query({
args: {
userId: v.string(),
@@ -162,7 +220,13 @@ export const create = mutation({
...args.asset,
storage_id: args.asset.storage_id ?? null,
ocr_text: null,
ocr_status: null,
ocr_status: shouldExtractAttachmentText({
assetType: args.asset.asset_type,
mimeType: args.asset.mime_type,
fileName: args.asset.file_name,
})
? "queued"
: null,
deleted_at: null,
deleted_by: null,
purged_at: null,
@@ -171,6 +235,16 @@ export const create = mutation({
updated_at: ts,
});
if (
shouldExtractAttachmentText({
assetType: args.asset.asset_type,
mimeType: args.asset.mime_type,
fileName: args.asset.file_name,
})
) {
await enqueueExtractMediaAssetTextJob(ctx, { userId: args.userId, assetId: args.asset.id, debounceMs: 800 });
}
return args.asset;
},
});
@@ -222,7 +296,13 @@ export const createWithStorage = mutation({
mime_type: args.asset.mime_type,
ocr_text: null,
ocr_status: null,
ocr_status: shouldExtractAttachmentText({
assetType: args.asset.asset_type,
mimeType: args.asset.mime_type,
fileName: args.asset.file_name,
})
? "queued"
: null,
deleted_at: null,
deleted_by: null,
purged_at: null,
@@ -232,6 +312,16 @@ export const createWithStorage = mutation({
};
await ctx.db.insert("media_assets", row);
if (
shouldExtractAttachmentText({
assetType: args.asset.asset_type,
mimeType: args.asset.mime_type,
fileName: args.asset.file_name,
})
) {
await enqueueExtractMediaAssetTextJob(ctx, { userId: args.userId, assetId: args.asset.id, debounceMs: 800 });
}
return row;
},
});
@@ -312,13 +402,57 @@ export const replaceStorageFromUpload = mutation({
thumbnail_url: url,
bucket: null,
storage_path: null,
ocr_text: null,
ocr_status: shouldExtractAttachmentText({
assetType: row.asset_type,
mimeType: row.mime_type ?? null,
fileName: row.file_name ?? null,
})
? "queued"
: row.ocr_status ?? null,
updated_at: nowIso(),
});
if (
shouldExtractAttachmentText({
assetType: row.asset_type,
mimeType: row.mime_type ?? null,
fileName: row.file_name ?? null,
})
) {
await enqueueExtractMediaAssetTextJob(ctx, { userId: args.userId, assetId: args.id, debounceMs: 800 });
}
return { ok: true, fileUrl: url };
},
});
export const enqueueExtractText = mutation({
args: { userId: v.string(), id: v.string() },
handler: async (ctx, args) => {
const row = await ctx.db
.query("media_assets")
.withIndex("by_asset_id", (q) => q.eq("id", args.id))
.first();
if (!row) throw new Error("资源不存在");
await assertWorkspaceMember(ctx, args.userId, row.workspace_id);
if (
!shouldExtractAttachmentText({
assetType: row.asset_type,
mimeType: row.mime_type ?? null,
fileName: row.file_name ?? null,
})
) {
return { ok: false, reason: "unsupported" as const };
}
await ctx.db.patch(row._id, { ocr_status: "queued", updated_at: nowIso() });
await enqueueExtractMediaAssetTextJob(ctx, { userId: args.userId, assetId: args.id, debounceMs: 0 });
return { ok: true };
},
});
export const purgeById = mutation({
args: { userId: v.string(), id: v.string(), expiredDeletedAt: v.string() },
handler: async (ctx, args) => {
+20
View File
@@ -244,10 +244,14 @@ export default defineSchema({
shared_with_user_id: v.string(),
permission: v.union(v.literal("read"), v.literal("edit")),
include_descendants: v.boolean(),
disable_download: v.optional(v.boolean()),
disable_copy: v.optional(v.boolean()),
created_by: v.string(),
created_at: v.string(),
updated_at: v.string(),
})
.index("by_shared_with_user", ["shared_with_user_id"])
.index("by_created_by", ["created_by"])
.index("by_document", ["document_id"])
.index("by_doc_user", ["document_id", "shared_with_user_id"])
.index("by_workspace_shared_with", ["workspace_id", "shared_with_user_id"])
@@ -275,11 +279,27 @@ export default defineSchema({
.index("by_group_user", ["group_id", "user_id"])
.index("by_workspace_user", ["workspace_id", "user_id"]),
group_invitations: defineTable({
workspace_id: v.string(),
group_id: v.string(),
invited_user_id: v.string(),
invited_by: v.string(),
created_at: v.string(),
updated_at: v.string(),
})
.index("by_invited_user", ["invited_user_id"])
.index("by_group_user", ["group_id", "invited_user_id"])
.index("by_workspace_invited_user", ["workspace_id", "invited_user_id"])
.index("by_workspace_group", ["workspace_id", "group_id"])
.index("by_workspace_invited_by", ["workspace_id", "invited_by"]),
document_group_shares: defineTable({
workspace_id: v.string(),
group_id: v.string(),
document_id: v.string(),
include_descendants: v.boolean(),
disable_download: v.optional(v.boolean()),
disable_copy: v.optional(v.boolean()),
created_by: v.string(),
created_at: v.string(),
updated_at: v.string(),
+79
View File
@@ -2,6 +2,18 @@ import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { nowIso } from "./_utils/time";
import { generateId } from "./_utils/id";
import type { MutationCtx, QueryCtx } from "./_generated/server";
async function assertWorkspaceMember(ctx: QueryCtx | MutationCtx, userId: string, workspaceId: string) {
const membership = await ctx.db
.query("workspace_members")
.withIndex("by_workspace_user", (q) => q.eq("workspace_id", workspaceId).eq("user_id", userId))
.first();
if (!membership) {
throw new Error("无权访问该工作空间");
}
return membership;
}
// Query: 获取单个表格
export const get = query({
@@ -102,6 +114,40 @@ export const listByDocument = query({
},
});
export const listByWorkspaceForSearch = query({
args: {
userId: v.string(),
workspaceId: v.string(),
includeArchived: v.optional(v.boolean()),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
const includeArchived = Boolean(args.includeArchived);
const limit = Math.max(1, Math.min(3000, Math.floor(args.limit ?? 3000)));
const rows = await ctx.db
.query("document_tables")
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.order("desc")
.take(limit * 2);
return rows
.filter((t) => (includeArchived ? true : !t.is_archived))
.slice(0, limit)
.map((t) => ({
id: t.id,
workspace_id: t.workspace_id,
document_id: t.document_id,
title: t.title,
is_archived: t.is_archived,
created_at: t.created_at,
updated_at: t.updated_at,
}));
},
});
// Mutation: 创建表格
export const create = mutation({
args: {
@@ -287,3 +333,36 @@ export const getRows = query({
.map((r) => r.row_data);
},
});
export const listRowsByWorkspaceForSearch = query({
args: {
userId: v.string(),
workspaceId: v.string(),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
const limit = Math.max(1, Math.min(8000, Math.floor(args.limit ?? 8000)));
const rows = await ctx.db
.query("document_table_rows")
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.order("desc")
.take(limit * 2);
return rows
.filter((r) => !r.is_deleted)
.slice(0, limit)
.map((r) => ({
id: r.id,
workspace_id: r.workspace_id,
document_id: r.document_id,
table_id: r.table_id,
row_index: r.row_index,
row_hash: r.row_hash ?? null,
created_at: r.created_at ?? null,
updated_at: r.updated_at ?? null,
}));
},
});