forked from EduCraft/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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user