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:
2026-07-07 21:34:15 +08:00
parent a2c8fa8eaf
commit f8c3e16a52
9 changed files with 261 additions and 7 deletions
+24 -2
View File
@@ -83,6 +83,7 @@ model Project {
permissionGrants PermissionGrant[] @relation("projectGrants")
permissionSettings PermissionSettings[] @relation("projectSettings")
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
auditEntries AuditEntry[] @relation("projectAudit")
@@index([archivedAt])
}
@@ -294,18 +295,39 @@ model RoleTriggerGrant {
/// spec AuditEntry: minimal skeleton — one entry relates to a run. Event type,
/// actor, timestamp, details are OPEN. This table mirrors that: `runId` is the
/// PINNED relation; the rest is open-shaped columns.
model AuditEntry {
id String @id @default(cuid())
runId String?
projectId String?
actorUserId String?
action String
metadata Json
createdAt DateTime @default(now())
actor User? @relation("auditActor", fields: [actorUserId], references: [id], onDelete: SetNull)
actor User? @relation("auditActor", fields: [actorUserId], references: [id], onDelete: SetNull)
project Project? @relation("projectAudit", fields: [projectId], references: [id], onDelete: SetNull)
@@index([runId])
@@index([projectId, createdAt])
@@index([actorUserId])
@@index([createdAt])
}
// --- Feishu event dedup --------------------------------------------------
/// Idempotency receipt for inbound Feishu ws events. The lark ws client may
/// redeliver an event (at-least-once); without dedup a duplicate would spawn a
/// second AgentRun — the lock would refuse it, but a FAILED run row + a busy
/// reply would still leak. `eventId @unique` makes the second insert a no-op
/// signal: the handler checks existence before processing.
model FeishuEventReceipt {
id String @id @default(cuid())
eventId String @unique
eventType String
messageId String?
receivedAt DateTime @default(now())
@@index([eventType])
@@index([messageId])
@@index([receivedAt])
}