forked from bai/curriculum-project-hub
feat(filelib): 操作日志模块——防篡改哈希链、组合查询与 CSV 导出
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,17 +29,22 @@ import {
|
||||
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";
|
||||
import { FILE_LIB_AUDIT_ACTIONS, nodeObjectType, writeFileLibAudit } from "./audit.js";
|
||||
|
||||
export interface FileLibActor {
|
||||
readonly userId: string;
|
||||
/** silo org OWNER/ADMIN(契约 C4 适配)。仅 root 创建/force_adjust 用,不给读旁路。 */
|
||||
readonly isWebsiteAdmin: boolean;
|
||||
/**
|
||||
* 展示名(飞书昵称)。只用于生成 commit message 的【用户名】部分;
|
||||
* 权限判定一律用 userId。缺失时回退到 userId。
|
||||
* 展示名(飞书昵称)。用于生成 commit message 的【用户名】部分,
|
||||
* 以及审计的操作人姓名快照;权限判定一律用 userId。缺失时回退到 userId。
|
||||
*/
|
||||
readonly displayName?: string | undefined;
|
||||
/**
|
||||
* 客户端信息(IP / User-Agent),由 guard 从请求头采集。
|
||||
* 只进审计,不参与任何判定;采不到即 undefined。
|
||||
*/
|
||||
readonly client?: { readonly ip?: string | undefined; readonly userAgent?: string | undefined } | undefined;
|
||||
}
|
||||
|
||||
export interface TreeServiceDeps {
|
||||
@@ -279,22 +284,27 @@ export async function createNode(
|
||||
|
||||
await writeFileLibAudit(tx, {
|
||||
action: nodeAction(input.kind, "Create"),
|
||||
actorUserId: actor.userId,
|
||||
actor,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: input.kind === "PROJECT" ? "project" : "folder",
|
||||
objectType: nodeObjectType(input.kind),
|
||||
objectId: id,
|
||||
objectName: name,
|
||||
objectPath: pathIds,
|
||||
detail: { name, parentId: input.parentId, initialGrants: initialGrants.length },
|
||||
// 创建:无前值。后值是落库的节点事实。
|
||||
after: { name, kind: input.kind, parentId: input.parentId, description: input.description ?? null },
|
||||
context: { initialGrants: initialGrants.length, isRootCreation: input.parentId === null },
|
||||
});
|
||||
for (const grant of initialGrants) {
|
||||
await writeFileLibAudit(tx, {
|
||||
action: FILE_LIB_AUDIT_ACTIONS.permissionGrant,
|
||||
actorUserId: actor.userId,
|
||||
actor,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: "grant",
|
||||
objectType: "GRANT",
|
||||
objectId: id,
|
||||
objectName: name,
|
||||
objectPath: pathIds,
|
||||
detail: { principalType: grant.principalType, principalId: grant.principalId, role: grant.role },
|
||||
after: { principalType: grant.principalType, principalId: grant.principalId, role: grant.role },
|
||||
context: { reason: "initial_grant_on_create" },
|
||||
});
|
||||
}
|
||||
return node;
|
||||
@@ -340,12 +350,14 @@ export async function renameNode(
|
||||
}
|
||||
await writeFileLibAudit(tx, {
|
||||
action: nodeAction(node.kind, "Rename"),
|
||||
actorUserId: actor.userId,
|
||||
actor,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: node.kind === "PROJECT" ? "project" : "folder",
|
||||
objectType: nodeObjectType(node.kind),
|
||||
objectId: node.id,
|
||||
objectName: name,
|
||||
objectPath: node.pathIds,
|
||||
detail: { from: node.name, to: name },
|
||||
before: { name: node.name },
|
||||
after: { name },
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
@@ -403,12 +415,16 @@ export async function moveNode(
|
||||
}
|
||||
await writeFileLibAudit(tx, {
|
||||
action: nodeAction(node.kind, "Move"),
|
||||
actorUserId: actor.userId,
|
||||
actor,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: node.kind === "PROJECT" ? "project" : "folder",
|
||||
objectType: nodeObjectType(node.kind),
|
||||
objectId: node.id,
|
||||
objectName: node.name,
|
||||
// 移动后的新路径 —— 按子树查询要能在新位置命中。
|
||||
objectPath: newPathIds,
|
||||
detail: { fromParentId: node.parentId, toParentId: newParentId },
|
||||
before: { parentId: node.parentId, pathIds: node.pathIds },
|
||||
after: { parentId: newParentId, pathIds: newPathIds },
|
||||
context: { movedToRoot: newParentId === null },
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
@@ -422,15 +438,19 @@ export async function softDeleteNode(
|
||||
): 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() } });
|
||||
const deletedAt = new Date();
|
||||
await tx.fileLibNode.update({ where: { id: node.id }, data: { deletedAt } });
|
||||
await writeFileLibAudit(tx, {
|
||||
action: nodeAction(node.kind, "Delete"),
|
||||
actorUserId: actor.userId,
|
||||
actor,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: node.kind === "PROJECT" ? "project" : "folder",
|
||||
objectType: nodeObjectType(node.kind),
|
||||
objectId: node.id,
|
||||
objectName: node.name,
|
||||
objectPath: node.pathIds,
|
||||
detail: { name: node.name },
|
||||
before: { name: node.name, deletedAt: null },
|
||||
// 软删(D15):后值是打标本身,不是消失 —— 回收站仍可恢复。
|
||||
after: { deletedAt: deletedAt.toISOString() },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user