0.4.0 convex及界面修改
This commit is contained in:
@@ -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 };
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user