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"; import { getCanonicalDocumentByBusinessId } from "./_utils/documentRecord"; 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; } 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) { 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; 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) => { const row = await ctx.db .query("media_assets") .withIndex("by_asset_id", (q) => q.eq("id", args.id)) .first(); if (!row) return null; await assertWorkspaceMember(ctx, args.userId, row.workspace_id); return row; }, }); export const generateUploadUrl = mutation({ args: { userId: v.string() }, handler: async (ctx, args) => { // 说明:简单兜底,要求用户至少有一个工作空间 membership。 const membership = await ctx.db .query("workspace_members") .withIndex("by_user_id", (q) => q.eq("user_id", args.userId)) .first(); if (!membership) { throw new Error("尚未初始化工作空间,无法上传"); } return await ctx.storage.generateUploadUrl(); }, }); export const listByWorkspace = query({ args: { userId: v.string(), workspaceId: v.string(), assetType: v.optional(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(200, Math.floor(args.limit ?? 12))); let rows = await ctx.db .query("media_assets") .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) .order("desc") .take(limit * 5); if (!includeDeleted) { rows = rows.filter((r) => !r.deleted_at); } if (args.assetType) { rows = rows.filter((r) => r.asset_type === args.assetType); } // 说明:Convex 的 take 以索引排序为准,这里再截一刀保证输出稳定。 return rows.slice(0, limit); }, }); 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(), documentId: v.string(), limit: v.optional(v.number()), }, handler: async (ctx, args) => { const limit = Math.max(1, Math.min(500, Math.floor(args.limit ?? 200))); const rows = await ctx.db .query("media_assets") .withIndex("by_document", (q) => q.eq("document_id", args.documentId)) .order("desc") .take(limit * 2); const filtered = rows.filter((r) => !r.deleted_at); const ws = filtered[0]?.workspace_id ?? rows[0]?.workspace_id ?? null; if (ws) await assertWorkspaceMember(ctx, args.userId, ws); return filtered.slice(0, limit); }, }); export const listDeletedByWorkspace = 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(2000, Math.floor(args.limit ?? 2000))); // 说明:Convex 目前不支持“deleted_at is not null”这种索引条件,先全取再过滤(对练手项目足够)。 const rows = await ctx.db .query("media_assets") .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) .order("desc") .take(limit * 3); return rows.filter((r) => Boolean(r.deleted_at) && !r.purged_at).slice(0, limit); }, }); export const listByIds = query({ args: { userId: v.string(), ids: v.array(v.string()) }, handler: async (ctx, args) => { const ids = Array.from(new Set(args.ids.filter(Boolean))).slice(0, 200); const out: any[] = []; for (const id of ids) { const row = await ctx.db .query("media_assets") .withIndex("by_asset_id", (q) => q.eq("id", id)) .first(); if (!row) continue; await assertWorkspaceMember(ctx, args.userId, row.workspace_id); out.push(row); } return out; }, }); export const create = mutation({ args: { userId: v.string(), asset: v.object({ id: v.string(), workspace_id: v.string(), document_id: v.string(), asset_type: v.string(), file_url: v.union(v.string(), v.null()), thumbnail_url: v.union(v.string(), v.null()), storage_id: v.optional(v.union(v.id("_storage"), v.null())), bucket: v.union(v.string(), v.null()), storage_path: v.union(v.string(), v.null()), file_name: v.union(v.string(), v.null()), file_size: v.union(v.number(), v.null()), mime_type: v.union(v.string(), v.null()), }), }, handler: async (ctx, args) => { await assertWorkspaceMember(ctx, args.userId, args.asset.workspace_id); const ts = nowIso(); await ctx.db.insert("media_assets", { ...args.asset, storage_id: args.asset.storage_id ?? null, ocr_text: 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, created_by: args.userId, created_at: ts, 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; }, }); export const createWithStorage = mutation({ args: { userId: v.string(), storageId: v.id("_storage"), asset: v.object({ id: v.string(), workspace_id: v.string(), document_id: v.string(), asset_type: v.string(), file_name: v.union(v.string(), v.null()), file_size: v.union(v.number(), v.null()), mime_type: v.union(v.string(), v.null()), }), }, handler: async (ctx, args) => { await assertWorkspaceMember(ctx, args.userId, args.asset.workspace_id); const doc = await getCanonicalDocumentByBusinessId(ctx, args.asset.document_id); if (!doc || doc.workspace_id !== args.asset.workspace_id) { throw new Error("目标页面不存在或不属于该工作空间"); } const url = await ctx.storage.getUrl(args.storageId); if (!url) { throw new Error("文件不存在或已过期"); } const ts = nowIso(); const row = { id: args.asset.id, workspace_id: args.asset.workspace_id, document_id: args.asset.document_id, asset_type: args.asset.asset_type, file_url: url, thumbnail_url: url, storage_id: args.storageId, bucket: null, storage_path: null, file_name: args.asset.file_name, file_size: args.asset.file_size, mime_type: args.asset.mime_type, ocr_text: 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, created_by: args.userId, created_at: ts, updated_at: ts, }; 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; }, }); export const patchById = mutation({ args: { userId: v.string(), id: v.string(), patch: v.any(), }, 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); const prevDeletedAt = (row as any).deleted_at ?? null; const next = { ...(args.patch as Record), updated_at: nowIso() }; await ctx.db.patch(row._id, next); // 说明:当 OCR 写回完成时,自动触发 LightRAG 入库任务(Convex jobs/actions)。 const patch = args.patch as Record; const prev = row as Record; const nextOcrStatus = typeof patch.ocr_status === "string" ? patch.ocr_status : prev.ocr_status; const nextOcrText = typeof patch.ocr_text === "string" ? patch.ocr_text : prev.ocr_text; 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 }; }, }); export const refreshUrl = 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); const storageId = (row.storage_id as any) ?? null; if (!storageId) { // 外链资源:直接回传现有 URL return { signedUrl: row.file_url }; } const url = await ctx.storage.getUrl(storageId); if (!url) { throw new Error("文件不存在或已被删除"); } await ctx.db.patch(row._id, { file_url: url, thumbnail_url: url, updated_at: nowIso() }); return { signedUrl: url }; }, }); export const replaceStorageFromUpload = mutation({ args: { userId: v.string(), id: v.string(), storageId: v.id("_storage") }, 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); const url = await ctx.storage.getUrl(args.storageId); if (!url) { throw new Error("文件不存在或已过期"); } await ctx.db.patch(row._id, { storage_id: args.storageId, file_url: url, 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) => { 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); // 仅允许清理“已进入垃圾桶且超出宽限期”的附件;清空垃圾桶会传入一个很靠后的 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 }; } 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 }; } } await deleteMediaAssetRow(ctx, row, new Set([String(row.id)])); return { ok: true, deleted: true }; }, }); 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("media_assets") .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) .order("desc") .take(5000); 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 }; } // 说明:按 storage_id 分组,只有当该 storage_id 没有任何“未清理”的引用时才删除底层文件。 const byStorage = new Map(); for (const r of targets) { const sid = (r.storage_id as any) ?? null; if (!sid) continue; const list = byStorage.get(sid) ?? []; list.push(r.id); byStorage.set(sid, list); } for (const [sid] of byStorage.entries()) { const refs = await ctx.db .query("media_assets") .withIndex("by_storage_id", (q) => q.eq("storage_id", sid as any)) .collect(); 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 { // ignore } } } for (const r of targets) { await ctx.db.delete(r._id); } return { ok: true, updated: targets.length, deleted: targets.length }; }, });