0.4.0 convex及界面修改
This commit is contained in:
+2
@@ -16,6 +16,7 @@ import type * as _utils_lightrag from "../_utils/lightrag.js";
|
||||
import type * as _utils_text from "../_utils/text.js";
|
||||
import type * as _utils_time from "../_utils/time.js";
|
||||
import type * as auth from "../auth.js";
|
||||
import type * as comments from "../comments.js";
|
||||
import type * as documentGroupShares from "../documentGroupShares.js";
|
||||
import type * as documentShares from "../documentShares.js";
|
||||
import type * as documentStars from "../documentStars.js";
|
||||
@@ -49,6 +50,7 @@ declare const fullApi: ApiFromModules<{
|
||||
"_utils/text": typeof _utils_text;
|
||||
"_utils/time": typeof _utils_time;
|
||||
auth: typeof auth;
|
||||
comments: typeof comments;
|
||||
documentGroupShares: typeof documentGroupShares;
|
||||
documentShares: typeof documentShares;
|
||||
documentStars: typeof documentStars;
|
||||
|
||||
@@ -3,10 +3,21 @@ type IngestTextArgs = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
export async function lightragIngestText(args: IngestTextArgs): Promise<{ trackId: string | null }> {
|
||||
type IngestTextResult = {
|
||||
trackId: string | null;
|
||||
skipped?: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export function isLightRagEnabled(): boolean {
|
||||
return Boolean((process.env.LIGHTRAG_URL || "").trim());
|
||||
}
|
||||
|
||||
export async function lightragIngestText(args: IngestTextArgs): Promise<IngestTextResult> {
|
||||
const baseUrl = (process.env.LIGHTRAG_URL || "").trim().replace(/\/+$/, "");
|
||||
if (!baseUrl) {
|
||||
throw new Error("缺少环境变量:LIGHTRAG_URL");
|
||||
// 说明:LightRAG 未配置时不应让任务系统持续报错;直接降级为“跳过入库”。
|
||||
return { trackId: null, skipped: true, reason: "missing_env_LIGHTRAG_URL" };
|
||||
}
|
||||
|
||||
const fileSource = String(args.fileSource ?? "").trim();
|
||||
@@ -16,7 +27,7 @@ export async function lightragIngestText(args: IngestTextArgs): Promise<{ trackI
|
||||
|
||||
const text = String(args.text ?? "");
|
||||
if (!text.trim()) {
|
||||
return { trackId: null };
|
||||
return { trackId: null, skipped: true, reason: "empty_text" };
|
||||
}
|
||||
|
||||
const apiKey = (process.env.LIGHTRAG_API_KEY || "").trim();
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { nowIso } from "./_utils/time";
|
||||
|
||||
async function requireUserId(ctx: any): Promise<string> {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (userId === null) {
|
||||
throw new Error("未登录");
|
||||
}
|
||||
return 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;
|
||||
}
|
||||
|
||||
type SharePermission = "read" | "edit";
|
||||
|
||||
async function resolveSharePermission(ctx: any, doc: any, userId: string): Promise<SharePermission | 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;
|
||||
}
|
||||
|
||||
let parentId: string | null = doc.parent_id ?? null;
|
||||
for (let depth = 0; depth < 60 && parentId; depth += 1) {
|
||||
const parentShare = await ctx.db
|
||||
.query("document_shares")
|
||||
.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;
|
||||
}
|
||||
|
||||
const parent = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q: any) => q.eq("id", parentId))
|
||||
.first();
|
||||
parentId = parent?.parent_id ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveGroupSharePermission(ctx: any, doc: any, userId: string): Promise<SharePermission | 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))
|
||||
.collect();
|
||||
if (!memberships.length) return null;
|
||||
const groupIds = new Set(memberships.map((m: any) => String(m.group_id)));
|
||||
|
||||
const direct = await ctx.db
|
||||
.query("document_group_shares")
|
||||
.withIndex("by_document", (q: any) => q.eq("document_id", doc.id))
|
||||
.collect();
|
||||
for (const row of direct) {
|
||||
if (groupIds.has(String(row.group_id))) {
|
||||
return "read";
|
||||
}
|
||||
}
|
||||
|
||||
// 说明:群组共享的“包含子页面”逻辑在 documents.ts 内已实现;这里做一个最小的祖先扫描即可。
|
||||
let parentId: string | null = doc.parent_id ?? null;
|
||||
for (let depth = 0; depth < 60 && parentId; depth += 1) {
|
||||
const parentShares = await ctx.db
|
||||
.query("document_group_shares")
|
||||
.withIndex("by_document", (q: any) => q.eq("document_id", parentId))
|
||||
.collect();
|
||||
for (const row of parentShares) {
|
||||
if (row.include_descendants && groupIds.has(String(row.group_id))) {
|
||||
return "read";
|
||||
}
|
||||
}
|
||||
const parent = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q: any) => q.eq("id", parentId))
|
||||
.first();
|
||||
parentId = parent?.parent_id ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function requireCanViewDocument(ctx: any, doc: any, userId: string) {
|
||||
await requireWorkspaceMember(ctx, doc.workspace_id, userId);
|
||||
if (doc.user_id === userId) return;
|
||||
if (doc.access_scope === "public") return;
|
||||
const perm =
|
||||
(await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId));
|
||||
if (!perm) {
|
||||
throw new Error("无权限");
|
||||
}
|
||||
}
|
||||
|
||||
async function requireCanModerateDocument(ctx: any, doc: any, userId: string) {
|
||||
await requireWorkspaceMember(ctx, doc.workspace_id, userId);
|
||||
if (doc.user_id === userId) return;
|
||||
if (doc.access_scope === "public") throw new Error("无权限");
|
||||
const perm =
|
||||
(await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId));
|
||||
if (perm !== "edit") {
|
||||
throw new Error("无权限");
|
||||
}
|
||||
}
|
||||
|
||||
const pickUserLite = (user: any) => {
|
||||
if (!user) return null;
|
||||
return {
|
||||
id: String(user._id),
|
||||
name: (user.name as string | undefined) ?? null,
|
||||
image: (user.image as string | undefined) ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
export const listThreadsByDocument = query({
|
||||
args: {
|
||||
documentId: v.string(),
|
||||
includeResolved: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const doc = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q: any) => q.eq("id", args.documentId))
|
||||
.first();
|
||||
if (!doc) throw new Error("页面不存在");
|
||||
if (doc.deleted_at != null) throw new Error("页面不存在");
|
||||
await requireCanViewDocument(ctx, doc, userId);
|
||||
|
||||
const threads = await ctx.db
|
||||
.query("comment_threads")
|
||||
.withIndex("by_document", (q: any) => q.eq("document_id", args.documentId))
|
||||
.collect();
|
||||
|
||||
const includeResolved = Boolean(args.includeResolved);
|
||||
const filtered = threads.filter((t: any) => includeResolved || t.resolved_at == null);
|
||||
filtered.sort((a: any, b: any) => String(b.last_activity_at).localeCompare(String(a.last_activity_at)));
|
||||
|
||||
const userIds = new Set<string>();
|
||||
filtered.forEach((t: any) => {
|
||||
if (t.created_by) userIds.add(String(t.created_by));
|
||||
if (t.last_comment_by) userIds.add(String(t.last_comment_by));
|
||||
if (t.resolved_by) userIds.add(String(t.resolved_by));
|
||||
});
|
||||
|
||||
const userById = new Map<string, any>();
|
||||
for (const uid of userIds) {
|
||||
const u = await ctx.db.get(uid as any);
|
||||
if (u) userById.set(uid, u);
|
||||
}
|
||||
|
||||
return filtered.map((t: any) => ({
|
||||
id: t.id,
|
||||
workspaceId: t.workspace_id,
|
||||
documentId: t.document_id,
|
||||
blockId: t.block_id ?? null,
|
||||
resolvedAt: t.resolved_at ?? null,
|
||||
resolvedBy: pickUserLite(userById.get(String(t.resolved_by ?? ""))),
|
||||
commentCount: Number(t.comment_count ?? 0),
|
||||
lastActivityAt: t.last_activity_at,
|
||||
lastCommentPreview: t.last_comment_preview ?? null,
|
||||
lastCommentBy: pickUserLite(userById.get(String(t.last_comment_by ?? ""))),
|
||||
createdAt: t.created_at,
|
||||
createdBy: pickUserLite(userById.get(String(t.created_by ?? ""))),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export const listMessagesByThread = query({
|
||||
args: { threadId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const thread = await ctx.db
|
||||
.query("comment_threads")
|
||||
.withIndex("by_thread_id", (q: any) => q.eq("id", args.threadId))
|
||||
.first();
|
||||
if (!thread) throw new Error("评论线程不存在");
|
||||
|
||||
const doc = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q: any) => q.eq("id", thread.document_id))
|
||||
.first();
|
||||
if (!doc) throw new Error("页面不存在");
|
||||
if (doc.deleted_at != null) throw new Error("页面不存在");
|
||||
await requireCanViewDocument(ctx, doc, userId);
|
||||
|
||||
const messages = await ctx.db
|
||||
.query("comment_messages")
|
||||
.withIndex("by_thread", (q: any) => q.eq("thread_id", args.threadId))
|
||||
.collect();
|
||||
messages.sort((a: any, b: any) => String(a.created_at).localeCompare(String(b.created_at)));
|
||||
|
||||
const userIds = new Set<string>();
|
||||
messages.forEach((m: any) => {
|
||||
if (m.created_by) userIds.add(String(m.created_by));
|
||||
if (m.deleted_by) userIds.add(String(m.deleted_by));
|
||||
});
|
||||
const userById = new Map<string, any>();
|
||||
for (const uid of userIds) {
|
||||
const u = await ctx.db.get(uid as any);
|
||||
if (u) userById.set(uid, u);
|
||||
}
|
||||
|
||||
return messages.map((m: any) => ({
|
||||
id: m.id,
|
||||
threadId: m.thread_id,
|
||||
documentId: m.document_id,
|
||||
blockId: m.block_id ?? null,
|
||||
parentId: m.parent_id ?? null,
|
||||
body: m.deleted_at ? "" : m.body,
|
||||
deletedAt: m.deleted_at ?? null,
|
||||
createdAt: m.created_at,
|
||||
createdBy: pickUserLite(userById.get(String(m.created_by ?? ""))),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export const createThread = mutation({
|
||||
args: {
|
||||
id: v.string(),
|
||||
documentId: v.string(),
|
||||
workspaceId: v.string(),
|
||||
blockId: v.optional(v.union(v.string(), v.null())),
|
||||
messageId: v.string(),
|
||||
body: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const doc = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q: any) => q.eq("id", args.documentId))
|
||||
.first();
|
||||
if (!doc) throw new Error("页面不存在");
|
||||
if (doc.deleted_at != null) throw new Error("页面不存在");
|
||||
await requireCanViewDocument(ctx, doc, userId);
|
||||
if (String(doc.workspace_id) !== String(args.workspaceId)) {
|
||||
throw new Error("workspaceId 不匹配");
|
||||
}
|
||||
|
||||
const body = args.body.trim();
|
||||
if (!body) throw new Error("评论内容不能为空");
|
||||
if (body.length > 4000) throw new Error("评论内容过长(最大 4000 字符)");
|
||||
|
||||
const ts = nowIso();
|
||||
await ctx.db.insert("comment_threads", {
|
||||
id: args.id,
|
||||
workspace_id: args.workspaceId,
|
||||
document_id: args.documentId,
|
||||
block_id: args.blockId ?? null,
|
||||
resolved_at: null,
|
||||
resolved_by: null,
|
||||
comment_count: 1,
|
||||
last_activity_at: ts,
|
||||
last_comment_preview: body.slice(0, 160),
|
||||
last_comment_by: userId,
|
||||
created_by: userId,
|
||||
created_at: ts,
|
||||
});
|
||||
|
||||
await ctx.db.insert("comment_messages", {
|
||||
id: args.messageId,
|
||||
thread_id: args.id,
|
||||
workspace_id: args.workspaceId,
|
||||
document_id: args.documentId,
|
||||
block_id: args.blockId ?? null,
|
||||
parent_id: null,
|
||||
body,
|
||||
created_by: userId,
|
||||
created_at: ts,
|
||||
deleted_at: null,
|
||||
deleted_by: null,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const reply = mutation({
|
||||
args: {
|
||||
messageId: v.string(),
|
||||
threadId: v.string(),
|
||||
body: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const thread = await ctx.db
|
||||
.query("comment_threads")
|
||||
.withIndex("by_thread_id", (q: any) => q.eq("id", args.threadId))
|
||||
.first();
|
||||
if (!thread) throw new Error("评论线程不存在");
|
||||
|
||||
const doc = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q: any) => q.eq("id", thread.document_id))
|
||||
.first();
|
||||
if (!doc) throw new Error("页面不存在");
|
||||
if (doc.deleted_at != null) throw new Error("页面不存在");
|
||||
await requireCanViewDocument(ctx, doc, userId);
|
||||
|
||||
const body = args.body.trim();
|
||||
if (!body) throw new Error("评论内容不能为空");
|
||||
if (body.length > 4000) throw new Error("评论内容过长(最大 4000 字符)");
|
||||
|
||||
const ts = nowIso();
|
||||
await ctx.db.insert("comment_messages", {
|
||||
id: args.messageId,
|
||||
thread_id: args.threadId,
|
||||
workspace_id: thread.workspace_id,
|
||||
document_id: thread.document_id,
|
||||
block_id: thread.block_id ?? null,
|
||||
parent_id: null,
|
||||
body,
|
||||
created_by: userId,
|
||||
created_at: ts,
|
||||
deleted_at: null,
|
||||
deleted_by: null,
|
||||
});
|
||||
|
||||
await ctx.db.patch(thread._id, {
|
||||
comment_count: Number(thread.comment_count ?? 0) + 1,
|
||||
last_activity_at: ts,
|
||||
last_comment_preview: body.slice(0, 160),
|
||||
last_comment_by: userId,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const setResolved = mutation({
|
||||
args: { threadId: v.string(), resolved: v.boolean() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const thread = await ctx.db
|
||||
.query("comment_threads")
|
||||
.withIndex("by_thread_id", (q: any) => q.eq("id", args.threadId))
|
||||
.first();
|
||||
if (!thread) throw new Error("评论线程不存在");
|
||||
|
||||
const doc = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q: any) => q.eq("id", thread.document_id))
|
||||
.first();
|
||||
if (!doc) throw new Error("页面不存在");
|
||||
if (doc.deleted_at != null) throw new Error("页面不存在");
|
||||
|
||||
// 说明:解决/取消解决属于“管理”行为,要求可编辑权限,避免只读用户随意改状态。
|
||||
await requireCanModerateDocument(ctx, doc, userId);
|
||||
|
||||
const ts = nowIso();
|
||||
await ctx.db.patch(thread._id, {
|
||||
resolved_at: args.resolved ? ts : null,
|
||||
resolved_by: args.resolved ? userId : null,
|
||||
last_activity_at: ts,
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const editMessage = mutation({
|
||||
args: { messageId: v.string(), body: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const message = await ctx.db
|
||||
.query("comment_messages")
|
||||
.withIndex("by_message_id", (q: any) => q.eq("id", args.messageId))
|
||||
.first();
|
||||
if (!message) throw new Error("评论不存在");
|
||||
if (message.deleted_at != null) throw new Error("该评论已删除");
|
||||
if (String(message.created_by) !== String(userId)) throw new Error("无权限");
|
||||
|
||||
const thread = await ctx.db
|
||||
.query("comment_threads")
|
||||
.withIndex("by_thread_id", (q: any) => q.eq("id", message.thread_id))
|
||||
.first();
|
||||
if (!thread) throw new Error("评论线程不存在");
|
||||
|
||||
const doc = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q: any) => q.eq("id", message.document_id))
|
||||
.first();
|
||||
if (!doc) throw new Error("页面不存在");
|
||||
if (doc.deleted_at != null) throw new Error("页面不存在");
|
||||
await requireCanViewDocument(ctx, doc, userId);
|
||||
|
||||
const body = args.body.trim();
|
||||
if (!body) throw new Error("评论内容不能为空");
|
||||
if (body.length > 4000) throw new Error("评论内容过长(最大 4000 字符)");
|
||||
|
||||
const ts = nowIso();
|
||||
await ctx.db.patch(message._id, { body });
|
||||
|
||||
const messages = await ctx.db
|
||||
.query("comment_messages")
|
||||
.withIndex("by_thread", (q: any) => q.eq("thread_id", message.thread_id))
|
||||
.collect();
|
||||
messages.sort((a: any, b: any) => String(b.created_at).localeCompare(String(a.created_at)));
|
||||
const latestAlive = messages.find((m: any) => m.deleted_at == null) ?? null;
|
||||
|
||||
await ctx.db.patch(thread._id, {
|
||||
last_activity_at: ts,
|
||||
...(latestAlive && String(latestAlive.id) === String(message.id)
|
||||
? { last_comment_preview: body.slice(0, 160), last_comment_by: userId }
|
||||
: {}),
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteMessage = mutation({
|
||||
args: { messageId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const message = await ctx.db
|
||||
.query("comment_messages")
|
||||
.withIndex("by_message_id", (q: any) => q.eq("id", args.messageId))
|
||||
.first();
|
||||
if (!message) throw new Error("评论不存在");
|
||||
if (message.deleted_at != null) return { ok: true };
|
||||
|
||||
const thread = await ctx.db
|
||||
.query("comment_threads")
|
||||
.withIndex("by_thread_id", (q: any) => q.eq("id", message.thread_id))
|
||||
.first();
|
||||
if (!thread) throw new Error("评论线程不存在");
|
||||
|
||||
const doc = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q: any) => q.eq("id", message.document_id))
|
||||
.first();
|
||||
if (!doc) throw new Error("页面不存在");
|
||||
if (doc.deleted_at != null) throw new Error("页面不存在");
|
||||
|
||||
if (String(message.created_by) === String(userId)) {
|
||||
await requireCanViewDocument(ctx, doc, userId);
|
||||
} else {
|
||||
await requireCanModerateDocument(ctx, doc, userId);
|
||||
}
|
||||
|
||||
const ts = nowIso();
|
||||
await ctx.db.patch(message._id, { deleted_at: ts, deleted_by: userId });
|
||||
|
||||
const messages = await ctx.db
|
||||
.query("comment_messages")
|
||||
.withIndex("by_thread", (q: any) => q.eq("thread_id", message.thread_id))
|
||||
.collect();
|
||||
messages.sort((a: any, b: any) => String(b.created_at).localeCompare(String(a.created_at)));
|
||||
const latestAlive = messages.find((m: any) => m.deleted_at == null) ?? null;
|
||||
|
||||
await ctx.db.patch(thread._id, {
|
||||
last_activity_at: ts,
|
||||
last_comment_preview: latestAlive ? String(latestAlive.body ?? "").slice(0, 160) : null,
|
||||
last_comment_by: latestAlive ? String(latestAlive.created_by ?? "") : null,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cronJobs } from "convex/server";
|
||||
import { internal } from "./_generated/api";
|
||||
|
||||
const crons = cronJobs();
|
||||
|
||||
// 每周定时清理(保留最近 7 天):authRefreshTokens / authSessions / jobs
|
||||
// 说明:使用 UTC 时间,避免本地时区/夏令时导致触发不稳定。
|
||||
crons.weekly(
|
||||
"cleanup_weekly_keep_last_7_days",
|
||||
{ dayOfWeek: "sunday", hourUTC: 3, minuteUTC: 0 },
|
||||
(internal as any).maintenance.cleanupWeekly,
|
||||
);
|
||||
|
||||
export default crons;
|
||||
|
||||
@@ -178,6 +178,154 @@ async function purgeDocumentShareRelations(ctx: any, workspaceId: string, docume
|
||||
}
|
||||
}
|
||||
|
||||
async function purgeDocumentRelatedData(ctx: any, workspaceId: string, documentIds: string[]) {
|
||||
const uniqueDocIds = Array.from(new Set(documentIds.filter(Boolean)));
|
||||
if (uniqueDocIds.length === 0) return;
|
||||
|
||||
// 1) 思维导图
|
||||
for (const docId of uniqueDocIds) {
|
||||
const rows = await ctx.db
|
||||
.query("mindmaps")
|
||||
.withIndex("by_doc_mindmap", (q: any) => q.eq("document_id", docId))
|
||||
.collect();
|
||||
for (const r of rows) {
|
||||
await ctx.db.delete(r._id);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 附件(含底层 storage 文件)
|
||||
const assets: any[] = [];
|
||||
for (const docId of uniqueDocIds) {
|
||||
const rows = await ctx.db
|
||||
.query("media_assets")
|
||||
.withIndex("by_document", (q: any) => q.eq("document_id", docId))
|
||||
.collect();
|
||||
assets.push(...rows);
|
||||
}
|
||||
|
||||
if (assets.length) {
|
||||
const assetIdsToDelete = new Set<string>(assets.map((a) => String(a.id)));
|
||||
const byStorage = new Map<string, string[]>();
|
||||
for (const a of assets) {
|
||||
const sid = (a.storage_id as any) ?? null;
|
||||
if (!sid) continue;
|
||||
const list = byStorage.get(String(sid)) ?? [];
|
||||
list.push(String(a.id));
|
||||
byStorage.set(String(sid), list);
|
||||
}
|
||||
|
||||
for (const [sid] of byStorage.entries()) {
|
||||
const refs = await ctx.db
|
||||
.query("media_assets")
|
||||
.withIndex("by_storage_id", (q: any) => q.eq("storage_id", sid as any))
|
||||
.collect();
|
||||
// 说明:只要仍有“未清理”的引用(包括仍在垃圾桶但可恢复的记录),就不要删底层文件。
|
||||
const otherAlive = refs.some((r: any) => !assetIdsToDelete.has(String(r.id)) && !r.purged_at);
|
||||
if (!otherAlive) {
|
||||
try {
|
||||
await ctx.storage.delete(sid as any);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const a of assets) {
|
||||
await ctx.db.delete(a._id);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 在线表格(含行数据)
|
||||
for (const docId of uniqueDocIds) {
|
||||
const tables = await ctx.db
|
||||
.query("document_tables")
|
||||
.withIndex("by_document", (q: any) => q.eq("document_id", docId))
|
||||
.collect();
|
||||
for (const table of tables) {
|
||||
const rows = await ctx.db
|
||||
.query("document_table_rows")
|
||||
.withIndex("by_table", (q: any) => q.eq("table_id", table.id))
|
||||
.collect();
|
||||
for (const r of rows) {
|
||||
await ctx.db.delete(r._id);
|
||||
}
|
||||
await ctx.db.delete(table._id);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 评论
|
||||
for (const docId of uniqueDocIds) {
|
||||
const msgs = await ctx.db
|
||||
.query("comment_messages")
|
||||
.withIndex("by_document", (q: any) => q.eq("document_id", docId))
|
||||
.collect();
|
||||
for (const m of msgs) {
|
||||
await ctx.db.delete(m._id);
|
||||
}
|
||||
|
||||
const threads = await ctx.db
|
||||
.query("comment_threads")
|
||||
.withIndex("by_document", (q: any) => q.eq("document_id", docId))
|
||||
.collect();
|
||||
for (const t of threads) {
|
||||
await ctx.db.delete(t._id);
|
||||
}
|
||||
}
|
||||
|
||||
// 5) 页面引用/反链(source/target 任一命中即删除)
|
||||
const deletedRefIds = new Set<string>();
|
||||
for (const docId of uniqueDocIds) {
|
||||
const bySource = await ctx.db
|
||||
.query("page_references")
|
||||
.withIndex("by_workspace_source", (q: any) => q.eq("workspace_id", workspaceId).eq("source_page_id", docId))
|
||||
.collect();
|
||||
for (const r of bySource) {
|
||||
const id = String(r._id);
|
||||
if (deletedRefIds.has(id)) continue;
|
||||
deletedRefIds.add(id);
|
||||
await ctx.db.delete(r._id);
|
||||
}
|
||||
|
||||
const byTarget = await ctx.db
|
||||
.query("page_references")
|
||||
.withIndex("by_workspace_target", (q: any) => q.eq("workspace_id", workspaceId).eq("target_page_id", docId))
|
||||
.collect();
|
||||
for (const r of byTarget) {
|
||||
const id = String(r._id);
|
||||
if (deletedRefIds.has(id)) continue;
|
||||
deletedRefIds.add(id);
|
||||
await ctx.db.delete(r._id);
|
||||
}
|
||||
}
|
||||
|
||||
// 6) 收藏(避免残留用户数据)
|
||||
for (const docId of uniqueDocIds) {
|
||||
const stars = await ctx.db
|
||||
.query("document_stars")
|
||||
.withIndex("by_workspace_document_user", (q: any) => q.eq("workspace_id", workspaceId).eq("document_id", docId))
|
||||
.collect();
|
||||
for (const s of stars) {
|
||||
await ctx.db.delete(s._id);
|
||||
}
|
||||
}
|
||||
|
||||
// 7) 最近访问(避免“最近/历史”里出现幽灵页面)
|
||||
// 说明:user_recent_pages 当前缺少按 document_id 的索引,这里先全表扫描再过滤。
|
||||
// 若未来数据量变大,再考虑加 index("by_document", ["document_id"]) 或按 workspace/user 拆分索引。
|
||||
const recents = await ctx.db.query("user_recent_pages").collect();
|
||||
if (recents.length) {
|
||||
const docIdSet = new Set(uniqueDocIds);
|
||||
for (const r of recents) {
|
||||
if (!docIdSet.has(String((r as any).document_id ?? ""))) continue;
|
||||
await ctx.db.delete(r._id);
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底:如果未来新增了其它 “document_id 外键表”,这里可以继续补充;
|
||||
// 当前先把最容易产生垃圾、且已出现历史堆积的表(media_assets/mindmaps/表格/评论/引用/收藏)清理掉。
|
||||
// 同时,避免误删“非 document_id 维度”的用户行为表(如 jobs 等),后续如确认需要可再补齐。
|
||||
}
|
||||
|
||||
export const getMeta = query({
|
||||
args: { id: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -228,9 +376,17 @@ export const getMeta = query({
|
||||
show_structure: doc.show_structure ?? null,
|
||||
protect_editing: doc.protect_editing ?? null,
|
||||
show_word_count: doc.show_word_count ?? null,
|
||||
collapse_backlinks: (doc as any).collapse_backlinks ?? null,
|
||||
page_font: (doc as any).page_font ?? null,
|
||||
layout_density: (doc as any).layout_density ?? null,
|
||||
hide_child_pages: (doc as any).hide_child_pages ?? null,
|
||||
show_block_ref_count: (doc as any).show_block_ref_count ?? null,
|
||||
embed_default_block_id: (doc as any).embed_default_block_id ?? null,
|
||||
word_count: doc.word_count ?? null,
|
||||
character_count: doc.character_count ?? null,
|
||||
block_count: doc.block_count ?? null,
|
||||
todo_total_count: (doc as any).todo_total_count ?? null,
|
||||
todo_done_count: (doc as any).todo_done_count ?? null,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -674,9 +830,17 @@ export const create = mutation({
|
||||
show_structure: false,
|
||||
protect_editing: false,
|
||||
show_word_count: true,
|
||||
collapse_backlinks: false,
|
||||
page_font: "default",
|
||||
layout_density: "normal",
|
||||
hide_child_pages: false,
|
||||
show_block_ref_count: false,
|
||||
embed_default_block_id: null,
|
||||
word_count: 0,
|
||||
character_count: 0,
|
||||
block_count: 0,
|
||||
todo_total_count: 0,
|
||||
todo_done_count: 0,
|
||||
created_at: ts,
|
||||
updated_at: ts,
|
||||
deleted_at: null,
|
||||
@@ -751,6 +915,32 @@ export const updateTitle = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const setTemplate = mutation({
|
||||
args: { id: v.string(), isTemplate: v.boolean() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const doc = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
||||
.first();
|
||||
if (!doc) throw new Error("页面不存在");
|
||||
if (doc.deleted_at != null) throw new Error("页面不存在");
|
||||
await requireWorkspaceMember(ctx, doc.workspace_id, userId);
|
||||
|
||||
if (doc.user_id !== userId) {
|
||||
if (doc.access_scope === "public") throw new Error("无权限");
|
||||
const perm =
|
||||
(await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId));
|
||||
if (perm !== "edit") throw new Error("无权限");
|
||||
}
|
||||
|
||||
const ts = nowIso();
|
||||
await ctx.db.patch(doc._id, { is_template: args.isTemplate, updated_at: ts });
|
||||
return { ok: true, updated_at: ts };
|
||||
},
|
||||
});
|
||||
|
||||
export const move = mutation({
|
||||
args: {
|
||||
id: v.string(),
|
||||
@@ -941,6 +1131,7 @@ export const purge = mutation({
|
||||
const subtree = collectSubtree(owned, doc.id);
|
||||
|
||||
// 删除顺序对当前数据模型无强制要求,这里简单逐个删除即可。
|
||||
await purgeDocumentRelatedData(ctx, doc.workspace_id, subtree.map((d) => d.id));
|
||||
for (const item of subtree) {
|
||||
await purgeDocumentShareRelations(ctx, doc.workspace_id, item.id);
|
||||
await ctx.db.delete(item._id);
|
||||
@@ -971,6 +1162,7 @@ export const emptyTrashByWorkspace = mutation({
|
||||
.collect();
|
||||
|
||||
const toDelete = docs.filter((d) => d.user_id === userId && d.deleted_at != null);
|
||||
await purgeDocumentRelatedData(ctx, args.workspaceId, toDelete.map((d) => d.id));
|
||||
for (const d of toDelete) {
|
||||
await purgeDocumentShareRelations(ctx, args.workspaceId, d.id);
|
||||
await ctx.db.delete(d._id);
|
||||
@@ -991,6 +1183,12 @@ export const updateOptions = mutation({
|
||||
showStructure: v.optional(v.boolean()),
|
||||
protectEditing: v.optional(v.boolean()),
|
||||
showWordCount: v.optional(v.boolean()),
|
||||
collapseBacklinks: v.optional(v.boolean()),
|
||||
pageFont: v.optional(v.union(v.literal("default"), v.literal("song"), v.literal("kai"))),
|
||||
layoutDensity: v.optional(v.union(v.literal("compact"), v.literal("normal"), v.literal("spacious"))),
|
||||
hideChildPages: v.optional(v.boolean()),
|
||||
showBlockRefCount: v.optional(v.boolean()),
|
||||
embedDefaultBlockId: v.optional(v.union(v.string(), v.null())),
|
||||
}),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
@@ -1019,6 +1217,15 @@ export const updateOptions = mutation({
|
||||
if (typeof args.options.showStructure === "boolean") patch.show_structure = args.options.showStructure;
|
||||
if (typeof args.options.protectEditing === "boolean") patch.protect_editing = args.options.protectEditing;
|
||||
if (typeof args.options.showWordCount === "boolean") patch.show_word_count = args.options.showWordCount;
|
||||
if (typeof args.options.collapseBacklinks === "boolean") patch.collapse_backlinks = args.options.collapseBacklinks;
|
||||
if (typeof (args.options as any).pageFont === "string") patch.page_font = (args.options as any).pageFont;
|
||||
if (typeof (args.options as any).layoutDensity === "string") patch.layout_density = (args.options as any).layoutDensity;
|
||||
if (typeof (args.options as any).hideChildPages === "boolean") patch.hide_child_pages = (args.options as any).hideChildPages;
|
||||
if (typeof (args.options as any).showBlockRefCount === "boolean")
|
||||
patch.show_block_ref_count = (args.options as any).showBlockRefCount;
|
||||
if (typeof (args.options as any).embedDefaultBlockId === "string" || (args.options as any).embedDefaultBlockId === null) {
|
||||
patch.embed_default_block_id = (args.options as any).embedDefaultBlockId;
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
throw new Error("缺少可更新的选项");
|
||||
@@ -1036,6 +1243,8 @@ export const updateStats = mutation({
|
||||
wordCount: v.number(),
|
||||
characterCount: v.number(),
|
||||
blockCount: v.number(),
|
||||
todoTotal: v.optional(v.number()),
|
||||
todoDone: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
@@ -1058,6 +1267,8 @@ export const updateStats = mutation({
|
||||
word_count: args.wordCount,
|
||||
character_count: args.characterCount,
|
||||
block_count: args.blockCount,
|
||||
todo_total_count: typeof args.todoTotal === "number" ? args.todoTotal : (doc as any).todo_total_count ?? 0,
|
||||
todo_done_count: typeof args.todoDone === "number" ? args.todoDone : (doc as any).todo_done_count ?? 0,
|
||||
updated_at: ts,
|
||||
});
|
||||
return { ok: true, updated_at: ts };
|
||||
@@ -1113,9 +1324,17 @@ export const duplicate = mutation({
|
||||
show_structure: source.show_structure ?? false,
|
||||
protect_editing: source.protect_editing ?? false,
|
||||
show_word_count: source.show_word_count ?? true,
|
||||
collapse_backlinks: (source as any).collapse_backlinks ?? false,
|
||||
page_font: (source as any).page_font ?? "default",
|
||||
layout_density: (source as any).layout_density ?? "normal",
|
||||
hide_child_pages: (source as any).hide_child_pages ?? false,
|
||||
show_block_ref_count: (source as any).show_block_ref_count ?? false,
|
||||
embed_default_block_id: (source as any).embed_default_block_id ?? null,
|
||||
word_count: source.word_count ?? 0,
|
||||
character_count: source.character_count ?? 0,
|
||||
block_count: source.block_count ?? 0,
|
||||
todo_total_count: (source as any).todo_total_count ?? 0,
|
||||
todo_done_count: (source as any).todo_done_count ?? 0,
|
||||
|
||||
created_at: ts,
|
||||
updated_at: ts,
|
||||
|
||||
@@ -132,11 +132,18 @@ export const run = internalAction({
|
||||
const title = meta.title ?? "无标题";
|
||||
const text = extractTextFromDocumentContent(contentRes?.content ?? null);
|
||||
const fileSource = `document:${documentId}`;
|
||||
const { trackId } = await lightragIngestText({ fileSource, text: `# ${title}\n\n${text}` });
|
||||
const ingest = await lightragIngestText({ fileSource, text: `# ${title}\n\n${text}` });
|
||||
if (ingest.skipped) {
|
||||
await ctx.runMutation(internal.jobs.finishSuccess, {
|
||||
id: args.id,
|
||||
result: { ok: true, kind: "document", documentId, skipped: true, reason: ingest.reason ?? "skipped" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.runMutation(internal.jobs.finishSuccess, {
|
||||
id: args.id,
|
||||
result: { ok: true, kind: "document", documentId, trackId },
|
||||
result: { ok: true, kind: "document", documentId, trackId: ingest.trackId },
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -165,11 +172,18 @@ export const run = internalAction({
|
||||
const title = docMeta.title ?? "无标题";
|
||||
const text = extractTextFromMindmapData(mindmapRes.data ?? null);
|
||||
const fileSource = `mindmap:${docId}:${mindmapId}`;
|
||||
const { trackId } = await lightragIngestText({ fileSource, text: `# ${title}\n\n${text}` });
|
||||
const ingest = await lightragIngestText({ fileSource, text: `# ${title}\n\n${text}` });
|
||||
if (ingest.skipped) {
|
||||
await ctx.runMutation(internal.jobs.finishSuccess, {
|
||||
id: args.id,
|
||||
result: { ok: true, kind: "mindmap", docId, mindmapId, skipped: true, reason: ingest.reason ?? "skipped" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.runMutation(internal.jobs.finishSuccess, {
|
||||
id: args.id,
|
||||
result: { ok: true, kind: "mindmap", docId, mindmapId, trackId },
|
||||
result: { ok: true, kind: "mindmap", docId, mindmapId, trackId: ingest.trackId },
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -191,11 +205,18 @@ export const run = internalAction({
|
||||
return;
|
||||
}
|
||||
const fileSource = `media:${assetId}`;
|
||||
const { trackId } = await lightragIngestText({ fileSource, text: `# ${title}\n\n${text}` });
|
||||
const ingest = await lightragIngestText({ fileSource, text: `# ${title}\n\n${text}` });
|
||||
if (ingest.skipped) {
|
||||
await ctx.runMutation(internal.jobs.finishSuccess, {
|
||||
id: args.id,
|
||||
result: { ok: true, kind: "media_asset", assetId, skipped: true, reason: ingest.reason ?? "skipped" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.runMutation(internal.jobs.finishSuccess, {
|
||||
id: args.id,
|
||||
result: { ok: true, kind: "media_asset", assetId, trackId },
|
||||
result: { ok: true, kind: "media_asset", assetId, trackId: ingest.trackId },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { internalMutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
async function cleanupByCreationTime(ctx: any, table: string, cutoffMs: number, maxDeletes: number, dryRun: boolean) {
|
||||
const batchSize = 200;
|
||||
let deleted = 0;
|
||||
|
||||
while (deleted < maxDeletes) {
|
||||
const take = Math.min(batchSize, maxDeletes - deleted);
|
||||
const rows = await ctx.db.query(table as any).order("asc").take(take);
|
||||
if (!rows.length) break;
|
||||
|
||||
let reachedNewer = false;
|
||||
for (const row of rows) {
|
||||
const created = typeof row._creationTime === "number" ? row._creationTime : 0;
|
||||
if (created >= cutoffMs) {
|
||||
reachedNewer = true;
|
||||
break;
|
||||
}
|
||||
if (!dryRun) {
|
||||
await ctx.db.delete(row._id);
|
||||
}
|
||||
deleted += 1;
|
||||
if (deleted >= maxDeletes) break;
|
||||
}
|
||||
|
||||
if (reachedNewer) break;
|
||||
}
|
||||
|
||||
// 说明:若本次达到上限,可能还有更多旧数据;由调用方决定是否继续调度。
|
||||
return deleted;
|
||||
}
|
||||
|
||||
export const cleanupWeekly = internalMutation({
|
||||
args: {
|
||||
dryRun: v.optional(v.boolean()),
|
||||
maxDeletes: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const dryRun = Boolean(args.dryRun);
|
||||
const maxDeletes = Math.max(100, Math.min(50_000, Math.floor(args.maxDeletes ?? 20_000)));
|
||||
|
||||
const cutoffMs = Date.now() - WEEK_MS;
|
||||
|
||||
// 说明:
|
||||
// - authRefreshTokens/authSessions:来自 @convex-dev/auth,按“只保留最近 7 天”的要求直接清理旧记录。
|
||||
// - jobs:异步任务表,保留最近 7 天即可(避免长期堆积)。
|
||||
const perTable = Math.max(100, Math.floor(maxDeletes / 3));
|
||||
|
||||
const deletedAuthRefreshTokens = await cleanupByCreationTime(
|
||||
ctx,
|
||||
"authRefreshTokens",
|
||||
cutoffMs,
|
||||
perTable,
|
||||
dryRun,
|
||||
);
|
||||
const deletedAuthSessions = await cleanupByCreationTime(ctx, "authSessions", cutoffMs, perTable, dryRun);
|
||||
const deletedJobs = await cleanupByCreationTime(ctx, "jobs", cutoffMs, maxDeletes - perTable * 2, dryRun);
|
||||
|
||||
// 若达到上限,兜底再跑一轮(避免一次删太多导致超时)
|
||||
const hitLimit =
|
||||
deletedAuthRefreshTokens >= perTable || deletedAuthSessions >= perTable || deletedJobs >= maxDeletes - perTable * 2;
|
||||
if (!dryRun && hitLimit) {
|
||||
await ctx.scheduler.runAfter(60_000, (internal as any).maintenance.cleanupWeekly, { dryRun: false, maxDeletes });
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
dryRun,
|
||||
cutoffMs,
|
||||
deleted: {
|
||||
authRefreshTokens: deletedAuthRefreshTokens,
|
||||
authSessions: deletedAuthSessions,
|
||||
jobs: deletedJobs,
|
||||
},
|
||||
rescheduled: !dryRun && hitLimit,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { internalMutation, mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { nowIso } from "./_utils/time";
|
||||
import { enqueueExtractMediaAssetTextJob, enqueueIngestMediaAssetJob } from "./_utils/ingestJobs";
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||
import { internal } from "./_generated/api";
|
||||
|
||||
async function assertWorkspaceMember(ctx: QueryCtx | MutationCtx, userId: string, workspaceId: string) {
|
||||
const membership = await ctx.db
|
||||
@@ -15,6 +16,41 @@ async function assertWorkspaceMember(ctx: QueryCtx | MutationCtx, userId: string
|
||||
return membership;
|
||||
}
|
||||
|
||||
function resolveGraceSeconds(): number {
|
||||
const raw = process.env.DELETE_GRACE_SECONDS ?? process.env.NEXT_PUBLIC_DELETE_GRACE_SECONDS ?? "600";
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 600;
|
||||
}
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
function isExpiredForPurge(deletedAt: unknown, expiredDeletedAt: string): boolean {
|
||||
if (!deletedAt || typeof deletedAt !== "string") return false;
|
||||
// 说明:deleted_at 使用 ISO 字符串,按字典序比较即可满足时间先后。
|
||||
return deletedAt <= expiredDeletedAt;
|
||||
}
|
||||
|
||||
async function deleteMediaAssetRow(ctx: any, row: any, deletingIds: Set<string>) {
|
||||
const storageId = (row.storage_id as any) ?? null;
|
||||
if (storageId) {
|
||||
const refs = await ctx.db
|
||||
.query("media_assets")
|
||||
.withIndex("by_storage_id", (q: any) => q.eq("storage_id", storageId))
|
||||
.collect();
|
||||
// 说明:只要仍有“未清理”的引用(包括仍在垃圾桶但可恢复的记录),就不要删底层文件。
|
||||
const otherAlive = refs.some((r: any) => !deletingIds.has(String(r.id)) && !r.purged_at);
|
||||
if (!otherAlive) {
|
||||
try {
|
||||
await ctx.storage.delete(storageId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
await ctx.db.delete(row._id);
|
||||
}
|
||||
|
||||
function shouldExtractAttachmentText(args: {
|
||||
assetType?: string | null;
|
||||
mimeType?: string | null;
|
||||
@@ -340,6 +376,8 @@ export const patchById = mutation({
|
||||
if (!row) throw new Error("资源不存在");
|
||||
await assertWorkspaceMember(ctx, args.userId, row.workspace_id);
|
||||
|
||||
const prevDeletedAt = (row as any).deleted_at ?? null;
|
||||
|
||||
const next = { ...(args.patch as Record<string, unknown>), updated_at: nowIso() };
|
||||
await ctx.db.patch(row._id, next);
|
||||
|
||||
@@ -351,6 +389,21 @@ export const patchById = mutation({
|
||||
if (nextOcrStatus === "completed" && typeof nextOcrText === "string" && nextOcrText.trim()) {
|
||||
await enqueueIngestMediaAssetJob(ctx, { userId: args.userId, assetId: args.id, debounceMs: 1200 });
|
||||
}
|
||||
|
||||
// 说明:当附件进入垃圾桶时,自动调度“到期彻底删除”,避免长期堆积垃圾数据。
|
||||
const nextDeletedAt =
|
||||
patch.deleted_at === null
|
||||
? null
|
||||
: typeof patch.deleted_at === "string"
|
||||
? patch.deleted_at
|
||||
: ((row as any).deleted_at ?? null);
|
||||
if (prevDeletedAt == null && typeof nextDeletedAt === "string" && nextDeletedAt.trim()) {
|
||||
const graceMs = (resolveGraceSeconds() + 5) * 1000;
|
||||
await ctx.scheduler.runAfter(graceMs, internal.mediaAssets.purgeIfExpired, {
|
||||
id: args.id,
|
||||
deletedAt: nextDeletedAt,
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
@@ -463,34 +516,53 @@ export const purgeById = mutation({
|
||||
if (!row) throw new Error("未找到附件");
|
||||
await assertWorkspaceMember(ctx, args.userId, row.workspace_id);
|
||||
|
||||
if (row.purged_at) {
|
||||
return { ok: true, alreadyPurged: true };
|
||||
// 仅允许清理“已进入垃圾桶且超出宽限期”的附件;清空垃圾桶会传入一个很靠后的 expiredDeletedAt 以强制清理。
|
||||
if (!isExpiredForPurge((row as any).deleted_at, args.expiredDeletedAt)) {
|
||||
return { ok: false, reason: "not_expired" as const };
|
||||
}
|
||||
await deleteMediaAssetRow(ctx, row, new Set([String(row.id)]));
|
||||
return { ok: true, deleted: 1 };
|
||||
},
|
||||
});
|
||||
|
||||
export const purgeIfExpired = internalMutation({
|
||||
args: { id: v.string(), deletedAt: 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) {
|
||||
return { ok: true, deleted: false, reason: "not_found" as const };
|
||||
}
|
||||
|
||||
const storageId = (row.storage_id as any) ?? null;
|
||||
if (storageId) {
|
||||
const refs = await ctx.db
|
||||
.query("media_assets")
|
||||
.withIndex("by_storage_id", (q) => q.eq("storage_id", storageId))
|
||||
.collect();
|
||||
const otherAlive = refs.some((r) => r.id !== row.id && !r.purged_at);
|
||||
if (!otherAlive) {
|
||||
await ctx.storage.delete(storageId);
|
||||
if ((row as any).deleted_at == null) {
|
||||
return { ok: true, deleted: false, reason: "restored" as const };
|
||||
}
|
||||
|
||||
if ((row as any).deleted_at !== args.deletedAt) {
|
||||
// 删除时间不一致:说明已被恢复/重新删除过,旧调度作废
|
||||
return { ok: true, deleted: false, reason: "changed" as const };
|
||||
}
|
||||
|
||||
const graceSeconds = resolveGraceSeconds();
|
||||
const deletedTs = Date.parse(String((row as any).deleted_at));
|
||||
if (Number.isFinite(deletedTs)) {
|
||||
const elapsedMs = Date.now() - deletedTs;
|
||||
const graceMs = graceSeconds * 1000;
|
||||
if (elapsedMs < graceMs) {
|
||||
const remainingMs = Math.max(1000, graceMs - elapsedMs + 5000);
|
||||
await ctx.scheduler.runAfter(remainingMs, internal.mediaAssets.purgeIfExpired, {
|
||||
id: args.id,
|
||||
deletedAt: args.deletedAt,
|
||||
});
|
||||
return { ok: true, deleted: false, rescheduled: true, remainingMs };
|
||||
}
|
||||
}
|
||||
|
||||
const ts = nowIso();
|
||||
await ctx.db.patch(row._id, {
|
||||
deleted_at: args.expiredDeletedAt,
|
||||
deleted_by: args.userId,
|
||||
purged_at: ts,
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
storage_id: null,
|
||||
updated_at: ts,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
await deleteMediaAssetRow(ctx, row, new Set([String(row.id)]));
|
||||
return { ok: true, deleted: true };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -505,7 +577,13 @@ export const emptyTrashByWorkspace = mutation({
|
||||
.order("desc")
|
||||
.take(5000);
|
||||
|
||||
const targets = rows.filter((r) => Boolean(r.deleted_at) && !r.purged_at);
|
||||
const targets = rows.filter((r) => {
|
||||
// 说明:
|
||||
// - 清空垃圾桶是强制操作,route 会传一个很靠后的 expiredDeletedAt
|
||||
// - 这里也顺手清掉历史 purged_at 墓碑(避免长期堆积)
|
||||
if (r.purged_at) return true;
|
||||
return isExpiredForPurge((r as any).deleted_at, args.expiredDeletedAt);
|
||||
});
|
||||
if (targets.length === 0) {
|
||||
return { ok: true, updated: 0 };
|
||||
}
|
||||
@@ -525,8 +603,10 @@ export const emptyTrashByWorkspace = mutation({
|
||||
.query("media_assets")
|
||||
.withIndex("by_storage_id", (q) => q.eq("storage_id", sid as any))
|
||||
.collect();
|
||||
const alive = refs.some((r) => !r.purged_at && !r.deleted_at);
|
||||
if (!alive) {
|
||||
const deletingIds = new Set(byStorage.get(sid) ?? []);
|
||||
// 说明:只要仍有“未清理”的引用(包括仍在垃圾桶但可恢复的记录),就不要删底层文件。
|
||||
const otherAlive = refs.some((r) => !deletingIds.has(r.id) && !r.purged_at);
|
||||
if (!otherAlive) {
|
||||
try {
|
||||
await ctx.storage.delete(sid as any);
|
||||
} catch {
|
||||
@@ -535,19 +615,10 @@ export const emptyTrashByWorkspace = mutation({
|
||||
}
|
||||
}
|
||||
|
||||
const ts = nowIso();
|
||||
for (const r of targets) {
|
||||
await ctx.db.patch(r._id, {
|
||||
deleted_at: args.expiredDeletedAt,
|
||||
deleted_by: args.userId,
|
||||
purged_at: ts,
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
storage_id: null,
|
||||
updated_at: ts,
|
||||
});
|
||||
await ctx.db.delete(r._id);
|
||||
}
|
||||
|
||||
return { ok: true, updated: targets.length };
|
||||
return { ok: true, updated: targets.length, deleted: targets.length };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { internalQuery, mutation, query } from "./_generated/server";
|
||||
import { internalMutation, internalQuery, mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { nowIso } from "./_utils/time";
|
||||
import { enqueueIngestMindmapJob } from "./_utils/ingestJobs";
|
||||
import { internal } from "./_generated/api";
|
||||
|
||||
const defaultMindmapData = {
|
||||
data: { text: "中心主题" },
|
||||
children: [],
|
||||
};
|
||||
|
||||
function resolveGraceSeconds(): number {
|
||||
const raw = process.env.DELETE_GRACE_SECONDS ?? process.env.NEXT_PUBLIC_DELETE_GRACE_SECONDS ?? "600";
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 600;
|
||||
}
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
async function requireUserId(ctx: any): Promise<string> {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (userId === null) {
|
||||
@@ -195,12 +205,71 @@ export const softDelete = mutation({
|
||||
return { ok: true, moved: 0 };
|
||||
}
|
||||
|
||||
if (existing.deleted_at != null) {
|
||||
// 已在垃圾桶:保持幂等,避免重复删除导致 updated_at 抖动/重复调度
|
||||
return { ok: true, moved: 0, deleted_at: existing.deleted_at };
|
||||
}
|
||||
|
||||
const ts = nowIso();
|
||||
await ctx.db.patch(existing._id, { deleted_at: ts, deleted_by: userId, updated_at: ts });
|
||||
|
||||
// 10 分钟宽限(可恢复);到期自动清理(彻底删除)。
|
||||
// 说明:这里用 scheduler.runAfter 做“按条目调度”,无需全局 cron。
|
||||
const graceMs = (resolveGraceSeconds() + 5) * 1000;
|
||||
await ctx.scheduler.runAfter(graceMs, internal.mindmaps.purgeIfExpired, {
|
||||
docId: args.docId,
|
||||
mindmapId,
|
||||
deletedAt: ts,
|
||||
});
|
||||
return { ok: true, moved: 1, deleted_at: ts };
|
||||
},
|
||||
});
|
||||
|
||||
export const purgeIfExpired = internalMutation({
|
||||
args: { docId: v.string(), mindmapId: v.string(), deletedAt: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const mindmapId = normalizeMindmapId(args.docId, args.mindmapId);
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("mindmaps")
|
||||
.withIndex("by_doc_mindmap", (q) => q.eq("document_id", args.docId).eq("mindmap_id", mindmapId))
|
||||
.first();
|
||||
|
||||
if (!existing) {
|
||||
return { ok: true, deleted: false, reason: "not_found" as const };
|
||||
}
|
||||
|
||||
if (existing.deleted_at == null) {
|
||||
return { ok: true, deleted: false, reason: "restored" as const };
|
||||
}
|
||||
|
||||
if (existing.deleted_at !== args.deletedAt) {
|
||||
// 删除时间不一致:说明已被恢复/重新删除过,旧调度作废
|
||||
return { ok: true, deleted: false, reason: "changed" as const };
|
||||
}
|
||||
|
||||
const graceSeconds = resolveGraceSeconds();
|
||||
const deletedTs = Date.parse(existing.deleted_at);
|
||||
if (Number.isFinite(deletedTs)) {
|
||||
const elapsedMs = Date.now() - deletedTs;
|
||||
const graceMs = graceSeconds * 1000;
|
||||
if (elapsedMs < graceMs) {
|
||||
// 兜底:调度延迟/时钟偏差时,重新排一次剩余时间
|
||||
const remainingMs = Math.max(1000, graceMs - elapsedMs + 5000);
|
||||
await ctx.scheduler.runAfter(remainingMs, internal.mindmaps.purgeIfExpired, {
|
||||
docId: args.docId,
|
||||
mindmapId,
|
||||
deletedAt: args.deletedAt,
|
||||
});
|
||||
return { ok: true, deleted: false, rescheduled: true, remainingMs };
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.db.delete(existing._id);
|
||||
return { ok: true, deleted: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const restore = mutation({
|
||||
args: { docId: v.string(), mindmapId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
|
||||
@@ -52,11 +52,19 @@ export default defineSchema({
|
||||
show_structure: v.union(v.boolean(), v.null()),
|
||||
protect_editing: v.union(v.boolean(), v.null()),
|
||||
show_word_count: v.union(v.boolean(), v.null()),
|
||||
collapse_backlinks: v.optional(v.union(v.boolean(), v.null())),
|
||||
page_font: v.optional(v.union(v.literal("default"), v.literal("song"), v.literal("kai"), v.null())),
|
||||
layout_density: v.optional(v.union(v.literal("compact"), v.literal("normal"), v.literal("spacious"), v.null())),
|
||||
hide_child_pages: v.optional(v.union(v.boolean(), v.null())),
|
||||
show_block_ref_count: v.optional(v.union(v.boolean(), v.null())),
|
||||
embed_default_block_id: v.optional(v.union(v.string(), v.null())),
|
||||
|
||||
// 统计信息(由客户端编辑器计算后回写)
|
||||
word_count: v.union(v.number(), v.null()),
|
||||
character_count: v.union(v.number(), v.null()),
|
||||
block_count: v.union(v.number(), v.null()),
|
||||
todo_total_count: v.optional(v.union(v.number(), v.null())),
|
||||
todo_done_count: v.optional(v.union(v.number(), v.null())),
|
||||
|
||||
// 说明:当前文档内容结构还在演进,先用 any 承接(与 Supabase Json 一致的宽松形态)。
|
||||
content: v.any(),
|
||||
@@ -203,6 +211,10 @@ export default defineSchema({
|
||||
view_preferences: v.any(),
|
||||
snapshot: v.optional(v.any()), // DocumentTableSnapshot: { rows?, luckysheet? }
|
||||
is_archived: v.boolean(),
|
||||
// 垃圾桶字段:与附件/思维导图的删除语义保持一致(允许历史数据缺字段)
|
||||
deleted_at: v.optional(v.union(v.string(), v.null())),
|
||||
deleted_by: v.optional(v.union(v.string(), v.null())),
|
||||
purged_at: v.optional(v.union(v.string(), v.null())),
|
||||
last_synced_at: v.optional(v.string()),
|
||||
created_by: v.string(),
|
||||
updated_by: v.optional(v.string()),
|
||||
@@ -254,9 +266,50 @@ export default defineSchema({
|
||||
.index("by_created_by", ["created_by"])
|
||||
.index("by_document", ["document_id"])
|
||||
.index("by_doc_user", ["document_id", "shared_with_user_id"])
|
||||
.index("by_workspace", ["workspace_id"])
|
||||
.index("by_workspace_shared_with", ["workspace_id", "shared_with_user_id"])
|
||||
.index("by_workspace_created_by", ["workspace_id", "created_by"]),
|
||||
|
||||
// M9:评论(页面/块)
|
||||
comment_threads: defineTable({
|
||||
id: v.string(),
|
||||
workspace_id: v.string(),
|
||||
document_id: v.string(),
|
||||
block_id: v.union(v.string(), v.null()),
|
||||
resolved_at: v.union(v.string(), v.null()),
|
||||
resolved_by: v.union(v.string(), v.null()),
|
||||
comment_count: v.number(),
|
||||
last_activity_at: v.string(),
|
||||
last_comment_preview: v.union(v.string(), v.null()),
|
||||
last_comment_by: v.union(v.string(), v.null()),
|
||||
created_by: v.string(),
|
||||
created_at: v.string(),
|
||||
})
|
||||
.index("by_thread_id", ["id"])
|
||||
.index("by_document", ["document_id"])
|
||||
.index("by_document_block", ["document_id", "block_id"])
|
||||
.index("by_workspace", ["workspace_id"])
|
||||
.index("by_workspace_document_activity", ["workspace_id", "document_id", "last_activity_at"]),
|
||||
|
||||
comment_messages: defineTable({
|
||||
id: v.string(),
|
||||
thread_id: v.string(),
|
||||
workspace_id: v.string(),
|
||||
document_id: v.string(),
|
||||
block_id: v.union(v.string(), v.null()),
|
||||
parent_id: v.union(v.string(), v.null()),
|
||||
body: v.string(),
|
||||
created_by: v.string(),
|
||||
created_at: v.string(),
|
||||
deleted_at: v.union(v.string(), v.null()),
|
||||
deleted_by: v.union(v.string(), v.null()),
|
||||
})
|
||||
.index("by_message_id", ["id"])
|
||||
.index("by_thread", ["thread_id"])
|
||||
.index("by_document", ["document_id"])
|
||||
.index("by_document_block", ["document_id", "block_id"])
|
||||
.index("by_workspace", ["workspace_id"]),
|
||||
|
||||
groups: defineTable({
|
||||
id: v.string(),
|
||||
workspace_id: v.string(),
|
||||
|
||||
@@ -142,6 +142,9 @@ export const listByWorkspaceForSearch = query({
|
||||
document_id: t.document_id,
|
||||
title: t.title,
|
||||
is_archived: t.is_archived,
|
||||
deleted_at: t.deleted_at ?? null,
|
||||
deleted_by: t.deleted_by ?? null,
|
||||
purged_at: t.purged_at ?? null,
|
||||
created_at: t.created_at,
|
||||
updated_at: t.updated_at,
|
||||
}));
|
||||
@@ -173,6 +176,9 @@ export const create = mutation({
|
||||
view_preferences: {},
|
||||
snapshot: args.snapshot ?? null,
|
||||
is_archived: false,
|
||||
deleted_at: null,
|
||||
deleted_by: null,
|
||||
purged_at: null,
|
||||
last_synced_at: now,
|
||||
created_by: args.userId,
|
||||
updated_by: args.userId,
|
||||
@@ -279,9 +285,39 @@ export const remove = mutation({
|
||||
throw new Error("Table not found");
|
||||
}
|
||||
|
||||
const now = nowIso();
|
||||
await ctx.db.patch(table._id, {
|
||||
is_archived: true,
|
||||
updated_at: nowIso(),
|
||||
deleted_at: now,
|
||||
deleted_by: args.userId,
|
||||
purged_at: null,
|
||||
updated_at: now,
|
||||
updated_by: args.userId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
// Mutation: 恢复表格(从垃圾桶恢复)
|
||||
export const restore = mutation({
|
||||
args: { userId: v.string(), tableId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const table = await ctx.db
|
||||
.query("document_tables")
|
||||
.withIndex("by_table_id", (q) => q.eq("id", args.tableId))
|
||||
.first();
|
||||
if (!table) {
|
||||
throw new Error("Table not found");
|
||||
}
|
||||
|
||||
const now = nowIso();
|
||||
await ctx.db.patch(table._id, {
|
||||
is_archived: false,
|
||||
deleted_at: null,
|
||||
deleted_by: null,
|
||||
purged_at: null,
|
||||
updated_at: now,
|
||||
updated_by: args.userId,
|
||||
});
|
||||
|
||||
@@ -318,6 +354,45 @@ export const purge = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const emptyTrashByWorkspace = mutation({
|
||||
args: { userId: v.string(), workspaceId: v.string(), expiredDeletedAt: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
|
||||
|
||||
const rows = await ctx.db
|
||||
.query("document_tables")
|
||||
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
|
||||
.order("desc")
|
||||
.take(5000);
|
||||
|
||||
// 仅清理“已进入垃圾桶且超出宽限期”的表格
|
||||
const targets = rows.filter((t) => {
|
||||
if (!t.is_archived) return false;
|
||||
const deletedAt = (t as any).deleted_at ?? null;
|
||||
if (!deletedAt || typeof deletedAt !== "string") return false;
|
||||
return deletedAt <= args.expiredDeletedAt;
|
||||
});
|
||||
|
||||
if (targets.length === 0) {
|
||||
return { ok: true, deleted: 0 };
|
||||
}
|
||||
|
||||
for (const table of targets) {
|
||||
const tableId = table.id;
|
||||
const tableRows = await ctx.db
|
||||
.query("document_table_rows")
|
||||
.withIndex("by_table", (q) => q.eq("table_id", tableId))
|
||||
.collect();
|
||||
for (const r of tableRows) {
|
||||
await ctx.db.delete(r._id);
|
||||
}
|
||||
await ctx.db.delete(table._id);
|
||||
}
|
||||
|
||||
return { ok: true, deleted: targets.length };
|
||||
},
|
||||
});
|
||||
|
||||
// Query: 获取表格行数据
|
||||
export const getRows = query({
|
||||
args: { tableId: v.string() },
|
||||
|
||||
Reference in New Issue
Block a user