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
+38
View File
@@ -10,6 +10,7 @@ import { readFile, writeFile, mkdir } from "node:fs/promises";
import { dirname } from "node:path"; import { dirname } from "node:path";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "./textStream.js"; import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "./textStream.js";
import type { SenderNameCache } from "./senderCache.js";
export interface FeishuConfig { export interface FeishuConfig {
readonly appId: string; readonly appId: string;
@@ -95,6 +96,14 @@ export interface CardActionEvent {
readonly token?: string; readonly token?: string;
} }
type ContactUserResponse = {
readonly data?: {
readonly user?: {
readonly name?: string | undefined;
} | undefined;
} | undefined;
} | null;
// --- Message helpers --- // --- Message helpers ---
export function buildOutboundPayload(content: string): OutboundPayload { export function buildOutboundPayload(content: string): OutboundPayload {
@@ -356,6 +365,35 @@ export async function removeReaction(rt: FeishuRuntime, messageId: string, react
} }
} }
/** Resolve a Feishu sender display name from open_id, optionally using a cache. */
export async function resolveSenderName(
rt: FeishuRuntime,
openId: string,
cache?: SenderNameCache,
): Promise<string | null> {
if (openId === "") return null;
const fetchName = async (id: string): Promise<string | null> => {
try {
const res = await rt.client.request({
method: "GET",
url: `/open-apis/contact/v3/users/${encodeURIComponent(id)}`,
params: { user_id_type: "open_id" },
}) as ContactUserResponse;
const name = res?.data?.user?.name;
return typeof name === "string" && name !== "" ? name : null;
} catch (e) {
rt.logger.warn({ openId: id, err: e instanceof Error ? e.message : String(e) }, "resolveSenderName failed");
return null;
}
};
if (cache !== undefined) {
return cache.getOrFetch(openId, fetchName);
}
return fetchName(openId);
}
// --- File helpers --- // --- File helpers ---
/** Download a file/image from a Feishu message to a local path. */ /** Download a file/image from a Feishu message to a local path. */
+82
View File
@@ -0,0 +1,82 @@
export interface SenderCacheOptions {
readonly ttlMs?: number;
readonly maxSize?: number;
}
interface SenderCacheEntry {
readonly name: string;
readonly expiresAt: number;
}
const DEFAULT_TTL_MS = 600_000;
const DEFAULT_MAX_SIZE = 2_048;
export class SenderNameCache {
private readonly ttlMs: number;
private readonly maxSize: number;
private readonly entries = new Map<string, SenderCacheEntry>();
constructor(options: SenderCacheOptions = {}) {
this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
this.maxSize = Math.max(0, options.maxSize ?? DEFAULT_MAX_SIZE);
}
get(openId: string): string | null {
const entry = this.entries.get(openId);
if (entry === undefined) return null;
if (entry.expiresAt <= Date.now()) {
this.entries.delete(openId);
return null;
}
this.entries.delete(openId);
this.entries.set(openId, entry);
return entry.name;
}
set(openId: string, name: string): void {
if (this.maxSize === 0) return;
this.entries.delete(openId);
this.entries.set(openId, { name, expiresAt: Date.now() + this.ttlMs });
while (this.entries.size > this.maxSize) {
const oldest = this.entries.keys().next().value;
if (oldest === undefined) break;
this.entries.delete(oldest);
}
}
async getOrFetch(
openId: string,
fetchFn: (openId: string) => Promise<string | null>,
): Promise<string | null> {
const cached = this.get(openId);
if (cached !== null) return cached;
const fetched = await fetchFn(openId);
if (fetched !== null) {
this.set(openId, fetched);
}
return fetched;
}
clear(): void {
this.entries.clear();
}
size(): number {
this.pruneExpired();
return this.entries.size;
}
private pruneExpired(): void {
const now = Date.now();
for (const [openId, entry] of this.entries) {
if (entry.expiresAt <= now) {
this.entries.delete(openId);
}
}
}
}
+37 -7
View File
@@ -21,6 +21,7 @@ import {
downloadMessageFile, downloadMessageFile,
parsePostMessage, parsePostMessage,
resolveCard, resolveCard,
resolveSenderName,
type CardActionEvent, type CardActionEvent,
type FeishuRuntime, type FeishuRuntime,
type MessageReceiveEvent, type MessageReceiveEvent,
@@ -35,9 +36,11 @@ import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js";
import { readFeishuContext } from "./read.js"; import { readFeishuContext } from "./read.js";
import { MessageBatcher, messageBatchKey, type MessageBatcherOptions } from "./messageBatcher.js"; import { MessageBatcher, messageBatchKey, type MessageBatcherOptions } from "./messageBatcher.js";
import { ApprovalManager } from "./approval.js"; import { ApprovalManager } from "./approval.js";
import { SenderNameCache } from "./senderCache.js";
export { ApprovalManager } from "./approval.js"; export { ApprovalManager } from "./approval.js";
export type { ApprovalResult, PendingApproval } from "./approval.js"; export type { ApprovalResult, PendingApproval } from "./approval.js";
export const senderNameCache = new SenderNameCache();
interface TriggerDeps { interface TriggerDeps {
readonly prisma: PrismaClient; readonly prisma: PrismaClient;
@@ -138,7 +141,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
projectId, projectId,
...(roleDecision.actorUserId !== undefined ? { actorUserId: roleDecision.actorUserId } : {}), ...(roleDecision.actorUserId !== undefined ? { actorUserId: roleDecision.actorUserId } : {}),
action: "trigger.role_denied", action: "trigger.role_denied",
metadata: { roleId, decision: auditDecision(roleDecision) }, metadata: {
roleId,
decision: auditDecision(roleDecision),
sender: await senderAuditMetadata(rt, senderOpenId),
},
}); });
await sendText(rt, chatId, `无权限使用角色 ${roleId}`); await sendText(rt, chatId, `无权限使用角色 ${roleId}`);
return; return;
@@ -208,6 +215,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
roleId, roleId,
model, model,
prompt: agentPrompt.slice(0, 200), prompt: agentPrompt.slice(0, 200),
sender: await senderAuditMetadata(rt, senderOpenId),
...(feishuTriggerContext === null ? {} : { feishuTriggerContext }), ...(feishuTriggerContext === null ? {} : { feishuTriggerContext }),
}, },
}); });
@@ -220,7 +228,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
where: { id: run.id }, where: { id: run.id },
data: { status: "FAILED", error: "lock race", finishedAt: new Date() }, 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 reactToMessage(rt, msg.message_id, "OnIt");
await sendText(rt, chatId, "项目正在处理中,请稍候。"); await sendText(rt, chatId, "项目正在处理中,请稍候。");
return; return;
@@ -340,16 +353,17 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
const messageId = nonEmpty(event.context?.open_message_id); const messageId = nonEmpty(event.context?.open_message_id);
if (messageId === undefined || !approvalManager.hasPending(messageId)) return; 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); const resolution = approvalResolutionFor(action);
approvalManager.resolve(messageId, action, resolvedBy); 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 onMessage = async (event: MessageReceiveEvent, rt: FeishuRuntime): Promise<void> => {
const msg = event.message; 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 // 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 // would spawn a second AgentRun — the lock would refuse it, but a FAILED
@@ -412,7 +426,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
projectId, projectId,
...(triggerDecision.actorUserId !== undefined ? { actorUserId: triggerDecision.actorUserId } : {}), ...(triggerDecision.actorUserId !== undefined ? { actorUserId: triggerDecision.actorUserId } : {}),
action: "trigger.denied", action: "trigger.denied",
metadata: { decision: auditDecision(triggerDecision) }, metadata: {
decision: auditDecision(triggerDecision),
sender: await senderAuditMetadata(rt, senderOpenId),
},
}); });
await sendText(rt, chatId, "无权限触发。"); await sendText(rt, chatId, "无权限触发。");
return; 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 { function withFileDeliveryInstructions(systemPrompt: string | undefined): string {
const fileDeliveryPrompt = 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. " + "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. " +
+107
View File
@@ -0,0 +1,107 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { SenderNameCache } from "../../src/feishu/senderCache.js";
describe("SenderNameCache", () => {
afterEach(() => {
vi.useRealTimers();
});
it("get returns null for missing key", () => {
const cache = new SenderNameCache();
expect(cache.get("ou_user")).toBeNull();
});
it("set then get returns the name", () => {
const cache = new SenderNameCache();
cache.set("ou_user", "Alice");
expect(cache.get("ou_user")).toBe("Alice");
});
it("expired entry returns null", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const cache = new SenderNameCache({ ttlMs: 100 });
cache.set("ou_user", "Alice");
vi.advanceTimersByTime(101);
expect(cache.get("ou_user")).toBeNull();
expect(cache.size()).toBe(0);
});
it("getOrFetch uses cache on hit", async () => {
const cache = new SenderNameCache();
const fetchFn = vi.fn(async () => "Fetched Alice");
cache.set("ou_user", "Alice");
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBe("Alice");
expect(fetchFn).not.toHaveBeenCalled();
});
it("getOrFetch calls fetchFn on miss", async () => {
const cache = new SenderNameCache();
const fetchFn = vi.fn(async () => "Alice");
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBe("Alice");
expect(fetchFn).toHaveBeenCalledTimes(1);
expect(fetchFn).toHaveBeenCalledWith("ou_user");
});
it("getOrFetch caches the result of fetchFn", async () => {
const cache = new SenderNameCache();
const fetchFn = vi.fn(async () => "Alice");
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBe("Alice");
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBe("Alice");
expect(fetchFn).toHaveBeenCalledTimes(1);
});
it("evicts the least recently used entry when full", () => {
const cache = new SenderNameCache({ maxSize: 2 });
cache.set("ou_1", "Alice");
cache.set("ou_2", "Bob");
expect(cache.get("ou_1")).toBe("Alice");
cache.set("ou_3", "Carol");
expect(cache.get("ou_1")).toBe("Alice");
expect(cache.get("ou_2")).toBeNull();
expect(cache.get("ou_3")).toBe("Carol");
});
it("clear empties the cache", () => {
const cache = new SenderNameCache();
cache.set("ou_user", "Alice");
cache.clear();
expect(cache.get("ou_user")).toBeNull();
expect(cache.size()).toBe(0);
});
it("size returns correct count", () => {
const cache = new SenderNameCache();
cache.set("ou_1", "Alice");
cache.set("ou_2", "Bob");
expect(cache.size()).toBe(2);
});
it("getOrFetch with null fetchFn result does not cache null", async () => {
const cache = new SenderNameCache();
const fetchFn = vi.fn(async () => null);
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBeNull();
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBeNull();
expect(fetchFn).toHaveBeenCalledTimes(2);
expect(cache.size()).toBe(0);
});
});