0.2.1 onlyoffice修复
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { nowIso } from "./_utils/time";
|
||||
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;
|
||||
}
|
||||
|
||||
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 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: null,
|
||||
deleted_at: null,
|
||||
deleted_by: null,
|
||||
purged_at: null,
|
||||
created_by: args.userId,
|
||||
created_at: ts,
|
||||
updated_at: ts,
|
||||
});
|
||||
|
||||
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: 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);
|
||||
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<string, unknown>), updated_at: nowIso() };
|
||||
await ctx.db.patch(row._id, next);
|
||||
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,
|
||||
updated_at: nowIso(),
|
||||
});
|
||||
|
||||
return { ok: true, fileUrl: url };
|
||||
},
|
||||
});
|
||||
|
||||
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<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();
|
||||
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 };
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user