forked from EduCraft/curriculum-project-hub
599 lines
22 KiB
TypeScript
599 lines
22 KiB
TypeScript
/**
|
|
* 文件库树服务(Phase 1 服务层,Phase 2 路由直接调用)。
|
|
*
|
|
* 语义锚定:
|
|
* - D8 无权限 → 404 不泄露;越权 → 403(loadChain / requireAccess)
|
|
* - D11 creator 不可变 + 自动 MANAGE grant;独立权限开关语义在 permission.ts
|
|
* - D12 move = 本节点 MANAGE + 目标父 EDIT+,事务 + pg 咨询锁防并发成环
|
|
* - D14 命名规则(model.ts)+ 活跃兄弟唯一(DB 部分唯一索引兜底)
|
|
* - D15 删除只打标本节点;"任一祖先已删"即整支不可见
|
|
* - D17 breadcrumb 无 View 的祖先只给占位,不泄露名字
|
|
* - 树表示:parentId 权威;pathIds 为 id 编码的派生物化路径(name 不入路径,
|
|
* rename 不重写后代;move 用一次前缀重写维护)
|
|
*
|
|
* 网站管理员(D19)= silo org 的 OWNER/ADMIN(契约 C4 的入驻适配):仅 root 创建
|
|
* 与 force_adjust 特权,不隐式穿透内容权限 —— 本文件所有读路径对它同样走
|
|
* effectiveRole,没有 admin 旁路。
|
|
*/
|
|
|
|
import { randomUUID } from "node:crypto";
|
|
import path from "node:path";
|
|
import { Prisma } from "@prisma/client";
|
|
import type { PrismaClient, FileLibNode } from "@prisma/client";
|
|
import {
|
|
FileLibError,
|
|
nameKey,
|
|
normalizeNodeName,
|
|
type FileLibRole,
|
|
} from "./model.js";
|
|
import { checkAccess, effectiveRole } from "./permission.js";
|
|
import type { GroupResolver } from "./groupResolver.js";
|
|
import type { VersionStore } from "./versionStore.js";
|
|
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
|
|
|
|
export interface FileLibActor {
|
|
readonly userId: string;
|
|
/** silo org OWNER/ADMIN(契约 C4 适配)。仅 root 创建/force_adjust 用,不给读旁路。 */
|
|
readonly isWebsiteAdmin: boolean;
|
|
}
|
|
|
|
export interface TreeServiceDeps {
|
|
readonly prisma: PrismaClient;
|
|
readonly groupResolver: GroupResolver;
|
|
readonly versionStore: VersionStore;
|
|
/** 文件库归属的 silo org(ADR-0020 租户隔离,一切查询 scope 到它)。 */
|
|
readonly organizationId: string;
|
|
/** 项目 git 仓库的磁盘根目录;项目仓 = <storageRoot>/<nodeId>。 */
|
|
readonly storageRoot: string;
|
|
}
|
|
|
|
/** 权限判定实际需要的最小依赖(grantService 等兄弟模块复用)。 */
|
|
export type AccessDeps = Pick<TreeServiceDeps, "organizationId" | "groupResolver">;
|
|
|
|
export interface InitialGrant {
|
|
readonly principalType: "USER" | "GROUP";
|
|
readonly principalId: string;
|
|
readonly role: FileLibRole;
|
|
}
|
|
|
|
type Tx = Prisma.TransactionClient;
|
|
|
|
interface Chain {
|
|
readonly node: FileLibNode;
|
|
/** 根在前、直接父在后;不含 node 自身。 */
|
|
readonly ancestors: readonly FileLibNode[];
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- 内部工具 */
|
|
|
|
/** pathIds = "/rootId/.../selfId";切出祖先 id(不含 self)。 */
|
|
function ancestorIdsOf(node: FileLibNode): string[] {
|
|
return node.pathIds.split("/").filter((seg) => seg !== "").slice(0, -1);
|
|
}
|
|
|
|
/** 取节点 + 祖先链(org scope);D15:自身或任一祖先已删 → 404。 */
|
|
async function loadVisibleChain(
|
|
tx: Tx,
|
|
organizationId: string,
|
|
nodeId: string,
|
|
): Promise<Chain> {
|
|
const node = await tx.fileLibNode.findFirst({ where: { id: nodeId, organizationId } });
|
|
if (node === null) throw new FileLibError(404, "node_not_found", "node not found");
|
|
const ancestorIds = ancestorIdsOf(node);
|
|
const ancestors = ancestorIds.length === 0
|
|
? []
|
|
: await tx.fileLibNode.findMany({ where: { organizationId, id: { in: ancestorIds } } });
|
|
if (node.deletedAt !== null || ancestors.some((a) => a.deletedAt !== null)) {
|
|
// D15:已删子树对外"不存在"(D8 不泄露)。
|
|
throw new FileLibError(404, "node_not_found", "node not found");
|
|
}
|
|
const byId = new Map(ancestors.map((a) => [a.id, a]));
|
|
const ordered = ancestorIds
|
|
.map((id) => byId.get(id))
|
|
.filter((a): a is FileLibNode => a !== undefined);
|
|
return { node, ancestors: ordered };
|
|
}
|
|
|
|
/** 数据获取层:把 chain、grants、groups、toggle 装配成纯 reducer 的输入。 */
|
|
async function resolveRole(
|
|
tx: Tx,
|
|
deps: AccessDeps,
|
|
actor: FileLibActor,
|
|
chain: Chain,
|
|
): Promise<FileLibRole | null> {
|
|
const chainIds = [...chain.ancestors.map((a) => a.id), chain.node.id];
|
|
const grants = await tx.fileLibGrant.findMany({
|
|
where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: chainIds } },
|
|
select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true },
|
|
});
|
|
let independentPermissionsEnabled = false;
|
|
if (chain.node.kind === "PROJECT") {
|
|
const settings = await tx.fileLibProjectSettings.findUnique({
|
|
where: { nodeId: chain.node.id },
|
|
select: { independentPermissionsEnabled: true },
|
|
});
|
|
independentPermissionsEnabled = settings?.independentPermissionsEnabled ?? false;
|
|
}
|
|
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
|
|
return effectiveRole({
|
|
nodeId: chain.node.id,
|
|
nodeKind: chain.node.kind,
|
|
ancestorIds: chain.ancestors.map((a) => a.id),
|
|
independentPermissionsEnabled,
|
|
userId: actor.userId,
|
|
groupIds,
|
|
grants,
|
|
});
|
|
}
|
|
|
|
/** D8 门禁:loadVisibleChain + resolveRole + checkAccess,失败抛 FileLibError。 */
|
|
async function requireAccess(
|
|
tx: Tx,
|
|
deps: AccessDeps,
|
|
actor: FileLibActor,
|
|
nodeId: string,
|
|
minRole: FileLibRole,
|
|
): Promise<Chain & { readonly role: FileLibRole }> {
|
|
const chain = await loadVisibleChain(tx, deps.organizationId, nodeId);
|
|
const role = await resolveRole(tx, deps, actor, chain);
|
|
const verdict = checkAccess(role, minRole);
|
|
if (!verdict.allowed) {
|
|
throw verdict.reason === "not_found"
|
|
? new FileLibError(404, "node_not_found", "node not found")
|
|
: new FileLibError(403, "forbidden", `requires ${minRole}`);
|
|
}
|
|
return { ...chain, role: verdict.role };
|
|
}
|
|
|
|
/**
|
|
* 兄弟模块(grantService 等)共用的 tx 内门禁:在调用方自己的事务里做
|
|
* 权限校验,校验与后续写同一根事务绳,避免 check-tx / write-tx 之间的竞态。
|
|
*/
|
|
export async function requireAccessInTx(
|
|
tx: Tx,
|
|
deps: AccessDeps,
|
|
actor: FileLibActor,
|
|
nodeId: string,
|
|
minRole: FileLibRole,
|
|
): Promise<Chain & { readonly role: FileLibRole }> {
|
|
return requireAccess(tx, deps, actor, nodeId, minRole);
|
|
}
|
|
|
|
/** P2002(活跃兄弟名部分唯一索引)→ 409。 */
|
|
function rethrowNameConflict(error: unknown, name: string): never {
|
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
|
throw new FileLibError(409, "name_conflict", `an active sibling named "${name}" already exists`);
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
function nodeAction(kind: FileLibNode["kind"], verb: "Create" | "Rename" | "Move" | "Delete"): string {
|
|
const table = kind === "PROJECT"
|
|
? { Create: FILE_LIB_AUDIT_ACTIONS.projectCreate, Rename: FILE_LIB_AUDIT_ACTIONS.projectRename, Move: FILE_LIB_AUDIT_ACTIONS.projectMove, Delete: FILE_LIB_AUDIT_ACTIONS.projectDelete }
|
|
: { Create: FILE_LIB_AUDIT_ACTIONS.folderCreate, Rename: FILE_LIB_AUDIT_ACTIONS.folderRename, Move: FILE_LIB_AUDIT_ACTIONS.folderMove, Delete: FILE_LIB_AUDIT_ACTIONS.folderDelete };
|
|
return table[verb];
|
|
}
|
|
|
|
function validateInitialGrants(actor: FileLibActor, grants: readonly InitialGrant[]): void {
|
|
const seen = new Set<string>();
|
|
for (const grant of grants) {
|
|
const key = `${grant.principalType}:${grant.principalId}`;
|
|
if (seen.has(key)) throw new FileLibError(400, "duplicate_principal", `duplicate grant principal: ${key}`);
|
|
seen.add(key);
|
|
if (grant.principalType === "USER" && grant.principalId === actor.userId) {
|
|
throw new FileLibError(400, "duplicate_principal", "creator already holds MANAGE via the creator grant");
|
|
}
|
|
// v1:不校验 group 存在性(C2 未提供批量校验口;给不存在 group 的授权天然无效,不危害)。
|
|
}
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- 公共操作 */
|
|
|
|
export interface CreateNodeInput {
|
|
readonly parentId: string | null;
|
|
readonly kind: "FOLDER" | "PROJECT";
|
|
readonly name: string;
|
|
readonly description?: string | undefined;
|
|
readonly grants?: readonly InitialGrant[] | undefined;
|
|
}
|
|
|
|
/**
|
|
* 创建文件夹/项目。root 创建仅网站管理员(契约 2.1);非 root 需父节点 EDIT+。
|
|
* creator 自动 MANAGE(D11);项目走 provisioning 状态机:PROVISIONING → init → READY。
|
|
*/
|
|
export async function createNode(
|
|
deps: TreeServiceDeps,
|
|
actor: FileLibActor,
|
|
input: CreateNodeInput,
|
|
): Promise<FileLibNode> {
|
|
const name = normalizeNodeName(input.name);
|
|
const initialGrants = input.grants ?? [];
|
|
validateInitialGrants(actor, initialGrants);
|
|
|
|
const id = randomUUID();
|
|
let pathIds: string;
|
|
let storageDir: string | null = null;
|
|
|
|
const created = await deps.prisma.$transaction(async (tx) => {
|
|
if (input.parentId === null) {
|
|
if (!actor.isWebsiteAdmin) {
|
|
throw new FileLibError(403, "forbidden", "root creation requires website administrator");
|
|
}
|
|
pathIds = `/${id}`;
|
|
} else {
|
|
const parent = await requireAccess(tx, deps, actor, input.parentId, "EDIT");
|
|
if (parent.node.kind !== "FOLDER") {
|
|
throw new FileLibError(400, "invalid_parent", "projects cannot have children");
|
|
}
|
|
pathIds = `${parent.node.pathIds}/${id}`;
|
|
}
|
|
|
|
if (input.kind === "PROJECT") {
|
|
storageDir = path.join(deps.storageRoot, id);
|
|
}
|
|
|
|
let node: FileLibNode;
|
|
try {
|
|
node = await tx.fileLibNode.create({
|
|
data: {
|
|
id,
|
|
organizationId: deps.organizationId,
|
|
parentId: input.parentId,
|
|
kind: input.kind,
|
|
name,
|
|
nameLower: nameKey(name),
|
|
pathIds,
|
|
creatorId: actor.userId,
|
|
provisionStatus: input.kind === "PROJECT" ? "PROVISIONING" : "READY",
|
|
storageDir,
|
|
...(input.description !== undefined && input.description.trim() !== ""
|
|
? { description: input.description.trim() }
|
|
: {}),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
rethrowNameConflict(error, name);
|
|
}
|
|
|
|
await tx.fileLibGrant.create({
|
|
data: {
|
|
organizationId: deps.organizationId,
|
|
nodeId: id,
|
|
principalType: "USER",
|
|
principalId: actor.userId,
|
|
role: "MANAGE",
|
|
isCreatorGrant: true,
|
|
createdByUserId: actor.userId,
|
|
},
|
|
});
|
|
for (const grant of initialGrants) {
|
|
await tx.fileLibGrant.create({
|
|
data: {
|
|
organizationId: deps.organizationId,
|
|
nodeId: id,
|
|
principalType: grant.principalType,
|
|
principalId: grant.principalId,
|
|
role: grant.role,
|
|
createdByUserId: actor.userId,
|
|
},
|
|
});
|
|
}
|
|
if (input.kind === "PROJECT") {
|
|
await tx.fileLibProjectSettings.create({
|
|
data: { nodeId: id, independentPermissionsEnabled: false },
|
|
});
|
|
}
|
|
|
|
await writeFileLibAudit(tx, {
|
|
action: nodeAction(input.kind, "Create"),
|
|
actorUserId: actor.userId,
|
|
organizationId: deps.organizationId,
|
|
objectType: input.kind === "PROJECT" ? "project" : "folder",
|
|
objectId: id,
|
|
objectPath: pathIds,
|
|
detail: { name, parentId: input.parentId, initialGrants: initialGrants.length },
|
|
});
|
|
for (const grant of initialGrants) {
|
|
await writeFileLibAudit(tx, {
|
|
action: FILE_LIB_AUDIT_ACTIONS.permissionGrant,
|
|
actorUserId: actor.userId,
|
|
organizationId: deps.organizationId,
|
|
objectType: "grant",
|
|
objectId: id,
|
|
objectPath: pathIds,
|
|
detail: { principalType: grant.principalType, principalId: grant.principalId, role: grant.role },
|
|
});
|
|
}
|
|
return node;
|
|
});
|
|
|
|
// provisioning 状态机(Metis 风险#1):DB 行已持久,init 失败 → FAILED 可重试/对账。
|
|
if (input.kind === "PROJECT" && storageDir !== null) {
|
|
try {
|
|
await deps.versionStore.init(storageDir);
|
|
return await deps.prisma.fileLibNode.update({
|
|
where: { id: created.id },
|
|
data: { provisionStatus: "READY" },
|
|
});
|
|
} catch (error) {
|
|
await deps.prisma.fileLibNode
|
|
.update({ where: { id: created.id }, data: { provisionStatus: "FAILED" } })
|
|
.catch(() => undefined);
|
|
throw new FileLibError(500, "provision_failed", `repository initialization failed: ${String(error)}`);
|
|
}
|
|
}
|
|
return created;
|
|
}
|
|
|
|
/** 重命名(需本节点 MANAGE,契约 8.2)。id 路径不含 name,后代无需重写。 */
|
|
export async function renameNode(
|
|
deps: TreeServiceDeps,
|
|
actor: FileLibActor,
|
|
nodeId: string,
|
|
rawName: string,
|
|
): Promise<FileLibNode> {
|
|
const name = normalizeNodeName(rawName);
|
|
return deps.prisma.$transaction(async (tx) => {
|
|
const { node } = await requireAccess(tx, deps, actor, nodeId, "MANAGE");
|
|
let updated: FileLibNode;
|
|
try {
|
|
updated = await tx.fileLibNode.update({
|
|
where: { id: node.id },
|
|
data: { name, nameLower: nameKey(name) },
|
|
});
|
|
} catch (error) {
|
|
rethrowNameConflict(error, name);
|
|
}
|
|
await writeFileLibAudit(tx, {
|
|
action: nodeAction(node.kind, "Rename"),
|
|
actorUserId: actor.userId,
|
|
organizationId: deps.organizationId,
|
|
objectType: node.kind === "PROJECT" ? "project" : "folder",
|
|
objectId: node.id,
|
|
objectPath: node.pathIds,
|
|
detail: { from: node.name, to: name },
|
|
});
|
|
return updated;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 移动(D12):本节点 MANAGE + 目标父 EDIT+(移到 root 需网站管理员);
|
|
* 事务 + org 级咨询锁防并发成环;后代 pathIds 一次前缀重写。
|
|
*/
|
|
export async function moveNode(
|
|
deps: TreeServiceDeps,
|
|
actor: FileLibActor,
|
|
nodeId: string,
|
|
newParentId: string | null,
|
|
): Promise<FileLibNode> {
|
|
return deps.prisma.$transaction(async (tx) => {
|
|
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${"filelib:tree:" + deps.organizationId}))`;
|
|
|
|
const { node } = await requireAccess(tx, deps, actor, nodeId, "MANAGE");
|
|
if (node.parentId === newParentId) return node;
|
|
|
|
let newPathIds: string;
|
|
if (newParentId === null) {
|
|
if (!actor.isWebsiteAdmin) {
|
|
throw new FileLibError(403, "forbidden", "moving to root requires website administrator");
|
|
}
|
|
newPathIds = `/${node.id}`;
|
|
} else {
|
|
const parent = await requireAccess(tx, deps, actor, newParentId, "EDIT");
|
|
if (parent.node.kind !== "FOLDER") {
|
|
throw new FileLibError(400, "invalid_parent", "projects cannot have children");
|
|
}
|
|
if (parent.node.id === node.id || parent.node.pathIds.startsWith(`${node.pathIds}/`)) {
|
|
throw new FileLibError(400, "move_into_own_subtree", "cannot move a node into its own subtree");
|
|
}
|
|
newPathIds = `${parent.node.pathIds}/${node.id}`;
|
|
}
|
|
|
|
const oldPrefix = node.pathIds;
|
|
let updated: FileLibNode;
|
|
try {
|
|
updated = await tx.fileLibNode.update({
|
|
where: { id: node.id },
|
|
data: { parentId: newParentId, pathIds: newPathIds },
|
|
});
|
|
// 派生列维护:整支后代的前缀重写(id 编码,与 name 无关)。
|
|
await tx.$executeRaw`
|
|
UPDATE "FileLibNode"
|
|
SET "pathIds" = ${newPathIds} || substring("pathIds" from ${oldPrefix.length + 1}::int)
|
|
WHERE "organizationId" = ${deps.organizationId}
|
|
AND "pathIds" LIKE ${oldPrefix + "/%"}
|
|
`;
|
|
} catch (error) {
|
|
rethrowNameConflict(error, node.name);
|
|
}
|
|
await writeFileLibAudit(tx, {
|
|
action: nodeAction(node.kind, "Move"),
|
|
actorUserId: actor.userId,
|
|
organizationId: deps.organizationId,
|
|
objectType: node.kind === "PROJECT" ? "project" : "folder",
|
|
objectId: node.id,
|
|
objectPath: newPathIds,
|
|
detail: { fromParentId: node.parentId, toParentId: newParentId },
|
|
});
|
|
return updated;
|
|
});
|
|
}
|
|
|
|
/** 软删除(D15):只打标本节点,后代靠"任一祖先已删"过滤;需 MANAGE。 */
|
|
export async function softDeleteNode(
|
|
deps: TreeServiceDeps,
|
|
actor: FileLibActor,
|
|
nodeId: string,
|
|
): Promise<void> {
|
|
await deps.prisma.$transaction(async (tx) => {
|
|
const { node } = await requireAccess(tx, deps, actor, nodeId, "MANAGE");
|
|
await tx.fileLibNode.update({ where: { id: node.id }, data: { deletedAt: new Date() } });
|
|
await writeFileLibAudit(tx, {
|
|
action: nodeAction(node.kind, "Delete"),
|
|
actorUserId: actor.userId,
|
|
organizationId: deps.organizationId,
|
|
objectType: node.kind === "PROJECT" ? "project" : "folder",
|
|
objectId: node.id,
|
|
objectPath: node.pathIds,
|
|
detail: { name: node.name },
|
|
});
|
|
});
|
|
}
|
|
|
|
/** 自查生效权限(契约 9.2 effective-permission)。D8:null 角色即不可见,404。 */
|
|
export async function getEffectiveRole(
|
|
deps: TreeServiceDeps,
|
|
actor: FileLibActor,
|
|
nodeId: string,
|
|
): Promise<FileLibRole | null> {
|
|
return deps.prisma.$transaction(async (tx) => {
|
|
const chain = await loadVisibleChain(tx, deps.organizationId, nodeId);
|
|
const role = await resolveRole(tx, deps, actor, chain);
|
|
if (role === null) throw new FileLibError(404, "node_not_found", "node not found");
|
|
return role;
|
|
});
|
|
}
|
|
|
|
export interface BreadcrumbEntry {
|
|
readonly depth: number;
|
|
/** D17:无 View 的祖先 id/name 都为 null(不泄露)。 */
|
|
readonly id: string | null;
|
|
readonly name: string | null;
|
|
readonly kind: "FOLDER" | "PROJECT";
|
|
}
|
|
|
|
/** D17 面包屑:需 self VIEW;链上每个节点单独算权限,无 View 只留占位。 */
|
|
export async function breadcrumb(
|
|
deps: TreeServiceDeps,
|
|
actor: FileLibActor,
|
|
nodeId: string,
|
|
): Promise<readonly BreadcrumbEntry[]> {
|
|
return deps.prisma.$transaction(async (tx) => {
|
|
const chain = await loadVisibleChain(tx, deps.organizationId, nodeId);
|
|
const selfRole = await resolveRole(tx, deps, actor, chain);
|
|
if (checkAccess(selfRole, "VIEW").allowed !== true) {
|
|
throw new FileLibError(404, "node_not_found", "node not found");
|
|
}
|
|
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
|
|
const chainNodes = [...chain.ancestors, chain.node];
|
|
const chainIds = chainNodes.map((n) => n.id);
|
|
const allGrants = await tx.fileLibGrant.findMany({
|
|
where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: chainIds } },
|
|
select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true },
|
|
});
|
|
const settings = chain.node.kind === "PROJECT"
|
|
? await tx.fileLibProjectSettings.findUnique({
|
|
where: { nodeId: chain.node.id },
|
|
select: { independentPermissionsEnabled: true },
|
|
})
|
|
: null;
|
|
|
|
return chainNodes.map((current, depth) => {
|
|
const role = effectiveRole({
|
|
nodeId: current.id,
|
|
nodeKind: current.kind,
|
|
ancestorIds: chainNodes.slice(0, depth).map((n) => n.id),
|
|
independentPermissionsEnabled:
|
|
current.id === chain.node.id ? settings?.independentPermissionsEnabled ?? false : false,
|
|
userId: actor.userId,
|
|
groupIds,
|
|
grants: allGrants,
|
|
});
|
|
const visible = role !== null;
|
|
return {
|
|
depth,
|
|
id: visible ? current.id : null,
|
|
name: visible ? current.name : null,
|
|
kind: current.kind,
|
|
};
|
|
});
|
|
});
|
|
}
|
|
|
|
export interface ChildNodeDto {
|
|
readonly id: string;
|
|
readonly parentId: string | null;
|
|
readonly kind: "FOLDER" | "PROJECT";
|
|
readonly name: string;
|
|
readonly role: FileLibRole;
|
|
readonly createdAt: Date;
|
|
readonly updatedAt: Date;
|
|
}
|
|
|
|
/** 列子节点(parentId=null 列 root);只返回调用者有 View 的(D8/P7)。 */
|
|
export async function listChildren(
|
|
deps: TreeServiceDeps,
|
|
actor: FileLibActor,
|
|
parentId: string | null,
|
|
): Promise<readonly ChildNodeDto[]> {
|
|
return deps.prisma.$transaction(async (tx) => {
|
|
let parentAncestorIds: string[] = [];
|
|
if (parentId !== null) {
|
|
const parent = await requireAccess(tx, deps, actor, parentId, "VIEW");
|
|
parentAncestorIds = [...parent.ancestors.map((a) => a.id), parent.node.id];
|
|
}
|
|
const children = await tx.fileLibNode.findMany({
|
|
where: { organizationId: deps.organizationId, parentId, deletedAt: null },
|
|
orderBy: [{ kind: "asc" }, { nameLower: "asc" }],
|
|
});
|
|
if (children.length === 0) return [];
|
|
|
|
// D13:一次请求只 resolve 一次组、拉一次 grant 集,批量计算,不做 per-child 往返。
|
|
const idsToFetch = [...parentAncestorIds, ...children.map((c) => c.id)];
|
|
const allGrants = await tx.fileLibGrant.findMany({
|
|
where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: idsToFetch } },
|
|
select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true },
|
|
});
|
|
const projectIds = children.filter((c) => c.kind === "PROJECT").map((c) => c.id);
|
|
const settingsRows = projectIds.length === 0
|
|
? []
|
|
: await tx.fileLibProjectSettings.findMany({
|
|
where: { nodeId: { in: projectIds } },
|
|
select: { nodeId: true, independentPermissionsEnabled: true },
|
|
});
|
|
const toggleByNode = new Map(settingsRows.map((s) => [s.nodeId, s.independentPermissionsEnabled]));
|
|
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
|
|
|
|
const out: ChildNodeDto[] = [];
|
|
for (const child of children) {
|
|
const role = effectiveRole({
|
|
nodeId: child.id,
|
|
nodeKind: child.kind,
|
|
ancestorIds: parentAncestorIds,
|
|
independentPermissionsEnabled: toggleByNode.get(child.id) ?? false,
|
|
userId: actor.userId,
|
|
groupIds,
|
|
grants: allGrants,
|
|
});
|
|
if (role === null) continue;
|
|
out.push({
|
|
id: child.id,
|
|
parentId: child.parentId,
|
|
kind: child.kind,
|
|
name: child.name,
|
|
role,
|
|
createdAt: child.createdAt,
|
|
updatedAt: child.updatedAt,
|
|
});
|
|
}
|
|
return out;
|
|
});
|
|
}
|
|
|
|
/** 更新节点简介(需 EDIT+;不记审计,非权限敏感的内容字段)。 */
|
|
export async function updateNodeDescription(
|
|
deps: TreeServiceDeps,
|
|
actor: FileLibActor,
|
|
nodeId: string,
|
|
description: string | null,
|
|
): Promise<FileLibNode> {
|
|
return deps.prisma.$transaction(async (tx) => {
|
|
const { node } = await requireAccessInTx(tx, deps, actor, nodeId, "EDIT");
|
|
return tx.fileLibNode.update({
|
|
where: { id: node.id },
|
|
data: { description: description?.trim() || null },
|
|
});
|
|
});
|
|
}
|