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
+30 -1
View File
@@ -22,6 +22,7 @@ import { runAgent, type ModelFactory, type ProjectContext } from "../agent/runne
import { acquireLock, currentLockRunId, releaseLock } from "../lock.js";
import { canTriggerAgent, canTriggerRole } from "../permission.js";
import { transcriptPath } from "../agent/transcript.js";
import { writeAudit } from "../audit.js";
interface TriggerDeps {
readonly prisma: PrismaClient;
@@ -42,6 +43,29 @@ export function makeTriggerHandler(deps: TriggerDeps) {
const sender = event.sender;
void sender; // sender→user mapping is OPEN (principal sub-typology)
// Dedup: the lark ws client redelivers at-least-once. A duplicate event
// would spawn a second AgentRun — the lock would refuse it, but a FAILED
// run row + a busy reply would leak. Record the event_id up front; if it
// already exists, this is a redelivery → stop.
const eventId = event.header?.event_id;
if (eventId !== undefined && eventId !== "") {
const existing = await deps.prisma.feishuEventReceipt.findUnique({
where: { eventId },
select: { id: true },
});
if (existing !== null) {
deps.logger.debug({ eventId }, "feishu trigger: duplicate event, skipping");
return;
}
await deps.prisma.feishuEventReceipt.create({
data: {
eventId,
eventType: event.header?.event_type ?? "im.message.receive_v1",
messageId: msg.message_id,
},
});
}
// Only react to text messages in group chats.
if (msg.message_type !== "text") return;
const chatId = msg.chat_id;
@@ -64,6 +88,7 @@ export function makeTriggerHandler(deps: TriggerDeps) {
const perm = await canTriggerAgent(deps.prisma, projectId, senderOpenId);
if (!perm.allowed) {
deps.logger.info({ projectId, senderOpenId, reason: perm.reason }, "feishu trigger: permission denied");
await writeAudit(deps.prisma, { projectId, actorUserId: senderOpenId || undefined, action: "trigger.denied", metadata: { reason: perm.reason } });
await sendText(rt, chatId, "无权限触发。");
return;
}
@@ -143,6 +168,7 @@ export function makeTriggerHandler(deps: TriggerDeps) {
const rolePerm = await canTriggerRole(deps.prisma, projectId, roleId, senderOpenId);
if (!rolePerm.allowed) {
deps.logger.info({ projectId, roleId, senderOpenId, reason: rolePerm.reason }, "feishu trigger: role permission denied");
await writeAudit(deps.prisma, { projectId, actorUserId: senderOpenId || undefined, action: "trigger.role_denied", metadata: { roleId, reason: rolePerm.reason } });
await sendText(rt, chatId, `无权限使用角色 ${roleId}`);
return;
}
@@ -193,16 +219,17 @@ export function makeTriggerHandler(deps: TriggerDeps) {
},
select: { id: true },
});
await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.created", metadata: { roleId, model, prompt: cleanPrompt.slice(0, 200) } });
// ADR-0002: acquire the project lock for this run.
try {
await acquireLock(deps.prisma, projectId, run.id, null);
} catch {
// Race: another run grabbed the lock between our check and create.
await deps.prisma.agentRun.update({
where: { id: run.id },
data: { status: "FAILED", error: "lock race", finishedAt: new Date() },
});
await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.lock_race", metadata: {} });
await sendText(rt, chatId, "项目正在处理中,请稍候。");
return;
}
@@ -241,6 +268,7 @@ export function makeTriggerHandler(deps: TriggerDeps) {
finishedAt: new Date(),
},
});
await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.finished", metadata: { status: result.status, inputTokens: result.usage.inputTokens, outputTokens: result.usage.outputTokens } });
await sendCard(rt, chatId, buildRunStatusCard({
status: statusReply(result.status),
model,
@@ -260,6 +288,7 @@ export function makeTriggerHandler(deps: TriggerDeps) {
} catch (updateErr) {
deps.logger.warn({ runId: run.id, err: String(updateErr) }, "trigger: could not mark run FAILED (already gone?)");
}
await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.failed", metadata: { error: String(e) } });
await sendCard(rt, chatId, buildRunStatusCard({
status: "处理出错",
model,