import { 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"; 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 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 ctx.db .query("documents") .withIndex("by_document_id", (q) => q.eq("id", args.asset.document_id)) .first(); 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 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 }); } 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); if (row.purged_at) { return { ok: true, alreadyPurged: true }; } 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); } } 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 }; }, }); 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) => Boolean(r.deleted_at) && !r.purged_at); 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 alive = refs.some((r) => !r.purged_at && !r.deleted_at); if (!alive) { try { await ctx.storage.delete(sid as any); } catch { // ignore } } } 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, }); } return { ok: true, updated: targets.length }; }, });