forked from bai/curriculum-project-hub
feat(hub): 事件去重(A-4) + 审计写入路径(A-8)
A-4 FeishuEventReceipt: - 新增 FeishuEventReceipt 表(eventId @unique),lark ws 至少一次投递的幂等兜底 - MessageReceiveEvent 加 header.event_id 字段 - trigger 入口查重:已存在的 event_id → 跳过(不建第二次 run) - resetDb 加入 FeishuEventReceipt A-8 审计写入: - AuditEntry 加 projectId(权限拒绝/slash 命令等无 run 的审计点可定位) - 新建 src/audit.ts:writeAudit(prisma, entry) best-effort 写入(吞错,不阻断 trigger) - trigger 五个审计点:trigger.denied / trigger.role_denied / run.created / run.lock_race / run.finished / run.failed - 新增 audit unit 测试(3) + trigger 集成测试(去重 + 审计,2) 59 tests 全过。
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Audit write path — A-8. The `AuditEntry` table existed but had no writer;
|
||||
* callers (trigger path, permission denials, slash commands) had no way to
|
||||
* record what happened. This module is that single write seam.
|
||||
*
|
||||
* ADR reference: spec Audit. The skeleton pins only the `runId` relation;
|
||||
* event type / actor / timestamp / details are content-OPEN. This helper
|
||||
* exposes `action` (event type) + `actorUserId` (actor) + `metadata`
|
||||
* (details) + `projectId` (locates audit points that don't have a run, e.g.
|
||||
* permission denials) + `runId` (loose string ref, not a Prisma relation —
|
||||
* see schema). `createdAt` is `@default(now())` so we don't pass it.
|
||||
*
|
||||
* Best-effort by design: an audit failure must never break the caller's path
|
||||
* (a trigger failing because the audit log is down would be a spec breach —
|
||||
* audit is observability, not control). Errors are swallowed here. There's no
|
||||
* logger in this layer yet; a future logger can be threaded in without
|
||||
* changing the signature.
|
||||
*/
|
||||
import type { PrismaClient, Prisma } from "@prisma/client";
|
||||
|
||||
export interface AuditInput {
|
||||
readonly runId?: string | undefined;
|
||||
readonly projectId?: string | undefined;
|
||||
readonly actorUserId?: string | undefined;
|
||||
readonly action: string;
|
||||
readonly metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one audit entry. Best-effort: any Prisma error is swallowed so the
|
||||
* caller's path (trigger, permission gate, slash command) is unaffected.
|
||||
*
|
||||
* Optional fields are only included when defined — under
|
||||
* `exactOptionalPropertyTypes`, assigning `undefined` to an optional key in a
|
||||
* Prisma `create` payload is a type error (and would also write an explicit
|
||||
* NULL rather than omit the column). Conditional assignment keeps the payload
|
||||
* honest. The data object is typed as `AuditEntryUncheckedCreateInput` (the
|
||||
* direct-FK variant) so it matches one branch of Prisma's `create` input
|
||||
* union exactly — the relation-connect branch has no `projectId`/`runId`
|
||||
* columns and would otherwise reject these fields.
|
||||
*/
|
||||
export async function writeAudit(
|
||||
prisma: PrismaClient,
|
||||
entry: AuditInput,
|
||||
): Promise<void> {
|
||||
const data: Prisma.AuditEntryUncheckedCreateInput = {
|
||||
action: entry.action,
|
||||
metadata: entry.metadata as Prisma.InputJsonValue,
|
||||
};
|
||||
if (entry.runId !== undefined) data.runId = entry.runId;
|
||||
if (entry.projectId !== undefined) data.projectId = entry.projectId;
|
||||
if (entry.actorUserId !== undefined) data.actorUserId = entry.actorUserId;
|
||||
|
||||
try {
|
||||
await prisma.auditEntry.create({ data });
|
||||
} catch {
|
||||
// Swallow: audit is best-effort and must not break the caller's path.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user