2026-02-01 08:47:40 +08:00
|
|
|
import { internalMutation, mutation, query } from "./_generated/server";
|
2026-01-17 10:12:53 +08:00
|
|
|
import { v } from "convex/values";
|
|
|
|
|
import { nowIso } from "./_utils/time";
|
2026-01-24 12:32:51 +08:00
|
|
|
import { enqueueExtractMediaAssetTextJob, enqueueIngestMediaAssetJob } from "./_utils/ingestJobs";
|
2026-01-17 10:12:53 +08:00
|
|
|
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
2026-02-01 08:47:40 +08:00
|
|
|
import { internal } from "./_generated/api";
|
2026-04-14 13:22:29 +08:00
|
|
|
import { getCanonicalDocumentByBusinessId } from "./_utils/documentRecord";
|
2026-01-17 10:12:53 +08:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 08:47:40 +08:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-24 12:32:51 +08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-17 10:12:53 +08:00
|
|
|
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);
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-24 12:32:51 +08:00
|
|
|
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,
|
|
|
|
|
}));
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-17 10:12:53 +08:00
|
|
|
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,
|
2026-01-24 12:32:51 +08:00
|
|
|
ocr_status: shouldExtractAttachmentText({
|
|
|
|
|
assetType: args.asset.asset_type,
|
|
|
|
|
mimeType: args.asset.mime_type,
|
|
|
|
|
fileName: args.asset.file_name,
|
|
|
|
|
})
|
|
|
|
|
? "queued"
|
|
|
|
|
: null,
|
2026-01-17 10:12:53 +08:00
|
|
|
deleted_at: null,
|
|
|
|
|
deleted_by: null,
|
|
|
|
|
purged_at: null,
|
|
|
|
|
created_by: args.userId,
|
|
|
|
|
created_at: ts,
|
|
|
|
|
updated_at: ts,
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-24 12:32:51 +08:00
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-17 10:12:53 +08:00
|
|
|
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);
|
|
|
|
|
|
2026-04-14 13:22:29 +08:00
|
|
|
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, args.asset.document_id);
|
2026-01-17 10:12:53 +08:00
|
|
|
|
|
|
|
|
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,
|
2026-01-24 12:32:51 +08:00
|
|
|
ocr_status: shouldExtractAttachmentText({
|
|
|
|
|
assetType: args.asset.asset_type,
|
|
|
|
|
mimeType: args.asset.mime_type,
|
|
|
|
|
fileName: args.asset.file_name,
|
|
|
|
|
})
|
|
|
|
|
? "queued"
|
|
|
|
|
: null,
|
2026-01-17 10:12:53 +08:00
|
|
|
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);
|
2026-01-24 12:32:51 +08:00
|
|
|
|
|
|
|
|
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 });
|
|
|
|
|
}
|
2026-01-17 10:12:53 +08:00
|
|
|
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);
|
|
|
|
|
|
2026-02-01 08:47:40 +08:00
|
|
|
const prevDeletedAt = (row as any).deleted_at ?? null;
|
|
|
|
|
|
2026-01-17 10:12:53 +08:00
|
|
|
const next = { ...(args.patch as Record<string, unknown>), updated_at: nowIso() };
|
|
|
|
|
await ctx.db.patch(row._id, next);
|
2026-01-18 05:13:53 +08:00
|
|
|
|
|
|
|
|
// 说明:当 OCR 写回完成时,自动触发 LightRAG 入库任务(Convex jobs/actions)。
|
|
|
|
|
const patch = args.patch as Record<string, unknown>;
|
|
|
|
|
const prev = row as Record<string, unknown>;
|
|
|
|
|
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 });
|
|
|
|
|
}
|
2026-02-01 08:47:40 +08:00
|
|
|
|
|
|
|
|
// 说明:当附件进入垃圾桶时,自动调度“到期彻底删除”,避免长期堆积垃圾数据。
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-01-17 10:12:53 +08:00
|
|
|
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,
|
2026-01-24 12:32:51 +08:00
|
|
|
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,
|
2026-01-17 10:12:53 +08:00
|
|
|
updated_at: nowIso(),
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-24 12:32:51 +08:00
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-17 10:12:53 +08:00
|
|
|
return { ok: true, fileUrl: url };
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-24 12:32:51 +08:00
|
|
|
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 };
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-17 10:12:53 +08:00
|
|
|
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);
|
|
|
|
|
|
2026-02-01 08:47:40 +08:00
|
|
|
// 仅允许清理“已进入垃圾桶且超出宽限期”的附件;清空垃圾桶会传入一个很靠后的 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 };
|
2026-01-17 10:12:53 +08:00
|
|
|
}
|
|
|
|
|
|
2026-02-01 08:47:40 +08:00
|
|
|
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 };
|
2026-01-17 10:12:53 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 08:47:40 +08:00
|
|
|
await deleteMediaAssetRow(ctx, row, new Set([String(row.id)]));
|
|
|
|
|
return { ok: true, deleted: true };
|
2026-01-17 10:12:53 +08:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
2026-02-01 08:47:40 +08:00
|
|
|
const targets = rows.filter((r) => {
|
|
|
|
|
// 说明:
|
|
|
|
|
// - 清空垃圾桶是强制操作,route 会传一个很靠后的 expiredDeletedAt
|
|
|
|
|
// - 这里也顺手清掉历史 purged_at 墓碑(避免长期堆积)
|
|
|
|
|
if (r.purged_at) return true;
|
|
|
|
|
return isExpiredForPurge((r as any).deleted_at, args.expiredDeletedAt);
|
|
|
|
|
});
|
2026-01-17 10:12:53 +08:00
|
|
|
if (targets.length === 0) {
|
|
|
|
|
return { ok: true, updated: 0 };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 说明:按 storage_id 分组,只有当该 storage_id 没有任何“未清理”的引用时才删除底层文件。
|
|
|
|
|
const byStorage = new Map<string, string[]>();
|
|
|
|
|
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();
|
2026-02-01 08:47:40 +08:00
|
|
|
const deletingIds = new Set(byStorage.get(sid) ?? []);
|
|
|
|
|
// 说明:只要仍有“未清理”的引用(包括仍在垃圾桶但可恢复的记录),就不要删底层文件。
|
|
|
|
|
const otherAlive = refs.some((r) => !deletingIds.has(r.id) && !r.purged_at);
|
|
|
|
|
if (!otherAlive) {
|
2026-01-17 10:12:53 +08:00
|
|
|
try {
|
|
|
|
|
await ctx.storage.delete(sid as any);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const r of targets) {
|
2026-02-01 08:47:40 +08:00
|
|
|
await ctx.db.delete(r._id);
|
2026-01-17 10:12:53 +08:00
|
|
|
}
|
|
|
|
|
|
2026-02-01 08:47:40 +08:00
|
|
|
return { ok: true, updated: targets.length, deleted: targets.length };
|
2026-01-17 10:12:53 +08:00
|
|
|
},
|
|
|
|
|
});
|