20260513 mindmap优化01

This commit is contained in:
lix-2026
2026-05-13 22:43:16 +08:00
parent 17c003976b
commit b4a452a8b7
89 changed files with 11557 additions and 707 deletions
@@ -0,0 +1,117 @@
export type DocumentVisibilityScope = "private" | "shared" | "public" | null | undefined;
export type DocumentVisibilityRecord = {
id: string;
user_id?: string | null;
parent_id?: string | null;
access_scope?: DocumentVisibilityScope;
deleted_at?: string | null;
};
export type DocumentVisibilityShareRecord = {
document_id: string;
include_descendants?: boolean | null;
};
type BuildVisibleDocumentIdsInput = {
userId: string;
docs: readonly DocumentVisibilityRecord[];
directShares: readonly DocumentVisibilityShareRecord[];
directGroupShares: readonly DocumentVisibilityShareRecord[];
includeDeletedDocuments?: boolean;
};
function normalizeDocumentId(value: string | null | undefined): string | null {
const trimmed = String(value ?? "").trim();
return trimmed.length > 0 ? trimmed : null;
}
function buildInheritedShareMap(
shares: readonly DocumentVisibilityShareRecord[],
): Map<string, { includeDescendants: boolean }> {
const result = new Map<string, { includeDescendants: boolean }>();
for (const share of shares) {
const documentId = normalizeDocumentId(share.document_id);
if (!documentId) continue;
const existing = result.get(documentId);
if (!existing) {
result.set(documentId, {
includeDescendants: Boolean(share.include_descendants),
});
continue;
}
existing.includeDescendants = existing.includeDescendants || Boolean(share.include_descendants);
}
return result;
}
function canAccessByInheritedShare(input: {
docId: string;
parentById: Map<string, string | null>;
directShareMap: Map<string, { includeDescendants: boolean }>;
cache: Map<string, boolean>;
}): boolean {
const cached = input.cache.get(input.docId);
if (typeof cached === "boolean") return cached;
if (input.directShareMap.has(input.docId)) {
input.cache.set(input.docId, true);
return true;
}
let parentId = input.parentById.get(input.docId) ?? null;
for (let depth = 0; depth < 60 && parentId; depth += 1) {
const parentShare = input.directShareMap.get(parentId);
if (parentShare && parentShare.includeDescendants) {
input.cache.set(input.docId, true);
return true;
}
parentId = input.parentById.get(parentId) ?? null;
}
input.cache.set(input.docId, false);
return false;
}
export function buildVisibleDocumentIds(input: BuildVisibleDocumentIdsInput): Set<string> {
const candidateDocs = input.includeDeletedDocuments
? [...input.docs]
: input.docs.filter((doc) => doc.deleted_at == null);
const parentById = new Map<string, string | null>();
for (const doc of candidateDocs) {
parentById.set(doc.id, normalizeDocumentId(doc.parent_id) ?? null);
}
const directShares = buildInheritedShareMap(input.directShares);
const directGroupShares = buildInheritedShareMap(input.directGroupShares);
const shareAccessCache = new Map<string, boolean>();
const groupAccessCache = new Map<string, boolean>();
const visible = new Set<string>();
for (const doc of candidateDocs) {
if (String(doc.user_id ?? "") === input.userId) {
visible.add(doc.id);
continue;
}
if (doc.access_scope === "public") {
visible.add(doc.id);
continue;
}
if (
canAccessByInheritedShare({
docId: doc.id,
parentById,
directShareMap: directShares,
cache: shareAccessCache,
}) ||
canAccessByInheritedShare({
docId: doc.id,
parentById,
directShareMap: directGroupShares,
cache: groupAccessCache,
})
) {
visible.add(doc.id);
}
}
return visible;
}
+89 -14
View File
@@ -4,13 +4,21 @@ import { requireUserId } from "./_utils/auth";
import { nowIso } from "./_utils/time";
import { enqueueIngestMindmapJob } from "./_utils/ingestJobs";
import { internal } from "./_generated/api";
import { requireCanonicalOwnedDocument } from "./_utils/documentRecord";
import { getCanonicalDocumentByBusinessId, requireCanonicalOwnedDocument } from "./_utils/documentRecord";
import { buildVisibleDocumentIds } from "./_utils/documentVisibility";
const defaultMindmapData = {
data: { text: "中心主题" },
children: [],
};
const bridgeArtifactArgs = {
streamDeltaHint: v.optional(v.any()),
domainEventHint: v.optional(v.any()),
domainEventPlan: v.optional(v.any()),
domainEventPlans: v.optional(v.any()),
};
function resolveGraceSeconds(): number {
const raw = process.env.DELETE_GRACE_SECONDS ?? process.env.NEXT_PUBLIC_DELETE_GRACE_SECONDS ?? "600";
const parsed = Number(raw);
@@ -31,12 +39,77 @@ async function requireOwnedDocument(ctx: any, userId: string, docId: string) {
return await requireCanonicalOwnedDocument<any>(ctx, docId, userId);
}
async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: string) {
const membership = await ctx.db
.query("workspace_members")
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", workspaceId).eq("user_id", userId))
.first();
if (!membership) {
throw new Error("无权操作该工作空间");
}
}
async function readWorkspaceVisibilityContext(ctx: any, workspaceId: string, userId: string) {
const docs = await ctx.db
.query("documents")
.withIndex("by_workspace", (q: any) => q.eq("workspace_id", workspaceId))
.collect();
const directShares = await ctx.db
.query("document_shares")
.withIndex("by_workspace_shared_with", (q: any) =>
q.eq("workspace_id", workspaceId).eq("shared_with_user_id", userId),
)
.collect();
const groupMemberships = await ctx.db
.query("group_members")
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", workspaceId).eq("user_id", userId))
.collect();
const groupIds = new Set<string>(groupMemberships.map((item: any) => String(item.group_id)));
const directGroupShares: Array<{ document_id: string; include_descendants: boolean | null }> = [];
for (const groupId of groupIds) {
const rows = await ctx.db
.query("document_group_shares")
.withIndex("by_workspace_group", (q: any) => q.eq("workspace_id", workspaceId).eq("group_id", groupId))
.collect();
for (const row of rows) {
directGroupShares.push({
document_id: row.document_id,
include_descendants: row.include_descendants,
});
}
}
return { docs, directShares, directGroupShares };
}
async function requireReadableDocument(ctx: any, userId: string, docId: string) {
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, docId);
if (!doc) {
throw new Error("页面不存在");
}
await requireWorkspaceMember(ctx, doc.workspace_id, userId);
const visibility = await readWorkspaceVisibilityContext(ctx, doc.workspace_id, userId);
const visibleDocumentIds = buildVisibleDocumentIds({
userId,
docs: visibility.docs,
directShares: visibility.directShares,
directGroupShares: visibility.directGroupShares,
});
if (!visibleDocumentIds.has(doc.id)) {
throw new Error("无权限");
}
return doc;
}
export const get = query({
args: { docId: v.string(), mindmapId: v.string() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await requireOwnedDocument(ctx, userId, args.docId);
const doc = await requireReadableDocument(ctx, userId, args.docId);
const mindmapId = normalizeMindmapId(args.docId, args.mindmapId);
const row = await ctx.db
@@ -122,6 +195,7 @@ export const put = mutation({
mindmapId: v.string(),
data: v.any(),
createOnly: v.optional(v.boolean()),
...bridgeArtifactArgs,
},
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
@@ -195,7 +269,7 @@ export const put = mutation({
});
export const softDelete = mutation({
args: { docId: v.string(), mindmapId: v.string() },
args: { docId: v.string(), mindmapId: v.string(), ...bridgeArtifactArgs },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
@@ -299,7 +373,7 @@ export const purgeIfExpired = internalMutation({
});
export const restore = mutation({
args: { docId: v.string(), mindmapId: v.string() },
args: { docId: v.string(), mindmapId: v.string(), ...bridgeArtifactArgs },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
@@ -328,7 +402,7 @@ export const restore = mutation({
});
export const purge = mutation({
args: { docId: v.string(), mindmapId: v.string() },
args: { docId: v.string(), mindmapId: v.string(), ...bridgeArtifactArgs },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
@@ -359,14 +433,15 @@ export const listByWorkspace = query({
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
// 说明:阶段 6 先按 membership 存在即可,避免引入复杂权限模型。
const membership = await ctx.db
.query("workspace_members")
.withIndex("by_workspace_user", (q) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId))
.first();
if (!membership) {
throw new Error("无权操作该工作空间");
}
await requireWorkspaceMember(ctx, args.workspaceId, userId);
const visibility = await readWorkspaceVisibilityContext(ctx, args.workspaceId, userId);
const visibleDocumentIds = buildVisibleDocumentIds({
userId,
docs: visibility.docs,
directShares: visibility.directShares,
directGroupShares: visibility.directGroupShares,
includeDeletedDocuments: true,
});
const rows = await ctx.db
.query("mindmaps")
@@ -376,7 +451,7 @@ export const listByWorkspace = query({
const includeDeleted = Boolean(args.includeDeleted);
return rows
.filter((r) => r.user_id === userId)
.filter((r) => visibleDocumentIds.has(r.document_id))
.filter((r) => (includeDeleted ? true : r.deleted_at == null))
.sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? ""))
.map((r) => ({