forked from EduCraft/curriculum-project-hub
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:
@@ -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. " +
|
||||
|
||||
Reference in New Issue
Block a user