From 4479f88f4f56dcab46b7887ee0da6cdc91af0f0a Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Wed, 8 Jul 2026 17:22:23 +0800 Subject: [PATCH] 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 --- hub/src/feishu/client.ts | 38 ++++++++++ hub/src/feishu/senderCache.ts | 82 ++++++++++++++++++++++ hub/src/feishu/trigger.ts | 44 ++++++++++-- hub/test/unit/sender-cache.test.ts | 107 +++++++++++++++++++++++++++++ 4 files changed, 264 insertions(+), 7 deletions(-) create mode 100644 hub/src/feishu/senderCache.ts create mode 100644 hub/test/unit/sender-cache.test.ts diff --git a/hub/src/feishu/client.ts b/hub/src/feishu/client.ts index c47603a..9038dac 100644 --- a/hub/src/feishu/client.ts +++ b/hub/src/feishu/client.ts @@ -10,6 +10,7 @@ import { readFile, writeFile, mkdir } from "node:fs/promises"; import { dirname } from "node:path"; import type { FastifyBaseLogger } from "fastify"; import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "./textStream.js"; +import type { SenderNameCache } from "./senderCache.js"; export interface FeishuConfig { readonly appId: string; @@ -95,6 +96,14 @@ export interface CardActionEvent { readonly token?: string; } +type ContactUserResponse = { + readonly data?: { + readonly user?: { + readonly name?: string | undefined; + } | undefined; + } | undefined; +} | null; + // --- Message helpers --- 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 { + if (openId === "") return null; + + const fetchName = async (id: string): Promise => { + 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 --- /** Download a file/image from a Feishu message to a local path. */ diff --git a/hub/src/feishu/senderCache.ts b/hub/src/feishu/senderCache.ts new file mode 100644 index 0000000..2163d00 --- /dev/null +++ b/hub/src/feishu/senderCache.ts @@ -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(); + + 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, + ): Promise { + 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); + } + } + } +} diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index dc14c0f..657ca95 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -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 => { 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 }; } +async function senderDisplayNameOrOpenId(rt: FeishuRuntime, openId: string): Promise { + return (await resolveSenderName(rt, openId, senderNameCache)) ?? openId; +} + +async function senderAuditMetadata(rt: FeishuRuntime, openId: string): Promise { + const sender: Record = { 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. " + diff --git a/hub/test/unit/sender-cache.test.ts b/hub/test/unit/sender-cache.test.ts new file mode 100644 index 0000000..23abbc3 --- /dev/null +++ b/hub/test/unit/sender-cache.test.ts @@ -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); + }); +});