feat: add sender profile cache with LRU eviction

- SenderNameCache: TTL 10min, LRU max 2048 entries, getOrFetch pattern
- resolveSenderName: API call with cache integration
- Trigger uses cache for approval card resolutions and audit metadata
- Add unit tests (9) for cache lifecycle, LRU, expiry, and null handling
This commit is contained in:
2026-07-08 17:22:23 +08:00
parent 2736006262
commit 4479f88f4f
4 changed files with 264 additions and 7 deletions
+37 -7
View File
@@ -21,6 +21,7 @@ import {
downloadMessageFile,
parsePostMessage,
resolveCard,
resolveSenderName,
type CardActionEvent,
type FeishuRuntime,
type MessageReceiveEvent,
@@ -35,9 +36,11 @@ import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js";
import { readFeishuContext } from "./read.js";
import { MessageBatcher, messageBatchKey, type MessageBatcherOptions } from "./messageBatcher.js";
import { ApprovalManager } from "./approval.js";
import { SenderNameCache } from "./senderCache.js";
export { ApprovalManager } from "./approval.js";
export type { ApprovalResult, PendingApproval } from "./approval.js";
export const senderNameCache = new SenderNameCache();
interface TriggerDeps {
readonly prisma: PrismaClient;
@@ -138,7 +141,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
projectId,
...(roleDecision.actorUserId !== undefined ? { actorUserId: roleDecision.actorUserId } : {}),
action: "trigger.role_denied",
metadata: { roleId, decision: auditDecision(roleDecision) },
metadata: {
roleId,
decision: auditDecision(roleDecision),
sender: await senderAuditMetadata(rt, senderOpenId),
},
});
await sendText(rt, chatId, `无权限使用角色 ${roleId}`);
return;
@@ -208,6 +215,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
roleId,
model,
prompt: agentPrompt.slice(0, 200),
sender: await senderAuditMetadata(rt, senderOpenId),
...(feishuTriggerContext === null ? {} : { feishuTriggerContext }),
},
});
@@ -220,7 +228,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
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 writeAudit(deps.prisma, {
runId: run.id,
projectId,
action: "run.lock_race",
metadata: { sender: await senderAuditMetadata(rt, senderOpenId) },
});
await reactToMessage(rt, msg.message_id, "OnIt");
await sendText(rt, chatId, "项目正在处理中,请稍候。");
return;
@@ -340,16 +353,17 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
const messageId = nonEmpty(event.context?.open_message_id);
if (messageId === undefined || !approvalManager.hasPending(messageId)) return;
const resolvedBy = nonEmpty(event.operator.open_id) ?? "unknown";
const operatorOpenId = nonEmpty(event.operator.open_id);
const resolvedBy = operatorOpenId ?? "unknown";
const resolvedByName =
operatorOpenId === undefined ? "unknown" : await senderDisplayNameOrOpenId(rt, operatorOpenId);
const resolution = approvalResolutionFor(action);
approvalManager.resolve(messageId, action, resolvedBy);
await resolveCard(rt, messageId, resolution.icon, resolution.label, resolution.template, resolvedBy);
await resolveCard(rt, messageId, resolution.icon, resolution.label, resolution.template, resolvedByName);
};
const onMessage = async (event: MessageReceiveEvent, rt: FeishuRuntime): Promise<void> => {
const msg = event.message;
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
@@ -412,7 +426,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
projectId,
...(triggerDecision.actorUserId !== undefined ? { actorUserId: triggerDecision.actorUserId } : {}),
action: "trigger.denied",
metadata: { decision: auditDecision(triggerDecision) },
metadata: {
decision: auditDecision(triggerDecision),
sender: await senderAuditMetadata(rt, senderOpenId),
},
});
await sendText(rt, chatId, "无权限触发。");
return;
@@ -712,6 +729,19 @@ function auditDecision(decision: AuthorizationDecision): Record<string, unknown>
};
}
async function senderDisplayNameOrOpenId(rt: FeishuRuntime, openId: string): Promise<string> {
return (await resolveSenderName(rt, openId, senderNameCache)) ?? openId;
}
async function senderAuditMetadata(rt: FeishuRuntime, openId: string): Promise<Prisma.InputJsonObject> {
const sender: Record<string, Prisma.InputJsonValue> = { open_id: openId };
const name = await resolveSenderName(rt, openId, senderNameCache);
if (name !== null) {
sender["name"] = name;
}
return sender as Prisma.InputJsonObject;
}
function withFileDeliveryInstructions(systemPrompt: string | undefined): string {
const fileDeliveryPrompt =
"When the user asks you to send, resend, attach, or provide a file, call the cph_hub send_file tool with the actual existing file path. " +