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
@@ -0,0 +1,23 @@
-- FeishuEventReceipt: idempotency for inbound ws events (at-least-once redelivery).
CREATE TABLE "FeishuEventReceipt" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"eventType" TEXT NOT NULL,
"messageId" TEXT,
"receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FeishuEventReceipt_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "FeishuEventReceipt_eventId_key" ON "FeishuEventReceipt"("eventId");
CREATE INDEX "FeishuEventReceipt_eventType_idx" ON "FeishuEventReceipt"("eventType");
CREATE INDEX "FeishuEventReceipt_messageId_idx" ON "FeishuEventReceipt"("messageId");
CREATE INDEX "FeishuEventReceipt_receivedAt_idx" ON "FeishuEventReceipt"("receivedAt");
-- AuditEntry: add projectId so audit points before/without a run (permission
-- denials, slash commands) are locatable to a project.
ALTER TABLE "AuditEntry" ADD COLUMN "projectId" TEXT;
CREATE INDEX "AuditEntry_projectId_createdAt_idx" ON "AuditEntry"("projectId", "createdAt");
ALTER TABLE "AuditEntry" ADD CONSTRAINT "AuditEntry_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+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])
}