feat(filelib): 老师端左栏导航:回收站 + 最近打开(ADR-0031)

- 回收站:listBin(祖先全活跃的已删顶点;管理员/直连 MANAGE 可见)、
  restore(与 D15 对称只清本节点,落审计)、purge(仅管理员,pathIds 枚举
  子树按深度降序分批硬删,绕过 self-FK RESTRICT)
- 最近打开:FileLibRecentVisit 表(filePath='' 兜底 PG 唯一索引),客户端
  成功打开后上报(VIEW 门禁,upsert 刷新),列表 20 条,D8/D15 可见性过滤
- 前端:/app 左栏(文件库/最近打开/回收站);RecentView/BinView;
  GridLibraryView 埋点 + navTarget 跳转(breadcrumb 建栈,role 已捎带)
- 测试:filelib-nav 集成 4 例;全套 79 例绿
This commit is contained in:
ymy
2026-07-31 13:27:04 +08:00
parent 04fa383286
commit d072e9ec1e
17 changed files with 998 additions and 17 deletions
+181
View File
@@ -0,0 +1,181 @@
/**
* 回收站(ADR-0031)。
*
* 列出:deletedAt != null 且**祖先全活跃**的节点(每支已删子树只露顶)。
* 可见性:网站管理员,或在该已删节点上持活跃 MANAGE grant(直连 grant,
* 不走继承 —— 回收站是管理面,不是浏览面)。
* 恢复:只清本节点 deletedAt(与 D15 删除对称),整支立即可见,落审计。
* 彻底删除:仅网站管理员;按 pathIds 物化路径枚举子树,**自最深一层逐批
* 向上删**(self-FK 是 ON DELETE RESTRICT,一次 deleteMany 不保证顺序),
* 同事务一条 node.purge 审计。
*/
import type { PrismaClient } from "@prisma/client";
import { FileLibError } from "./model.js";
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
import type { GroupResolver } from "./groupResolver.js";
import type { FileLibActor } from "./treeService.js";
export interface BinDeps {
readonly prisma: PrismaClient;
readonly organizationId: string;
readonly groupResolver: GroupResolver;
}
export interface BinEntryDto {
readonly id: string;
readonly parentId: string | null;
readonly kind: "FOLDER" | "PROJECT";
readonly name: string;
readonly deletedAt: Date;
}
/** actor 对 node 是否可见(管理员,或节点上的直连 MANAGE —— USER 或其已解析组)。 */
async function canSeeEntry(
tx: Pick<PrismaClient, "fileLibGrant">,
deps: BinDeps,
actor: FileLibActor,
groupIds: readonly string[],
nodeId: string,
): Promise<boolean> {
if (actor.isWebsiteAdmin) return true;
const grant = await tx.fileLibGrant.findFirst({
where: {
organizationId: deps.organizationId,
nodeId,
revokedAt: null,
role: "MANAGE",
OR: [
{ principalType: "USER", principalId: actor.userId },
...(groupIds.length > 0
? [{ principalType: "GROUP" as const, principalId: { in: [...groupIds] } }]
: []),
],
},
select: { id: true },
});
return grant !== null;
}
/** 列出回收站(祖先全活跃的已删节点顶)。 */
export async function listBin(deps: BinDeps, actor: FileLibActor): Promise<readonly BinEntryDto[]> {
const deleted = await deps.prisma.fileLibNode.findMany({
where: { organizationId: deps.organizationId, deletedAt: { not: null } },
orderBy: { deletedAt: "desc" },
});
if (deleted.length === 0) return [];
// 祖先活跃性:收集所有 pathIds 里的祖先段,查哪些已删,做集合判定。
const ancestorIds = new Set<string>();
for (const n of deleted) {
const segments = n.pathIds.split("/").filter((s) => s !== "" && s !== n.id);
for (const s of segments) ancestorIds.add(s);
}
const deletedAncestorIds = new Set(
(
await deps.prisma.fileLibNode.findMany({
where: { id: { in: [...ancestorIds] }, deletedAt: { not: null } },
select: { id: true },
})
).map((r) => r.id),
);
const tops = deleted.filter(
(n) => !n.pathIds.split("/").filter((s) => s !== "" && s !== n.id).some((s) => deletedAncestorIds.has(s)),
);
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
const out: BinEntryDto[] = [];
for (const n of tops) {
if (await canSeeEntry(deps.prisma, deps, actor, groupIds, n.id)) {
out.push({ id: n.id, parentId: n.parentId, kind: n.kind, name: n.name, deletedAt: n.deletedAt! });
}
}
return out;
}
/** 取回收站条目并做可见性门禁(D8:不可见即 404)。 */
async function requireBinEntry(
tx: PrismaClient,
deps: BinDeps,
actor: FileLibActor,
groupIds: readonly string[],
nodeId: string,
): Promise<{ readonly id: string; readonly kind: "FOLDER" | "PROJECT"; readonly name: string; readonly pathIds: string }> {
const node = await tx.fileLibNode.findFirst({
where: { id: nodeId, organizationId: deps.organizationId, deletedAt: { not: null } },
});
if (node === null) throw new FileLibError(404, "node_not_found", "node not found");
if (!(await canSeeEntry(tx, deps, actor, groupIds, node.id))) {
throw new FileLibError(404, "node_not_found", "node not found");
}
return { id: node.id, kind: node.kind, name: node.name, pathIds: node.pathIds };
}
/** 恢复:只清本节点 deletedAt(子树随之可见);落 restore 审计。 */
export async function restoreBinEntry(deps: BinDeps, actor: FileLibActor, nodeId: string): Promise<void> {
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
await deps.prisma.$transaction(async (tx) => {
const node = await requireBinEntry(tx as PrismaClient, deps, actor, groupIds, nodeId);
await tx.fileLibNode.update({ where: { id: node.id }, data: { deletedAt: null } });
await writeFileLibAudit(tx, {
action: node.kind === "PROJECT"
? FILE_LIB_AUDIT_ACTIONS.projectRestore
: FILE_LIB_AUDIT_ACTIONS.folderRestore,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: node.kind === "PROJECT" ? "project" : "folder",
objectId: node.id,
objectPath: node.pathIds,
detail: { name: node.name },
});
});
}
/**
* 彻底删除(仅网站管理员):整支硬删。子树经 pathIds 前缀枚举,
* 按"路径段数"降序分批 deleteMany —— self-FK 是 ON DELETE RESTRICT,
* 父行必须晚于全部子孙行删除。
*/
export async function purgeBinEntry(deps: BinDeps, actor: FileLibActor, nodeId: string): Promise<{ readonly removed: number }> {
if (!actor.isWebsiteAdmin) {
throw new FileLibError(404, "node_not_found", "node not found");
}
return deps.prisma.$transaction(async (tx) => {
const node = await tx.fileLibNode.findFirst({
where: { id: nodeId, organizationId: deps.organizationId, deletedAt: { not: null } },
});
if (node === null) throw new FileLibError(404, "node_not_found", "node not found");
const subtree = await tx.fileLibNode.findMany({
where: {
organizationId: deps.organizationId,
OR: [{ id: node.id }, { pathIds: { startsWith: `${node.pathIds}/` } }],
},
select: { id: true, pathIds: true },
});
const depthOf = (p: string): number => p.split("/").filter((s) => s !== "").length;
const byDepthDesc = [...subtree].sort((a, b) => depthOf(b.pathIds) - depthOf(a.pathIds));
let removed = 0;
let cursor = 0;
while (cursor < byDepthDesc.length) {
const depth = depthOf(byDepthDesc[cursor]!.pathIds);
const batch: string[] = [];
while (cursor < byDepthDesc.length && depthOf(byDepthDesc[cursor]!.pathIds) === depth) {
batch.push(byDepthDesc[cursor]!.id);
cursor += 1;
}
removed += (await tx.fileLibNode.deleteMany({ where: { id: { in: batch } } })).count;
}
await writeFileLibAudit(tx, {
action: FILE_LIB_AUDIT_ACTIONS.nodePurge,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: node.kind === "PROJECT" ? "project" : "folder",
objectId: node.id,
objectPath: node.pathIds,
detail: { name: node.name, removed },
});
return { removed };
});
}