From de283a73f702fcd9760cc28169c99f7ee1382841 Mon Sep 17 00:00:00 2001 From: ding113 Date: Sun, 2 Aug 2026 12:53:57 +0800 Subject: [PATCH 1/5] fix: support canonical and client session detail identities --- messages/en/dashboard.json | 5 + messages/ja/dashboard.json | 5 + messages/ru/dashboard.json | 5 + messages/zh-CN/dashboard.json | 5 + messages/zh-TW/dashboard.json | 5 + src/actions/active-sessions.ts | 71 +++++++----- .../_components/error-details-dialog.test.tsx | 14 ++- .../components/MetadataTab.tsx | 4 + .../components/SummaryTab.tsx | 14 +-- .../error-details-dialog/index.tsx | 20 +++- .../_components/error-details-dialog/types.ts | 2 + .../logs/_components/usage-logs-table.tsx | 1 + .../_components/virtualized-logs-table.tsx | 1 + .../_components/request-list-sidebar.test.tsx | 104 ++++++++++++++++++ .../_components/request-list-sidebar.tsx | 9 +- .../session-messages-client-actions.test.tsx | 2 + .../_components/session-messages-client.tsx | 17 ++- src/app/api/v1/resources/sessions/handlers.ts | 11 +- src/app/api/v1/resources/sessions/router.ts | 3 +- .../api-client/v1/actions/active-sessions.ts | 4 +- src/lib/api-client/v1/errors.ts | 2 + src/lib/api-client/v1/openapi-types.gen.ts | 2 + src/lib/api/v1/schemas/sessions.ts | 6 +- src/lib/cache/session-cache.ts | 3 + src/repository/message.ts | 37 +++++-- tests/api/v1/sessions/sessions.test.ts | 18 ++- .../active-sessions-detail-snapshots.test.ts | 21 ++++ .../actions/active-sessions-requests.test.ts | 22 ++++ .../active-sessions-special-settings.test.ts | 6 + .../active-sessions-termination.test.ts | 19 ++++ tests/unit/api/v1/api-client-actions.test.ts | 15 +++ tests/unit/frontend/api-error-i18n.test.ts | 29 +++++ .../message-session-request-query.test.ts | 52 ++++++++- 33 files changed, 469 insertions(+), 65 deletions(-) create mode 100644 src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/request-list-sidebar.test.tsx diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index da56878e2..d603cf3b5 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -400,6 +400,8 @@ "sessionInfo": "Session Info", "sessionId": "Session ID", "prefixId": "Prefix ID", + "canonicalSessionId": "Canonical Session ID", + "clientSessionId": "Client Session ID", "clientInfo": "Client Info", "billingInfo": "Billing Info", "technicalTimeline": "Technical Timeline", @@ -807,6 +809,8 @@ }, "details": { "title": "Session Messages", + "canonicalSessionId": "Canonical Session ID", + "clientSessionId": "Client Session ID", "messages": "Messages", "info": "Session Info", "timeline": "Timeline", @@ -919,6 +923,7 @@ "loadError": "Load failed", "fetchFailed": "Fetch Failed", "unknownError": "Unknown Error", + "error": "Session details could not be loaded", "storageNotEnabled": "Not Stored", "storageNotEnabledHint": "Tip: Check REDIS_URL and ENABLE_RATE_LIMIT=true (session details cache). To store unredacted messages, set STORE_SESSION_MESSAGES=true." }, diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 13b6a99d4..a1a7a927f 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -400,6 +400,8 @@ "sessionInfo": "セッション情報", "sessionId": "Session ID", "prefixId": "Prefix ID", + "canonicalSessionId": "Canonical Session ID", + "clientSessionId": "クライアント Session ID", "clientInfo": "クライアント情報", "billingInfo": "課金情報", "technicalTimeline": "技術タイムライン", @@ -807,6 +809,8 @@ }, "details": { "title": "セッション メッセージ", + "canonicalSessionId": "Canonical Session ID", + "clientSessionId": "クライアント Session ID", "messages": "メッセージリスト", "info": "セッション情報", "timeline": "タイムライン", @@ -919,6 +923,7 @@ "loadError": "読み込み失敗", "fetchFailed": "取得失敗", "unknownError": "不明なエラー", + "error": "Session 詳細を読み込めませんでした", "storageNotEnabled": "未保存", "storageNotEnabledHint": "ヒント: REDIS_URL と ENABLE_RATE_LIMIT=true を確認してください (セッション詳細キャッシュ)。未マスクの messages を保存するには STORE_SESSION_MESSAGES=true を設定してください。" }, diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index a5eb3f2dc..8434be5ec 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -400,6 +400,8 @@ "sessionInfo": "Информация о сеансе", "sessionId": "Session ID", "prefixId": "Prefix ID", + "canonicalSessionId": "Canonical Session ID", + "clientSessionId": "Идентификатор Session клиента", "clientInfo": "Информация о клиенте", "billingInfo": "Информация о биллинге", "technicalTimeline": "Техническая хронология", @@ -807,6 +809,8 @@ }, "details": { "title": "Сообщения сеанса", + "canonicalSessionId": "Canonical Session ID", + "clientSessionId": "Идентификатор Session клиента", "messages": "Список сообщений", "info": "Информация сеанса", "timeline": "Хронология", @@ -919,6 +923,7 @@ "loadError": "Ошибка загрузки", "fetchFailed": "Не удалось получить", "unknownError": "Неизвестная ошибка", + "error": "Не удалось загрузить сведения о Session", "storageNotEnabled": "Не сохранено", "storageNotEnabledHint": "Подсказка: проверьте REDIS_URL и ENABLE_RATE_LIMIT=true (кэш деталей сессии). Чтобы сохранять сообщения без маскировки, установите STORE_SESSION_MESSAGES=true." }, diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 7da08116f..67ece1026 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -400,6 +400,8 @@ "sessionInfo": "会话信息", "sessionId": "Session ID", "prefixId": "Prefix ID", + "canonicalSessionId": "Canonical Session ID", + "clientSessionId": "客户端 Session ID", "clientInfo": "客户端信息", "billingInfo": "计费信息", "technicalTimeline": "技术时间线", @@ -807,6 +809,8 @@ }, "details": { "title": "Session Messages", + "canonicalSessionId": "Canonical Session ID", + "clientSessionId": "客户端 Session ID", "messages": "消息列表", "info": "会话信息", "timeline": "时间线", @@ -919,6 +923,7 @@ "loadError": "加载失败", "fetchFailed": "获取失败", "unknownError": "未知错误", + "error": "Session 详情加载失败", "storageNotEnabled": "未存储", "storageNotEnabledHint": "提示:请检查 REDIS_URL 与 ENABLE_RATE_LIMIT=true(用于会话详情缓存);如需保存未脱敏 messages,请设置 STORE_SESSION_MESSAGES=true。" }, diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index 35ffc49a3..ab387ccab 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -400,6 +400,8 @@ "sessionInfo": "工作階段資訊", "sessionId": "Session ID", "prefixId": "Prefix ID", + "canonicalSessionId": "Canonical Session ID", + "clientSessionId": "用戶端 Session ID", "clientInfo": "用戶端資訊", "billingInfo": "計費資訊", "technicalTimeline": "技術時間軸", @@ -807,6 +809,8 @@ }, "details": { "title": "會話訊息", + "canonicalSessionId": "Canonical Session ID", + "clientSessionId": "用戶端 Session ID", "messages": "訊息列表", "info": "會話資訊", "timeline": "時間線", @@ -919,6 +923,7 @@ "loadError": "載入失敗", "fetchFailed": "取得失敗", "unknownError": "未知錯誤", + "error": "Session 詳情載入失敗", "storageNotEnabled": "未儲存", "storageNotEnabledHint": "提示:請檢查 REDIS_URL 與 ENABLE_RATE_LIMIT=true(用於 Session 詳情快取);如需儲存未脫敏的 messages,請設定 STORE_SESSION_MESSAGES=true。" }, diff --git a/src/actions/active-sessions.ts b/src/actions/active-sessions.ts index 215d974d9..5c0d04693 100644 --- a/src/actions/active-sessions.ts +++ b/src/actions/active-sessions.ts @@ -27,6 +27,16 @@ type ResolvedSessionIdentity = NonNullable< Awaited> >; +type CanonicalSessionStats = Awaited< + ReturnType +>[number]; + +async function loadCanonicalSessionStats(sessionId: string): Promise { + const { aggregateMultipleSessionStats } = await import("@/repository/message"); + const [sessionStats] = await aggregateMultipleSessionStats([sessionId]); + return sessionStats ?? null; +} + type SessionTerminationDependencies = { SessionManager: typeof import("@/lib/session-manager").SessionManager; SessionTracker: typeof import("@/lib/session-tracker").SessionTracker; @@ -647,8 +657,7 @@ export async function getSessionMessages( const currentUserId = authSession.user.id; // 1. 获取 session 统计数据以验证所有权 - const { aggregateSessionStats } = await import("@/repository/message"); - const sessionStats = await aggregateSessionStats(sessionId); + const sessionStats = await loadCanonicalSessionStats(sessionId); if (!sessionStats) { return { @@ -670,7 +679,7 @@ export async function getSessionMessages( // 3. 获取 messages const locatorResult = await resolveSessionRequestLocator( - sessionId, + sessionStats.sessionId, requestSequence, requestedSourceSessionId ); @@ -712,7 +721,8 @@ export async function getSessionMessages( export async function hasSessionMessages( sessionId: string, requestSequence?: number, - requestedSourceSessionId?: string + requestedSourceSessionId?: string, + requestId?: number ): Promise> { try { // 验证用户权限 @@ -728,8 +738,7 @@ export async function hasSessionMessages( const currentUserId = authSession.user.id; // 检查 Session 所有权(需要从数据库获取 userId) - const { aggregateSessionStats } = await import("@/repository/message"); - const sessionStats = await aggregateSessionStats(sessionId); + const sessionStats = await loadCanonicalSessionStats(sessionId); if (!sessionStats) { return { @@ -750,9 +759,10 @@ export async function hasSessionMessages( } const locatorResult = await resolveSessionRequestLocator( - sessionId, + sessionStats.sessionId, requestSequence, - requestedSourceSessionId + requestedSourceSessionId, + requestId ); if (!locatorResult.ok) return locatorResult; @@ -760,7 +770,7 @@ export async function hasSessionMessages( const sourceSessionId = locatorResult.locator.sourceSessionId; // 只有有效的显式序号才检查特定请求;非法值按未指定序号处理。 - if (normalizeRequestSequence(requestSequence) !== null) { + if (requestId !== undefined || normalizeRequestSequence(requestSequence) !== null) { const messages = await SessionManager.getSessionMessages( sourceSessionId, locatorResult.locator.requestSequence @@ -814,9 +824,8 @@ export async function getSessionDetails( responseMeta: SessionDetailResponseMeta; snapshots: SessionDetailSnapshots; specialSettings: SpecialSetting[] | null; - sessionStats: Awaited< - ReturnType - > | null; + sessionStats: CanonicalSessionStats | null; + canonicalSessionId: string; currentSourceSessionId: string; currentSequence: number | null; prevRequest: { requestId: number; sourceSessionId: string; requestSequence: number } | null; @@ -841,21 +850,21 @@ export async function getSessionDetails( // 1. 尝试从缓存获取统计数据 const cachedStats = getSessionDetailsCache(sessionId); - let sessionStats: Awaited< - ReturnType - > | null; + let sessionStats: CanonicalSessionStats | null; if (cachedStats) { logger.debug(`[SessionCache] Session details cache hit: ${sessionId}`); sessionStats = cachedStats; } else { // 2. 从数据库查询 - const { aggregateSessionStats } = await import("@/repository/message"); - sessionStats = await aggregateSessionStats(sessionId); + sessionStats = await loadCanonicalSessionStats(sessionId); // 3. 写入缓存 if (sessionStats) { setSessionDetailsCache(sessionId, sessionStats); + if (sessionStats.sessionId !== sessionId) { + setSessionDetailsCache(sessionStats.sessionId, sessionStats); + } } logger.debug(`[SessionCache] Session details fetched and cached: ${sessionId}`); @@ -879,8 +888,9 @@ export async function getSessionDetails( }; } + const canonicalSessionId = sessionStats.sessionId; const locatorResult = await resolveSessionRequestLocator( - sessionId, + canonicalSessionId, requestSequence, requestedSourceSessionId, requestId @@ -899,7 +909,7 @@ export async function getSessionDetails( const adjacent = effectiveSequence == null ? { prevRequest: null, nextRequest: null } - : await findAdjacentSessionRequests(sessionId, effectiveRequestId); + : await findAdjacentSessionRequests(canonicalSessionId, effectiveRequestId); const parseJsonStringOrNull = (value: unknown): unknown => { if (typeof value !== "string") return value; @@ -1049,6 +1059,7 @@ export async function getSessionDetails( snapshots: effectiveSnapshots, specialSettings: unifiedSpecialSettings, sessionStats, + canonicalSessionId, currentSourceSessionId: sourceSessionId, currentSequence: effectiveSequence ?? null, prevRequest: adjacent.prevRequest, @@ -1075,19 +1086,20 @@ export async function getSessionDetails( * @param sessionId - Session ID * @param page - 页码(从 1 开始) * @param pageSize - 每页数量(默认 20) - * @param order - 排序方式:asc(正序)或 desc(倒序),默认 asc + * @param order - 排序方式:asc(正序)或 desc(倒序),默认 desc */ export async function getSessionRequests( sessionId: string, page: number = 1, pageSize: number = 20, - order: "asc" | "desc" = "asc" + order: "asc" | "desc" = "desc" ): Promise< ActionResult<{ requests: Array<{ id: number; sourceSessionId: string; sequence: number; + displaySequence: number; model: string | null; statusCode: number | null; costUsd: string | null; @@ -1114,8 +1126,7 @@ export async function getSessionRequests( const currentUserId = authSession.user.id; // 1. 验证 Session 所有权 - const { aggregateSessionStats } = await import("@/repository/message"); - const sessionStats = await aggregateSessionStats(sessionId); + const sessionStats = await loadCanonicalSessionStats(sessionId); if (!sessionStats) { return { @@ -1137,7 +1148,7 @@ export async function getSessionRequests( // 2. 查询请求列表 const { findRequestsBySessionIdentity } = await import("@/repository/message"); const offset = (page - 1) * pageSize; - const { requests, total } = await findRequestsBySessionIdentity(sessionId, { + const { requests, total } = await findRequestsBySessionIdentity(sessionStats.sessionId, { limit: pageSize, offset, order, @@ -1399,8 +1410,16 @@ export async function terminateActiveSessionsBatch( clearActiveSessionsCache(); clearAllSessionsQueryCache(); - // 清除每个终止 Session 的详情缓存 - for (const sid of allowedSessionIds) { + // 清除每个终止 Session 的 canonical 和请求 alias 详情缓存 + const allowedCanonicalIds = new Set(allowedSessionIds); + const sessionDetailCacheIds = new Set(allowedSessionIds); + for (const session of sessionsData) { + if (!allowedCanonicalIds.has(session.sessionId)) continue; + for (const requestedId of session.requestedSessionIds ?? []) { + sessionDetailCacheIds.add(requestedId); + } + } + for (const sid of sessionDetailCacheIds) { clearSessionDetailsCache(sid); } diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx index feecf2ed5..d2b058427 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx @@ -13,8 +13,9 @@ import type { RoutingTraceV1 } from "@/types/routing-trace"; const hasSessionMessagesMock = vi.fn(); vi.mock("@/lib/api-client/v1/actions/active-sessions", () => ({ - hasSessionMessages: (...args: [string, number | undefined, string | undefined]) => - hasSessionMessagesMock(...args), + hasSessionMessages: ( + ...args: [string, number | undefined, string | undefined, number | undefined] + ) => hasSessionMessagesMock(...args), })); const getSessionOriginChainMock = vi.fn(); @@ -292,6 +293,8 @@ const messages = { sessionInfo: "Session Info", sessionId: "Session ID", prefixId: "Prefix ID", + canonicalSessionId: "Canonical Session ID", + clientSessionId: "Client Session ID", clientInfo: "Client Info", billingInfo: "Billing Info", technicalTimeline: "Technical Timeline", @@ -447,6 +450,7 @@ describe("error-details-dialog layout", () => { sourceSessionId="physical-a" sessionIdentityKind="prefix_affinity" requestSequence={3} + requestId={203} /> ); @@ -454,7 +458,7 @@ describe("error-details-dialog layout", () => { await Promise.resolve(); }); - expect(hasSessionMessagesMock).toHaveBeenCalledWith("pfx:scope:root", 3, "physical-a"); + expect(hasSessionMessagesMock).toHaveBeenCalledWith("pfx:scope:root", 3, "physical-a", 203); unmount(); }); @@ -470,6 +474,7 @@ describe("error-details-dialog layout", () => { sourceSessionId="physical-a" sessionIdentityKind="prefix_affinity" requestSequence={3} + requestId={203} /> ); @@ -482,6 +487,9 @@ describe("error-details-dialog layout", () => { expect(container.querySelector('a[href*="seq=3"]')).toBeTruthy(); expect(container.querySelector('a[href*="sessionId=pfx%3Ascope%3Aroot"]')).toBeTruthy(); expect(container.querySelector('a[href*="sessionId=physical-a"]')).toBeTruthy(); + expect(container.querySelector('a[href*="requestId=203"]')).toBeTruthy(); + expect(container.textContent).toContain("Canonical Session ID: pfx:scope:root"); + expect(container.textContent).toContain("Client Session ID: physical-a"); unmount(); }); diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/MetadataTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/MetadataTab.tsx index 6a6294d27..8be20e185 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/MetadataTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/MetadataTab.tsx @@ -30,6 +30,7 @@ export function MetadataTab({ sessionId, sourceSessionId, requestSequence, + requestId, userAgent, endpoint, specialSettings, @@ -56,6 +57,9 @@ export function MetadataTab({ if (sourceSessionId) { sessionRequestParams.set("sourceSessionId", sourceSessionId); } + if (requestId != null) { + sessionRequestParams.set("requestId", String(requestId)); + } const sessionMessagesHref = sessionId ? `/dashboard/sessions/${sessionId}/messages${sessionRequestParams.size > 0 ? `?${sessionRequestParams.toString()}` : ""}` : ""; diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx index c9c45a2cd..522a7a293 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx @@ -75,8 +75,8 @@ export function SummaryTab({ firstByteMs, sessionId, sourceSessionId, - sessionIdentityKind, requestSequence, + requestId, userAgent, clientIp, endpoint, @@ -105,21 +105,21 @@ export function SummaryTab({ if (sourceSessionId) { sessionRequestParams.set("sourceSessionId", sourceSessionId); } + if (requestId != null) { + sessionRequestParams.set("requestId", String(requestId)); + } const sessionMessagesHref = sessionId ? `/dashboard/sessions/${encodeURIComponent(sessionId)}/messages${sessionRequestParams.size > 0 ? `?${sessionRequestParams.toString()}` : ""}` : ""; const identityRows = [ sessionId ? { - label: - sessionIdentityKind === "prefix_affinity" - ? t("metadata.prefixId") - : t("metadata.sessionId"), + label: t("metadata.canonicalSessionId"), value: sessionId, } : null, sourceSessionId && sourceSessionId !== sessionId - ? { label: t("metadata.sessionId"), value: sourceSessionId } + ? { label: t("metadata.clientSessionId"), value: sourceSessionId } : null, ].filter((item): item is { label: string; value: string } => item !== null); const modelAudit = resolveModelAuditDisplay({ @@ -345,7 +345,7 @@ export function SummaryTab({
- {identity.label}: + {identity.label}:{" "} { if (open && sessionId) { - const requestId = ++messageCheckRequestIdRef.current; + const checkId = ++messageCheckRequestIdRef.current; setCheckingMessages(true); - hasSessionMessages(sessionId, requestSequence ?? undefined, sourceSessionId ?? undefined) + hasSessionMessages( + sessionId, + requestSequence ?? undefined, + sourceSessionId ?? undefined, + requestId ?? undefined + ) .then((result) => { - if (requestId !== messageCheckRequestIdRef.current) return; + if (checkId !== messageCheckRequestIdRef.current) return; if (result.ok) { setHasMessages(result.data); } }) .catch((err) => { - if (requestId !== messageCheckRequestIdRef.current) return; + if (checkId !== messageCheckRequestIdRef.current) return; console.error("Failed to check session messages:", err); }) .finally(() => { - if (requestId === messageCheckRequestIdRef.current) { + if (checkId === messageCheckRequestIdRef.current) { setCheckingMessages(false); } }); @@ -175,7 +182,7 @@ export function ErrorDetailsDialog({ setHasMessages(false); setCheckingMessages(false); } - }, [open, sessionId, requestSequence, sourceSessionId]); + }, [open, sessionId, requestSequence, sourceSessionId, requestId]); // Handle scrollToRedirect - switch to metadata tab when redirect info needs focus useEffect(() => { @@ -246,6 +253,7 @@ export function ErrorDetailsDialog({ sourceSessionId, sessionIdentityKind, requestSequence, + requestId, blockedBy, blockedReason, isReplay, diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/types.ts b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/types.ts index 5012c3560..7e8971990 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/types.ts +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/types.ts @@ -25,6 +25,8 @@ export interface TabSharedProps { sessionIdentityKind?: "session_id" | "prefix_affinity" | null; /** Request sequence number within session */ requestSequence?: number | null; + /** Database request ID used to select an exact request */ + requestId?: number | null; /** Block type (e.g., "sensitive_word", "warmup") */ blockedBy?: string | null; /** Block reason (JSON string) */ diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx index 8c7f6ed71..f828d519a 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx @@ -665,6 +665,7 @@ export function UsageLogsTable({ sourceSessionId={log.sourceSessionId} sessionIdentityKind={log.sessionIdentityKind} requestSequence={log.requestSequence} + requestId={log.id} blockedBy={log.blockedBy} blockedReason={log.blockedReason} isReplay={log.isReplay} diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx index 7e6a78c0e..c1e65eb12 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx @@ -1318,6 +1318,7 @@ export function VirtualizedLogsTable({ sourceSessionId={log.sourceSessionId} sessionIdentityKind={log.sessionIdentityKind} requestSequence={log.requestSequence} + requestId={log.id} blockedBy={log.blockedBy} blockedReason={log.blockedReason} isReplay={log.isReplay} diff --git a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/request-list-sidebar.test.tsx b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/request-list-sidebar.test.tsx new file mode 100644 index 000000000..97092c5ed --- /dev/null +++ b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/request-list-sidebar.test.tsx @@ -0,0 +1,104 @@ +/** + * @vitest-environment happy-dom + */ + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { RequestListSidebar } from "./request-list-sidebar"; + +const getSessionRequestsMock = vi.fn(); +const { translateMock } = vi.hoisted(() => ({ + translateMock: (key: string) => key, +})); + +vi.mock("@/lib/api-client/v1/actions/active-sessions", () => ({ + getSessionRequests: (...args: unknown[]) => getSessionRequestsMock(...args), +})); + +vi.mock("next-intl", () => ({ + useTranslations: () => translateMock, + useTimeZone: () => "UTC", +})); + +async function flushEffects() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +afterEach(() => { + getSessionRequestsMock.mockReset(); + document.body.innerHTML = ""; +}); + +describe("RequestListSidebar", () => { + test("loads newest first and displays public round numbers without changing selectors", async () => { + getSessionRequestsMock.mockResolvedValue({ + ok: true, + data: { + requests: [ + { + id: 42, + sourceSessionId: "physical-new", + sequence: 1, + displaySequence: 4, + model: "model-new", + statusCode: 200, + costUsd: "0", + createdAt: new Date("2026-08-02T00:04:00.000Z"), + inputTokens: 4, + outputTokens: 4, + errorMessage: null, + }, + { + id: 41, + sourceSessionId: "physical-old", + sequence: 1, + displaySequence: 2, + model: "model-old", + statusCode: 200, + costUsd: "0", + createdAt: new Date("2026-08-02T00:02:00.000Z"), + inputTokens: 2, + outputTokens: 2, + errorMessage: null, + }, + ], + total: 2, + hasMore: false, + }, + }); + const onSelect = vi.fn(); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + + ); + }); + await flushEffects(); + + expect(getSessionRequestsMock).toHaveBeenCalledWith("pfx:scope:root", 1, 20, "desc"); + expect(container.textContent).toContain("#4"); + expect(container.textContent).toContain("#2"); + expect(container.textContent?.indexOf("#4")).toBeLessThan( + container.textContent?.indexOf("#2") ?? -1 + ); + + const newest = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("#4") + ); + act(() => newest?.click()); + expect(onSelect).toHaveBeenCalledWith("physical-new", 1, 42); + + act(() => root.unmount()); + }); +}); diff --git a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/request-list-sidebar.tsx b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/request-list-sidebar.tsx index 2e2233ae0..4ad2e5404 100644 --- a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/request-list-sidebar.tsx +++ b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/request-list-sidebar.tsx @@ -22,6 +22,7 @@ interface RequestItem { id: number; sourceSessionId: string; sequence: number; + displaySequence: number; model: string | null; statusCode: number | null; costUsd: string | null; @@ -56,7 +57,7 @@ export function RequestListSidebar({ const [hasMore, setHasMore] = useState(false); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const [order, setOrder] = useState<"asc" | "desc">("asc"); + const [order, setOrder] = useState<"asc" | "desc">("desc"); const pageSize = 20; @@ -128,9 +129,9 @@ export function RequestListSidebar({ ? "bg-primary text-primary-foreground shadow-sm" : "hover:bg-muted" )} - title={`#${req.sequence} - ${req.model || "Unknown"}`} + title={`#${req.displaySequence} - ${req.model || "Unknown"}`} > - {req.sequence} + {req.displaySequence} {/* Tiny status dot */} - #{request.sequence} + #{request.displaySequence} >, { ok: true }>["data"]["sessionStats"] >(null); + const [canonicalSessionId, setCanonicalSessionId] = useState(null); + const [currentSourceSessionId, setCurrentSourceSessionId] = useState(null); const [currentSequence, setCurrentSequence] = useState(null); const [prevRequest, setPrevRequest] = useState<{ requestId: number; @@ -121,6 +123,8 @@ export function SessionMessagesClient() { setSnapshots(null); setSpecialSettings(null); setSessionStats(null); + setCanonicalSessionId(null); + setCurrentSourceSessionId(null); setCurrentSequence(null); setPrevRequest(null); setNextRequest(null); @@ -173,6 +177,8 @@ export function SessionMessagesClient() { setSnapshots(result.data.snapshots); setSpecialSettings(result.data.specialSettings); setSessionStats(result.data.sessionStats); + setCanonicalSessionId(result.data.canonicalSessionId); + setCurrentSourceSessionId(result.data.currentSourceSessionId); setCurrentSequence(result.data.currentSequence); setPrevRequest(result.data.prevRequest); setNextRequest(result.data.nextRequest); @@ -383,8 +389,17 @@ export function SessionMessagesClient() { variant="outline" className="font-mono font-normal text-xs bg-muted/50 truncate max-w-[100px] sm:max-w-none" > - {sessionId} + {t("details.canonicalSessionId")}: {canonicalSessionId ?? sessionId} + {currentSourceSessionId && + currentSourceSessionId !== (canonicalSessionId ?? sessionId) && ( + + {t("details.clientSessionId")}: {currentSourceSessionId} + + )}
diff --git a/src/app/api/v1/resources/sessions/handlers.ts b/src/app/api/v1/resources/sessions/handlers.ts index 890885dfc..fed275636 100644 --- a/src/app/api/v1/resources/sessions/handlers.ts +++ b/src/app/api/v1/resources/sessions/handlers.ts @@ -11,6 +11,7 @@ import { jsonResponse, noContentResponse } from "@/lib/api/v1/_shared/response-h import { BatchTerminateSessionsSchema, SessionDetailQuerySchema, + SessionExistsQuerySchema, SessionIdParamSchema, SessionRequestsQuerySchema, SessionSequenceQuerySchema, @@ -91,9 +92,10 @@ export async function getSessionMessages(c: Context): Promise { export async function hasSessionMessages(c: Context): Promise { const params = parseSessionParams(c); if (params instanceof Response) return params; - const query = SessionSequenceQuerySchema.safeParse({ + const query = SessionExistsQuerySchema.safeParse({ requestSequence: c.req.query("requestSequence"), sourceSessionId: c.req.query("sourceSessionId"), + requestId: c.req.query("requestId"), }); if (!query.success) return fromZodError(query.error, new URL(c.req.url).pathname); @@ -101,7 +103,12 @@ export async function hasSessionMessages(c: Context): Promise { const result = await callAction( c, actions.hasSessionMessages, - [params.sessionId, query.data.requestSequence, query.data.sourceSessionId] as never[], + [ + params.sessionId, + query.data.requestSequence, + query.data.sourceSessionId, + query.data.requestId, + ] as never[], c.get("auth") ); if (!result.ok) return actionError(c, result); diff --git a/src/app/api/v1/resources/sessions/router.ts b/src/app/api/v1/resources/sessions/router.ts index fe10f5286..b5347a95d 100644 --- a/src/app/api/v1/resources/sessions/router.ts +++ b/src/app/api/v1/resources/sessions/router.ts @@ -7,6 +7,7 @@ import { BatchTerminateSessionsSchema, SessionBooleanResponseSchema, SessionDetailQuerySchema, + SessionExistsQuerySchema, SessionGenericResponseSchema, SessionIdParamSchema, SessionListResponseSchema, @@ -162,7 +163,7 @@ sessionsRouter.openapi( description: "Checks whether stored messages exist for a session or request sequence.", "x-required-access": "read", security, - request: { params: SessionIdParamSchema, query: SessionSequenceQuerySchema }, + request: { params: SessionIdParamSchema, query: SessionExistsQuerySchema }, responses: { 200: { description: "Session message existence.", diff --git a/src/lib/api-client/v1/actions/active-sessions.ts b/src/lib/api-client/v1/actions/active-sessions.ts index 6990d1c6f..e68163925 100644 --- a/src/lib/api-client/v1/actions/active-sessions.ts +++ b/src/lib/api-client/v1/actions/active-sessions.ts @@ -46,13 +46,15 @@ export function getSessionMessages( export function hasSessionMessages( sessionId: string, requestSequence?: number, - sourceSessionId?: string + sourceSessionId?: string, + requestId?: number ) { return toActionResult( apiGet<{ exists: boolean }>( `/api/v1/sessions/${encodeURIComponent(sessionId)}/messages/exists${searchParams({ requestSequence, sourceSessionId, + requestId, })}` ).then((body) => body.exists) ); diff --git a/src/lib/api-client/v1/errors.ts b/src/lib/api-client/v1/errors.ts index de9a69ce8..993b427b0 100644 --- a/src/lib/api-client/v1/errors.ts +++ b/src/lib/api-client/v1/errors.ts @@ -49,6 +49,8 @@ const API_ERROR_MESSAGE_KEYS: Record = { "key.action_failed": "OPERATION_FAILED", "user.not_found": "USER_NOT_FOUND", "user.action_failed": "OPERATION_FAILED", + "session.not_found": "NOT_FOUND", + "session.action_failed": "OPERATION_FAILED", }; export function getApiErrorMessageKey(error: ApiError): string { diff --git a/src/lib/api-client/v1/openapi-types.gen.ts b/src/lib/api-client/v1/openapi-types.gen.ts index 23c5c7190..da3070baa 100644 --- a/src/lib/api-client/v1/openapi-types.gen.ts +++ b/src/lib/api-client/v1/openapi-types.gen.ts @@ -23624,6 +23624,8 @@ export interface operations { requestSequence?: number; /** @description Physical source session id. */ sourceSessionId?: string; + /** @description Stable request id. */ + requestId?: number; }; header?: never; path: { diff --git a/src/lib/api/v1/schemas/sessions.ts b/src/lib/api/v1/schemas/sessions.ts index 9cdd51fc5..efc84c80e 100644 --- a/src/lib/api/v1/schemas/sessions.ts +++ b/src/lib/api/v1/schemas/sessions.ts @@ -16,6 +16,10 @@ export const SessionSequenceQuerySchema = z.object({ sourceSessionId: z.string().min(1).optional().describe("Physical source session id."), }); +export const SessionExistsQuerySchema = SessionSequenceQuerySchema.extend({ + requestId: z.coerce.number().int().positive().optional().describe("Stable request id."), +}); + export const SessionDetailQuerySchema = SessionSequenceQuerySchema.extend({ requestId: z.coerce.number().int().positive().optional().describe("Stable request id."), }); @@ -23,7 +27,7 @@ export const SessionDetailQuerySchema = SessionSequenceQuerySchema.extend({ export const SessionRequestsQuerySchema = z.object({ page: z.coerce.number().int().min(1).default(1).describe("One-based page number."), pageSize: z.coerce.number().int().min(1).max(200).default(20).describe("Page size."), - order: z.enum(["asc", "desc"]).default("asc").describe("Sort order."), + order: z.enum(["asc", "desc"]).default("desc").describe("Sort order."), }); export const BatchTerminateSessionsSchema = z diff --git a/src/lib/cache/session-cache.ts b/src/lib/cache/session-cache.ts index 94fac4ac0..c81bcc98e 100644 --- a/src/lib/cache/session-cache.ts +++ b/src/lib/cache/session-cache.ts @@ -74,6 +74,9 @@ const activeSessionsCache = new SessionCache< // Session details cache (1s TTL, max 10000 entries) const sessionDetailsCache = new SessionCache<{ sessionId: string; + requestedSessionIds?: string[]; + sessionIdentityKind: "session_id" | "prefix_affinity"; + sessionFingerprint: string | null; requestCount: number; totalCostUsd: string; totalInputTokens: number; diff --git a/src/repository/message.ts b/src/repository/message.ts index 448555221..5e5a70741 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -1401,6 +1401,7 @@ export async function aggregateSessionStats(sessionId: string): Promise<{ /** 解析活跃 Session identity 到可查看的物理 Session/前缀绑定信息。 */ export async function resolveSessionIdentity(identity: string): Promise<{ + identity: string; sourceSessionId: string | null; identityKind: "session_id" | "prefix_affinity" | null; scopeTag: string | null; @@ -1409,6 +1410,7 @@ export async function resolveSessionIdentity(identity: string): Promise<{ } | null> { const rows = await db .select({ + identity: messageSessionIdentity, sessionId: messageRequest.sessionId, identityKind: messageRequest.sessionIdentityKind, scopeTag: messageRequest.affinityScopeTag, @@ -1416,7 +1418,7 @@ export async function resolveSessionIdentity(identity: string): Promise<{ fingerprintChain: messageRequest.affinityFingerprintChain, }) .from(messageRequest) - .where(and(eq(messageSessionIdentity, identity), isNull(messageRequest.deletedAt))) + .where(and(messageSessionLookup(identity), isNull(messageRequest.deletedAt))) .orderBy(desc(messageRequest.createdAt)); if (rows.length === 0) return null; @@ -1436,6 +1438,7 @@ export async function resolveSessionIdentity(identity: string): Promise<{ ); return { + identity: rows[0]?.identity ?? identity, sourceSessionId: rows.find((row) => row.sessionId)?.sessionId ?? null, identityKind: identityKinds.size === 1 ? ([...identityKinds][0] ?? null) : null, scopeTag: rows.find((row) => row.scopeTag)?.scopeTag ?? null, @@ -1544,7 +1547,7 @@ export async function isSessionSourceForIdentity( .from(messageRequest) .where( and( - eq(messageSessionIdentity, identity), + messageSessionLookup(identity), eq(messageRequest.sessionId, sourceSessionId), isNull(messageRequest.deletedAt) ) @@ -1577,7 +1580,7 @@ export async function findSessionRequestLocator( .from(messageRequest) .where( and( - eq(messageSessionIdentity, identity), + messageSessionLookup(identity), isNotNull(messageRequest.sessionId), isNotNull(messageRequest.requestSequence), selector.requestId !== undefined ? eq(messageRequest.id, selector.requestId) : undefined, @@ -2098,6 +2101,7 @@ export async function findRequestsBySessionId( id: number; sourceSessionId: string; sequence: number; + displaySequence: number; model: string | null; statusCode: number | null; costUsd: string | null; @@ -2124,6 +2128,10 @@ export async function findRequestsBySessionId( id: messageRequest.id, sessionId: messageRequest.sessionId, sequence: messageRequest.requestSequence, + displaySequence: sql`COALESCE( + ${messageRequest.requestSequence}, + row_number() OVER (ORDER BY ${messageRequest.createdAt} ASC, ${messageRequest.id} ASC)::int + )`, model: messageRequest.model, statusCode: messageRequest.statusCode, costUsd: messageRequest.costUsd, @@ -2145,6 +2153,7 @@ export async function findRequestsBySessionId( id: r.id, sourceSessionId: r.sessionId ?? sessionId, sequence: r.sequence ?? 1, + displaySequence: r.displaySequence, model: r.model, statusCode: r.statusCode, costUsd: r.costUsd, @@ -2161,9 +2170,9 @@ export async function findRequestsBySessionIdentity( identity: string, options?: { limit?: number; offset?: number; order?: "asc" | "desc" } ): Promise>> { - const { limit = 20, offset = 0, order = "asc" } = options || {}; + const { limit = 20, offset = 0, order = "desc" } = options || {}; const where = and( - eq(messageSessionIdentity, identity), + messageSessionLookup(identity), isNotNull(messageRequest.sessionId), eq(messageRequest.isReplay, false), isNull(messageRequest.deletedAt) @@ -2177,6 +2186,19 @@ export async function findRequestsBySessionIdentity( id: messageRequest.id, sessionId: messageRequest.sessionId, sequence: messageRequest.requestSequence, + displaySequence: sql`CASE + WHEN ${messageRequest.sessionIdentityKind} = 'prefix_affinity' + AND NOT bool_or(COALESCE(${messageRequest.requestSequence}, 1) <> 1) OVER () + THEN COALESCE( + NULLIF(jsonb_array_length(${messageRequest.affinityFingerprintChain}), 0), + row_number() OVER (ORDER BY ${messageRequest.createdAt} ASC, ${messageRequest.id} ASC)::int + ) + ELSE COALESCE( + ${messageRequest.requestSequence}, + NULLIF(jsonb_array_length(${messageRequest.affinityFingerprintChain}), 0), + row_number() OVER (ORDER BY ${messageRequest.createdAt} ASC, ${messageRequest.id} ASC)::int + ) + END`, model: messageRequest.model, statusCode: messageRequest.statusCode, costUsd: messageRequest.costUsd, @@ -2202,6 +2224,7 @@ export async function findRequestsBySessionIdentity( id: row.id, sourceSessionId: row.sessionId, sequence: row.sequence ?? 1, + displaySequence: row.displaySequence, model: row.model, statusCode: row.statusCode, costUsd: row.costUsd, @@ -2275,7 +2298,7 @@ export async function findAdjacentSessionRequests( .from(messageRequest) .where( and( - eq(messageSessionIdentity, identity), + messageSessionLookup(identity), eq(messageRequest.id, requestId), eq(messageRequest.isReplay, false), isNull(messageRequest.deletedAt) @@ -2293,7 +2316,7 @@ export async function findAdjacentSessionRequests( requestSequence: messageRequest.requestSequence, }; const timelineFilter = and( - eq(messageSessionIdentity, identity), + messageSessionLookup(identity), isNotNull(messageRequest.sessionId), isNotNull(messageRequest.requestSequence), eq(messageRequest.isReplay, false), diff --git a/tests/api/v1/sessions/sessions.test.ts b/tests/api/v1/sessions/sessions.test.ts index 584b80164..2b89cac21 100644 --- a/tests/api/v1/sessions/sessions.test.ts +++ b/tests/api/v1/sessions/sessions.test.ts @@ -120,7 +120,14 @@ describe("v1 session endpoints", () => { headers, }); expect(exists.json).toEqual({ exists: true }); - expect(hasSessionMessagesMock).toHaveBeenCalledWith("s1", 2, "physical-1"); + expect(hasSessionMessagesMock).toHaveBeenCalledWith("s1", 2, "physical-1", undefined); + + await callV1Route({ + method: "GET", + pathname: "/api/v1/sessions/s1/messages/exists?sourceSessionId=physical-1&requestId=203", + headers, + }); + expect(hasSessionMessagesMock).toHaveBeenLastCalledWith("s1", undefined, "physical-1", 203); const requests = await callV1Route({ method: "GET", @@ -130,6 +137,13 @@ describe("v1 session endpoints", () => { expect(requests.response.status).toBe(200); expect(getSessionRequestsMock).toHaveBeenCalledWith("s1", 2, 5, "desc"); + await callV1Route({ + method: "GET", + pathname: "/api/v1/sessions/s1/requests", + headers, + }); + expect(getSessionRequestsMock).toHaveBeenLastCalledWith("s1", 1, 20, "desc"); + const origin = await callV1Route({ method: "GET", pathname: "/api/v1/sessions/s1/origin-chain?requestSequence=2&sourceSessionId=physical-1", @@ -215,7 +229,7 @@ describe("v1 session endpoints", () => { expect(queryParameterNames("/api/v1/sessions/{sessionId}")).toContain("requestId"); expect(queryParameterNames("/api/v1/sessions/{sessionId}/messages")).not.toContain("requestId"); - expect(queryParameterNames("/api/v1/sessions/{sessionId}/messages/exists")).not.toContain( + expect(queryParameterNames("/api/v1/sessions/{sessionId}/messages/exists")).toContain( "requestId" ); expect(queryParameterNames("/api/v1/sessions/{sessionId}/origin-chain")).not.toContain( diff --git a/tests/unit/actions/active-sessions-detail-snapshots.test.ts b/tests/unit/actions/active-sessions-detail-snapshots.test.ts index d750cda03..d7f736800 100644 --- a/tests/unit/actions/active-sessions-detail-snapshots.test.ts +++ b/tests/unit/actions/active-sessions-detail-snapshots.test.ts @@ -21,6 +21,7 @@ const getSessionRequestPhaseSnapshotMock = vi.fn(); const getSessionResponsePhaseSnapshotMock = vi.fn(); const aggregateSessionStatsMock = vi.fn(); +const aggregateMultipleSessionStatsMock = vi.fn(); const resolveSessionIdentityMock = vi.fn(); const isSessionSourceForIdentityMock = vi.fn(); const findSessionRequestLocatorMock = vi.fn(); @@ -71,6 +72,7 @@ vi.mock("@/lib/session-manager", () => ({ vi.mock("@/repository/message", () => ({ aggregateSessionStats: aggregateSessionStatsMock, + aggregateMultipleSessionStats: aggregateMultipleSessionStatsMock, resolveSessionIdentity: resolveSessionIdentityMock, isSessionSourceForIdentity: isSessionSourceForIdentityMock, findSessionRequestLocator: findSessionRequestLocatorMock, @@ -107,6 +109,10 @@ describe("getSessionDetails - additive detail snapshots contract", () => { apiType: "chat", cacheTtlApplied: null, }); + aggregateMultipleSessionStatsMock.mockImplementation(async (sessionIds: string[]) => { + const stats = await aggregateSessionStatsMock(sessionIds[0]); + return stats ? [{ ...stats, sessionId: stats.sessionId ?? sessionIds[0] }] : []; + }); resolveSessionIdentityMock.mockResolvedValue(null); isSessionSourceForIdentityMock.mockResolvedValue(true); findSessionRequestLocatorMock.mockImplementation( @@ -515,4 +521,19 @@ describe("getSessionDetails - additive detail snapshots contract", () => { expect(hasAnySessionMessagesMock).toHaveBeenCalledWith("sess_x"); expect(getSessionMessagesMock).not.toHaveBeenCalled(); }); + + test("uses request id to check the exact request when sequence is absent", async () => { + const { hasSessionMessages } = await import("@/actions/active-sessions"); + + const result = await hasSessionMessages("pfx:scope:root", undefined, "physical-a", 203); + + expect(result).toEqual({ ok: true, data: true }); + expect(findSessionRequestLocatorMock).toHaveBeenLastCalledWith("sess_x", { + requestId: 203, + requestSequence: undefined, + sourceSessionId: "physical-a", + }); + expect(getSessionMessagesMock).toHaveBeenCalledWith("physical-a", 1); + expect(hasAnySessionMessagesMock).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/actions/active-sessions-requests.test.ts b/tests/unit/actions/active-sessions-requests.test.ts index b63ecef2d..c7f58e6ed 100644 --- a/tests/unit/actions/active-sessions-requests.test.ts +++ b/tests/unit/actions/active-sessions-requests.test.ts @@ -2,12 +2,14 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; const getSessionMock = vi.fn(); const aggregateSessionStatsMock = vi.fn(); +const aggregateMultipleSessionStatsMock = vi.fn(); const findRequestsBySessionIdMock = vi.fn(); const findRequestsBySessionIdentityMock = vi.fn(); vi.mock("@/lib/auth", () => ({ getSession: getSessionMock })); vi.mock("@/repository/message", () => ({ aggregateSessionStats: aggregateSessionStatsMock, + aggregateMultipleSessionStats: aggregateMultipleSessionStatsMock, findRequestsBySessionId: findRequestsBySessionIdMock, findRequestsBySessionIdentity: findRequestsBySessionIdentityMock, })); @@ -38,6 +40,12 @@ describe("getSessionRequests public identity contract", () => { sessionId: "public-session-identity", userId: 1, }); + aggregateMultipleSessionStatsMock.mockResolvedValue([ + { + sessionId: "public-session-identity", + userId: 1, + }, + ]); findRequestsBySessionIdMock.mockResolvedValue({ requests: [], total: 0 }); findRequestsBySessionIdentityMock.mockResolvedValue({ requests: [], total: 0 }); }); @@ -56,4 +64,18 @@ describe("getSessionRequests public identity contract", () => { }); expect(findRequestsBySessionIdMock).not.toHaveBeenCalled(); }); + + test("defaults request lists to newest first", async () => { + const { getSessionRequests } = await import("@/actions/active-sessions"); + + await expect(getSessionRequests("public-session-identity")).resolves.toEqual({ + ok: true, + data: { requests: [], total: 0, hasMore: false }, + }); + expect(findRequestsBySessionIdentityMock).toHaveBeenCalledWith("public-session-identity", { + limit: 20, + offset: 0, + order: "desc", + }); + }); }); diff --git a/tests/unit/actions/active-sessions-special-settings.test.ts b/tests/unit/actions/active-sessions-special-settings.test.ts index 30ea1a58b..9cfe2989f 100644 --- a/tests/unit/actions/active-sessions-special-settings.test.ts +++ b/tests/unit/actions/active-sessions-special-settings.test.ts @@ -19,6 +19,7 @@ const getSessionRequestPhaseSnapshotMock = vi.fn(); const getSessionResponsePhaseSnapshotMock = vi.fn(); const aggregateSessionStatsMock = vi.fn(); +const aggregateMultipleSessionStatsMock = vi.fn(); const findSessionRequestLocatorMock = vi.fn(); const findAdjacentSessionRequestsMock = vi.fn(); const findMessageRequestAuditBySessionIdAndSequenceMock = vi.fn(); @@ -66,6 +67,7 @@ vi.mock("@/lib/session-manager", () => ({ vi.mock("@/repository/message", () => ({ aggregateSessionStats: aggregateSessionStatsMock, + aggregateMultipleSessionStats: aggregateMultipleSessionStatsMock, findSessionRequestLocator: findSessionRequestLocatorMock, findAdjacentSessionRequests: findAdjacentSessionRequestsMock, findMessageRequestAuditBySessionIdAndSequence: findMessageRequestAuditBySessionIdAndSequenceMock, @@ -99,6 +101,10 @@ describe("getSessionDetails - unified specialSettings", () => { apiType: "chat", cacheTtlApplied: null, }); + aggregateMultipleSessionStatsMock.mockImplementation(async (sessionIds: string[]) => { + const stats = await aggregateSessionStatsMock(sessionIds[0]); + return stats ? [{ ...stats, sessionId: stats.sessionId ?? sessionIds[0] }] : []; + }); findSessionRequestLocatorMock.mockResolvedValue({ sourceSessionId: "sess_x", requestSequence: 1, diff --git a/tests/unit/actions/active-sessions-termination.test.ts b/tests/unit/actions/active-sessions-termination.test.ts index 41e88fb10..426970f1c 100644 --- a/tests/unit/actions/active-sessions-termination.test.ts +++ b/tests/unit/actions/active-sessions-termination.test.ts @@ -239,6 +239,25 @@ describe("active Session termination identity contract", () => { expect(terminateObservedSessionMock).toHaveBeenCalledWith("physical-session"); }); + test("batch termination clears physical aliases and canonical detail caches", async () => { + aggregateMultipleSessionStatsMock.mockResolvedValue([ + { + sessionId: "pfx:scope:tip", + requestedSessionIds: ["physical-session"], + userId: 1, + }, + ]); + + const { terminateActiveSessionsBatch } = await import("@/actions/active-sessions"); + await expect(terminateActiveSessionsBatch(["physical-session"])).resolves.toMatchObject({ + ok: true, + data: { successCount: 1 }, + }); + + expect(clearSessionDetailsCacheMock).toHaveBeenCalledWith("physical-session"); + expect(clearSessionDetailsCacheMock).toHaveBeenCalledWith("pfx:scope:tip"); + }); + test("batch counts affinity invalidation failures instead of observed cleanup results", async () => { aggregateMultipleSessionStatsMock.mockResolvedValue([ { sessionId: "pfx:scope:tip", userId: 1 }, diff --git a/tests/unit/api/v1/api-client-actions.test.ts b/tests/unit/api/v1/api-client-actions.test.ts index 924bbf29f..4bad78fa2 100644 --- a/tests/unit/api/v1/api-client-actions.test.ts +++ b/tests/unit/api/v1/api-client-actions.test.ts @@ -95,6 +95,21 @@ describe("v1 action compatibility client", () => { ); }); + test("preserves the stable request id for message existence checks", async () => { + getMock.mockResolvedValue({ exists: true }); + + await activeSessions.hasSessionMessages( + "pfx:scope:fingerprint", + undefined, + "physical-session", + 203 + ); + + expect(getMock).toHaveBeenCalledWith( + "/api/v1/sessions/pfx%3Ascope%3Afingerprint/messages/exists?sourceSessionId=physical-session&requestId=203" + ); + }); + test("preserves provider edit undo metadata from response headers", async () => { patchMock.mockImplementation( async ( diff --git a/tests/unit/frontend/api-error-i18n.test.ts b/tests/unit/frontend/api-error-i18n.test.ts index f77a20c1a..7f9091859 100644 --- a/tests/unit/frontend/api-error-i18n.test.ts +++ b/tests/unit/frontend/api-error-i18n.test.ts @@ -4,6 +4,11 @@ import { getApiErrorMessageKey, getApiErrorMessageParams, } from "@/lib/api-client/v1/errors"; +import enDashboard from "../../../messages/en/dashboard.json"; +import jaDashboard from "../../../messages/ja/dashboard.json"; +import ruDashboard from "../../../messages/ru/dashboard.json"; +import zhCNDashboard from "../../../messages/zh-CN/dashboard.json"; +import zhTWDashboard from "../../../messages/zh-TW/dashboard.json"; describe("v1 API error i18n mapping", () => { test("maps problem error codes to existing translation keys instead of raw details", () => { @@ -84,4 +89,28 @@ describe("v1 API error i18n mapping", () => { ) ).toBe("OPERATION_FAILED"); }); + + test("maps Session REST codes to existing translation keys", () => { + expect( + getApiErrorMessageKey( + new ApiError({ status: 404, errorCode: "session.not_found", detail: "Not found" }) + ) + ).toBe("NOT_FOUND"); + + expect( + getApiErrorMessageKey( + new ApiError({ status: 400, errorCode: "session.action_failed", detail: "Bad request" }) + ) + ).toBe("OPERATION_FAILED"); + }); + + test("defines Session detail identity and error labels in every locale", () => { + for (const dashboard of [enDashboard, zhCNDashboard, zhTWDashboard, jaDashboard, ruDashboard]) { + expect(dashboard.sessions.status.error).toBeTruthy(); + expect(dashboard.sessions.details.canonicalSessionId).toBeTruthy(); + expect(dashboard.sessions.details.clientSessionId).toBeTruthy(); + expect(dashboard.logs.details.metadata.canonicalSessionId).toBeTruthy(); + expect(dashboard.logs.details.metadata.clientSessionId).toBeTruthy(); + } + }); }); diff --git a/tests/unit/repository/message-session-request-query.test.ts b/tests/unit/repository/message-session-request-query.test.ts index 94d3b92c0..c8442f280 100644 --- a/tests/unit/repository/message-session-request-query.test.ts +++ b/tests/unit/repository/message-session-request-query.test.ts @@ -43,7 +43,10 @@ type RequestRow = Pick< | "outputTokens" | "errorMessage" | "sessionId" -> & { readonly sequence: MessageRow["requestSequence"] }; +> & { + readonly sequence: MessageRow["requestSequence"]; + readonly displaySequence: number; +}; const firstCreatedAt = new Date("2026-05-04T10:00:00.000Z"); const secondCreatedAt = new Date("2026-05-04T10:01:00.000Z"); @@ -62,6 +65,7 @@ describe("message repository session request queries", () => { id: 31, sessionId: "session-requests", sequence: null, + displaySequence: 1, model: "model-a", statusCode: 200, costUsd: "0.100000000000000", @@ -74,6 +78,7 @@ describe("message repository session request queries", () => { id: 32, sessionId: "session-requests", sequence: 3, + displaySequence: 3, model: "model-b", statusCode: 429, costUsd: "0.200000000000000", @@ -94,6 +99,7 @@ describe("message repository session request queries", () => { id: 31, sourceSessionId: "session-requests", sequence: 1, + displaySequence: 1, model: "model-a", statusCode: 200, costUsd: "0.100000000000000", @@ -106,6 +112,7 @@ describe("message repository session request queries", () => { id: 32, sourceSessionId: "session-requests", sequence: 3, + displaySequence: 3, model: "model-b", statusCode: 429, costUsd: "0.200000000000000", @@ -132,6 +139,7 @@ describe("message repository session request queries", () => { id: 35, sessionId: "session-desc", sequence: 5, + displaySequence: 5, model: null, statusCode: null, costUsd: null, @@ -163,6 +171,7 @@ describe("message repository session request queries", () => { id: 41, sessionId: "physical-a", sequence: 1, + displaySequence: 2, model: "model-a", statusCode: 200, costUsd: "0", @@ -175,6 +184,7 @@ describe("message repository session request queries", () => { id: 42, sessionId: "physical-b", sequence: 1, + displaySequence: 4, model: "model-b", statusCode: 200, costUsd: "0", @@ -189,16 +199,50 @@ describe("message repository session request queries", () => { const result = await findRequestsBySessionIdentity("pfx:scope:fingerprint"); expect( - result.requests.map(({ sourceSessionId, sequence }) => ({ sourceSessionId, sequence })) + result.requests.map(({ sourceSessionId, sequence, displaySequence }) => ({ + sourceSessionId, + sequence, + displaySequence, + })) ).toEqual([ - { sourceSessionId: "physical-a", sequence: 1 }, - { sourceSessionId: "physical-b", sequence: 1 }, + { sourceSessionId: "physical-a", sequence: 1, displaySequence: 2 }, + { sourceSessionId: "physical-b", sequence: 1, displaySequence: 4 }, ]); expect(sqlText(count.trace.where)).toContain("is_replay"); expect(sqlText(count.trace.where)).toContain("false"); expect(sqlText(rows.trace.where)).toContain("pfx:scope:fingerprint"); expect(sqlText(rows.trace.where)).toContain("is_replay"); expect(sqlText(rows.trace.where)).toContain("false"); + expect(sqlText(rows.trace.orderBy)).toContain("created_at desc"); + expect(sqlText(rows.trace.orderBy)).toContain("id desc"); + }); + + test("accepts a physical Session id when resolving a request locator", async () => { + const locator = createDrizzleQuery([ + { + requestId: 204, + sourceSessionId: "physical-a", + requestSequence: 1, + identityKind: "prefix_affinity", + scopeTag: "scope", + fingerprint: "fingerprint", + }, + ]); + boundary.select.mockReturnValueOnce(locator); + + await expect(findSessionRequestLocator("physical-a", { requestId: 204 })).resolves.toEqual({ + requestId: 204, + sourceSessionId: "physical-a", + requestSequence: 1, + identityKind: "prefix_affinity", + scopeTag: "scope", + fingerprint: "fingerprint", + }); + + const where = sqlText(locator.trace.where); + expect(where).toContain("physical-a"); + expect(where).toContain("session_identity"); + expect(where).toContain("session_id"); }); test("resolves an exact request locator inside a prefix identity", async () => { From 9a522ff3a910ad19d94fc33fbc0b3fc276092c0d Mon Sep 17 00:00:00 2001 From: ding113 Date: Sun, 2 Aug 2026 16:00:02 +0800 Subject: [PATCH 2/5] fix: isolate session detail lookups by owner --- messages/en/dashboard.json | 4 +- messages/ja/dashboard.json | 4 +- messages/ru/dashboard.json | 4 +- messages/zh-CN/dashboard.json | 4 +- messages/zh-TW/dashboard.json | 4 +- src/actions/active-sessions.ts | 139 ++++++++++--- src/actions/session-origin-chain.ts | 18 +- src/actions/session-response.ts | 26 ++- .../_components/error-details-dialog.test.tsx | 55 +++++ .../error-details-dialog/index.tsx | 4 + .../_components/request-list-sidebar.test.tsx | 55 ++++- .../_components/request-list-sidebar.tsx | 7 +- src/app/v1/_lib/proxy/session-guard.ts | 2 +- src/lib/cache/session-cache.ts | 51 ++++- .../session-manager-detail-snapshots.test.ts | 55 ++++- src/lib/session-manager.ts | 34 ++- src/lib/session-request-locator.ts | 19 +- src/repository/message.ts | 196 +++++++++++++----- .../active-sessions-detail-snapshots.test.ts | 72 +++++-- .../actions/active-sessions-requests.test.ts | 23 ++ .../active-sessions-special-settings.test.ts | 15 +- .../active-sessions-termination.test.ts | 53 ++++- .../session-origin-chain-integration.test.ts | 11 +- .../unit/actions/session-origin-chain.test.ts | 25 ++- tests/unit/actions/session-response.test.ts | 48 ++++- tests/unit/frontend/api-error-i18n.test.ts | 2 + tests/unit/lib/cache/session-cache.test.ts | 70 ++++++- .../unit/lib/session-request-locator.test.ts | 75 ++++++- ...e-aggregate-multiple-session-stats.test.ts | 37 +++- .../message-aggregate-session-stats.test.ts | 4 +- .../message-public-readback.test.ts | 12 +- .../message-session-request-query.test.ts | 76 ++++++- 32 files changed, 1005 insertions(+), 199 deletions(-) diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index d603cf3b5..02fffca1e 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -939,7 +939,9 @@ "prev": "Prev", "next": "Next", "orderAsc": "Switch to ascending order (oldest first)", - "orderDesc": "Switch to descending order (newest first)" + "orderDesc": "Switch to descending order (newest first)", + "itemTitle": "Request #{sequence} - {model}", + "unknownModel": "Unknown model" }, "back": "Back", "loadingError": "Loading failed" diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index a1a7a927f..2ec011c52 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -939,7 +939,9 @@ "prev": "前へ", "next": "次へ", "orderAsc": "昇順に切り替え(古い順)", - "orderDesc": "降順に切り替え(新しい順)" + "orderDesc": "降順に切り替え(新しい順)", + "itemTitle": "リクエスト #{sequence} - {model}", + "unknownModel": "不明なモデル" }, "back": "戻る", "loadingError": "読み込み失敗" diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 8434be5ec..14a281b3f 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -939,7 +939,9 @@ "prev": "Назад", "next": "Вперед", "orderAsc": "Переключить на прямой порядок (старые первыми)", - "orderDesc": "Переключить на обратный порядок (новые первыми)" + "orderDesc": "Переключить на обратный порядок (новые первыми)", + "itemTitle": "Запрос #{sequence} - {model}", + "unknownModel": "Неизвестная модель" }, "back": "Назад", "loadingError": "Не удалось загрузить" diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 67ece1026..ea3d3885c 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -939,7 +939,9 @@ "prev": "上一页", "next": "下一页", "orderAsc": "切换为正序(最早的在前)", - "orderDesc": "切换为倒序(最新的在前)" + "orderDesc": "切换为倒序(最新的在前)", + "itemTitle": "请求 #{sequence} - {model}", + "unknownModel": "未知模型" }, "back": "返回", "loadingError": "加载失败" diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index ab387ccab..3f338fc5c 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -939,7 +939,9 @@ "prev": "上一頁", "next": "下一頁", "orderAsc": "切換為正序(最早的在前)", - "orderDesc": "切換為倒序(最新的在前)" + "orderDesc": "切換為倒序(最新的在前)", + "itemTitle": "請求 #{sequence} - {model}", + "unknownModel": "未知模型" }, "back": "返回上一頁", "loadingError": "載入失敗" diff --git a/src/actions/active-sessions.ts b/src/actions/active-sessions.ts index 5c0d04693..d672357ad 100644 --- a/src/actions/active-sessions.ts +++ b/src/actions/active-sessions.ts @@ -12,6 +12,7 @@ import { extractAfterRequestMessages, isSessionMessages } from "@/lib/session-de import { resolveSessionRequestLocator } from "@/lib/session-request-locator"; import { normalizeRequestSequence } from "@/lib/utils/request-sequence"; import { buildUnifiedSpecialSettings } from "@/lib/utils/special-settings"; +import { isReservedSessionIdentity } from "@/repository/_shared/usage-log-filters"; import { type ActiveSessionInfo, DEFAULT_SESSION_DETAIL_VIEW_MODE, @@ -31,9 +32,12 @@ type CanonicalSessionStats = Awaited< ReturnType >[number]; -async function loadCanonicalSessionStats(sessionId: string): Promise { +async function loadCanonicalSessionStats( + sessionId: string, + ownerUserId?: number +): Promise { const { aggregateMultipleSessionStats } = await import("@/repository/message"); - const [sessionStats] = await aggregateMultipleSessionStats([sessionId]); + const [sessionStats] = await aggregateMultipleSessionStats([sessionId], ownerUserId); return sessionStats ?? null; } @@ -64,6 +68,7 @@ async function loadSessionTerminationDependencies(): Promise { const { @@ -78,10 +83,18 @@ async function terminateResolvedSessionIdentity( !resolution.scopeTag || !resolution.fingerprint ) { - const terminated = await SessionManager.terminateSession( - resolution?.sourceSessionId ?? identity + const physicalSources = await listPhysicalSessionSourcesForIdentity(identity, ownerUserId); + const outcomes = await Promise.all( + physicalSources.map((source) => + SessionManager.terminateSession( + source.sessionId, + source.providerIds.length > 0 ? source.providerIds : undefined, + source.keyId + ) + ) ); - if (terminated) { + const terminated = outcomes.some(Boolean); + if (terminated && isReservedSessionIdentity(identity)) { await SessionTracker.terminateObservedSession(identity); } return terminated; @@ -94,7 +107,7 @@ async function terminateResolvedSessionIdentity( ); if (!invalidated) return false; - const physicalSources = await listPhysicalSessionSourcesForIdentity(identity); + const physicalSources = await listPhysicalSessionSourcesForIdentity(identity, ownerUserId); for (const source of physicalSources) { if ( @@ -657,7 +670,10 @@ export async function getSessionMessages( const currentUserId = authSession.user.id; // 1. 获取 session 统计数据以验证所有权 - const sessionStats = await loadCanonicalSessionStats(sessionId); + const sessionStats = await loadCanonicalSessionStats( + sessionId, + isAdmin ? undefined : currentUserId + ); if (!sessionStats) { return { @@ -681,11 +697,22 @@ export async function getSessionMessages( const locatorResult = await resolveSessionRequestLocator( sessionStats.sessionId, requestSequence, - requestedSourceSessionId + requestedSourceSessionId, + undefined, + sessionStats.userId ); if (!locatorResult.ok) return locatorResult; const { SessionManager } = await import("@/lib/session-manager"); + if ( + !(await SessionManager.isSessionRequestOwnedByKey( + locatorResult.locator.sourceSessionId, + locatorResult.locator.requestSequence, + locatorResult.locator.keyId + )) + ) { + return { ok: false, error: "Messages 未存储或已过期" }; + } const messages = await SessionManager.getSessionMessages( locatorResult.locator.sourceSessionId, locatorResult.locator.requestSequence @@ -738,7 +765,10 @@ export async function hasSessionMessages( const currentUserId = authSession.user.id; // 检查 Session 所有权(需要从数据库获取 userId) - const sessionStats = await loadCanonicalSessionStats(sessionId); + const sessionStats = await loadCanonicalSessionStats( + sessionId, + isAdmin ? undefined : currentUserId + ); if (!sessionStats) { return { @@ -762,12 +792,22 @@ export async function hasSessionMessages( sessionStats.sessionId, requestSequence, requestedSourceSessionId, - requestId + requestId, + sessionStats.userId ); if (!locatorResult.ok) return locatorResult; const { SessionManager } = await import("@/lib/session-manager"); const sourceSessionId = locatorResult.locator.sourceSessionId; + if ( + !(await SessionManager.isSessionRequestOwnedByKey( + sourceSessionId, + locatorResult.locator.requestSequence, + locatorResult.locator.keyId + )) + ) { + return { ok: true, data: false }; + } // 只有有效的显式序号才检查特定请求;非法值按未指定序号处理。 if (requestId !== undefined || normalizeRequestSequence(requestSequence) !== null) { @@ -848,7 +888,7 @@ export async function getSessionDetails( const currentUserId = authSession.user.id; // 1. 尝试从缓存获取统计数据 - const cachedStats = getSessionDetailsCache(sessionId); + const cachedStats = isAdmin ? null : getSessionDetailsCache(sessionId, currentUserId); let sessionStats: CanonicalSessionStats | null; @@ -857,7 +897,10 @@ export async function getSessionDetails( sessionStats = cachedStats; } else { // 2. 从数据库查询 - sessionStats = await loadCanonicalSessionStats(sessionId); + sessionStats = await loadCanonicalSessionStats( + sessionId, + isAdmin ? undefined : currentUserId + ); // 3. 写入缓存 if (sessionStats) { @@ -893,7 +936,8 @@ export async function getSessionDetails( canonicalSessionId, requestSequence, requestedSourceSessionId, - requestId + requestId, + sessionStats.userId ); if (!locatorResult.ok) return locatorResult; @@ -904,12 +948,17 @@ export async function getSessionDetails( // 5. 请求 locator 已同时验证 identity、物理 Session 和序号,后续所有读取必须复用它。 const { SessionManager } = await import("@/lib/session-manager"); - const { findAdjacentSessionRequests, findMessageRequestAuditBySessionIdAndSequence } = - await import("@/repository/message"); + const { findAdjacentSessionRequests, findMessageRequestAuditById } = await import( + "@/repository/message" + ); const adjacent = effectiveSequence == null ? { prevRequest: null, nextRequest: null } - : await findAdjacentSessionRequests(canonicalSessionId, effectiveRequestId); + : await findAdjacentSessionRequests( + canonicalSessionId, + effectiveRequestId, + sessionStats.userId + ); const parseJsonStringOrNull = (value: unknown): unknown => { if (typeof value !== "string") return value; @@ -933,6 +982,19 @@ export async function getSessionDetails( } }; + if ( + !(await SessionManager.isSessionRequestOwnedByKey( + sourceSessionId, + effectiveSequence, + locatorResult.locator.keyId + )) + ) { + return { + ok: false, + error: "Session 请求详情已过期", + }; + } + // 6. 并行获取 messages、requestBody 和 response(不缓存,因为这些数据较大) const [ requestBody, @@ -959,9 +1021,7 @@ export async function getSessionDetails( SessionManager.getSessionUpstreamRequestMeta(sourceSessionId, effectiveSequence), SessionManager.getSessionUpstreamResponseMeta(sourceSessionId, effectiveSequence), SessionManager.getSessionSpecialSettings(sourceSessionId, effectiveSequence), - effectiveSequence - ? findMessageRequestAuditBySessionIdAndSequence(sourceSessionId, effectiveSequence) - : Promise.resolve(null), + findMessageRequestAuditById(effectiveRequestId, sessionStats.userId), SessionManager.getSessionRequestPhaseSnapshot(sourceSessionId, "before", effectiveSequence), SessionManager.getSessionRequestPhaseSnapshot(sourceSessionId, "after", effectiveSequence), SessionManager.getSessionResponsePhaseSnapshot(sourceSessionId, "before", effectiveSequence), @@ -1126,7 +1186,10 @@ export async function getSessionRequests( const currentUserId = authSession.user.id; // 1. 验证 Session 所有权 - const sessionStats = await loadCanonicalSessionStats(sessionId); + const sessionStats = await loadCanonicalSessionStats( + sessionId, + isAdmin ? undefined : currentUserId + ); if (!sessionStats) { return { @@ -1152,6 +1215,7 @@ export async function getSessionRequests( limit: pageSize, offset, order, + ownerUserId: sessionStats.userId, }); return { @@ -1197,7 +1261,10 @@ export async function terminateActiveSession(sessionId: string): Promise [session.sessionId, session.userId] as const) + ); for (let offset = 0; offset < allowedSessionIds.length; offset += terminationChunkSize) { const chunk = allowedSessionIds.slice(offset, offset + terminationChunkSize); const outcomes = await Promise.allSettled( chunk.map(async (identity) => { - const resolution = await resolveSessionIdentity(identity); - return terminateResolvedSessionIdentity(identity, resolution, terminationDependencies); + const ownerUserId = ownerByCanonicalId.get(identity); + if (ownerUserId === undefined) return false; + const resolution = await resolveSessionIdentity(identity, ownerUserId); + return terminateResolvedSessionIdentity( + identity, + resolution, + ownerUserId, + terminationDependencies + ); }) ); for (const [index, outcome] of outcomes.entries()) { diff --git a/src/actions/session-origin-chain.ts b/src/actions/session-origin-chain.ts index b95c1d301..c60f10365 100644 --- a/src/actions/session-origin-chain.ts +++ b/src/actions/session-origin-chain.ts @@ -3,7 +3,7 @@ import { getSession } from "@/lib/auth"; import { logger } from "@/lib/logger"; import { resolveSessionRequestLocator } from "@/lib/session-request-locator"; -import { aggregateSessionStats, findSessionOriginChain } from "@/repository/message"; +import { aggregateMultipleSessionStats, findSessionOriginChain } from "@/repository/message"; import type { ProviderChainItem } from "@/types/message"; import type { ActionResult } from "./types"; @@ -18,7 +18,10 @@ export async function getSessionOriginChain( return { ok: false, error: "未登录" }; } - const sessionStats = await aggregateSessionStats(sessionId); + const [sessionStats] = await aggregateMultipleSessionStats( + [sessionId], + session.user.role === "admin" ? undefined : session.user.id + ); if (!sessionStats) { return { ok: false, error: "Session 不存在" }; } @@ -28,13 +31,18 @@ export async function getSessionOriginChain( } const locatorResult = await resolveSessionRequestLocator( - sessionId, + sessionStats.sessionId, requestSequence, - requestedSourceSessionId + requestedSourceSessionId, + undefined, + sessionStats.userId ); if (!locatorResult.ok) return locatorResult; - const chain = await findSessionOriginChain(locatorResult.locator.sourceSessionId); + const chain = await findSessionOriginChain( + locatorResult.locator.sourceSessionId, + sessionStats.userId + ); return { ok: true, data: chain ?? null }; } catch (error) { logger.error("获取会话来源链失败:", error); diff --git a/src/actions/session-response.ts b/src/actions/session-response.ts index e00de0e8c..9b5d233c0 100644 --- a/src/actions/session-response.ts +++ b/src/actions/session-response.ts @@ -33,8 +33,11 @@ export async function getSessionResponse( const currentUserId = authSession.user.id; // 1. 获取 session 统计数据以验证所有权 - const { aggregateSessionStats } = await import("@/repository/message"); - const sessionStats = await aggregateSessionStats(sessionId); + const { aggregateMultipleSessionStats } = await import("@/repository/message"); + const [sessionStats] = await aggregateMultipleSessionStats( + [sessionId], + isAdmin ? undefined : currentUserId + ); if (!sessionStats) { return { @@ -55,12 +58,27 @@ export async function getSessionResponse( } const locatorResult = await resolveSessionRequestLocator( - sessionId, + sessionStats.sessionId, requestSequence, - requestedSourceSessionId + requestedSourceSessionId, + undefined, + sessionStats.userId ); if (!locatorResult.ok) return locatorResult; + if ( + !(await SessionManager.isSessionRequestOwnedByKey( + locatorResult.locator.sourceSessionId, + locatorResult.locator.requestSequence, + locatorResult.locator.keyId + )) + ) { + return { + ok: false, + error: "响应体已过期(5分钟 TTL)或尚未记录", + }; + } + // 3. 只读取 locator 已授权的物理请求响应体 const response = await SessionManager.getSessionResponse( locatorResult.locator.sourceSessionId, diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx index d2b058427..dc6f5deff 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx @@ -422,6 +422,15 @@ function renderClientWithIntl(node: ReactNode) { return { container, + rerender: (nextNode: ReactNode) => { + act(() => { + root.render( + + {nextNode} + + ); + }); + }, unmount: () => { act(() => root.unmount()); container.remove(); @@ -614,6 +623,52 @@ describe("error-details-dialog layout", () => { unmount(); }); + test("clears stale Session detail availability while checking a new request", async () => { + hasSessionMessagesMock.mockResolvedValueOnce({ ok: true, data: true }); + const pendingCheck = Promise.withResolvers<{ ok: false; error: string }>(); + const rendered = renderClientWithIntl( + + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(rendered.container.querySelector('a[href*="requestId=203"]')).toBeTruthy(); + + hasSessionMessagesMock.mockReturnValueOnce(pendingCheck.promise); + rendered.rerender( + + ); + + expect(rendered.container.querySelector('a[href*="requestId=203"]')).toBeNull(); + + await act(async () => { + pendingCheck.resolve({ ok: false, error: "not found" }); + await pendingCheck.promise; + }); + expect(rendered.container.querySelector('a[href*="requestId=204"]')).toBeNull(); + rendered.unmount(); + }); + test("marks Replay requests and shows their source request", () => { const html = renderWithIntl( { @@ -179,6 +182,7 @@ export function ErrorDetailsDialog({ } }); } else { + ++messageCheckRequestIdRef.current; setHasMessages(false); setCheckingMessages(false); } diff --git a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/request-list-sidebar.test.tsx b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/request-list-sidebar.test.tsx index 97092c5ed..16bd4ef22 100644 --- a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/request-list-sidebar.test.tsx +++ b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/request-list-sidebar.test.tsx @@ -5,11 +5,17 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, describe, expect, test, vi } from "vitest"; -import { RequestListSidebar } from "./request-list-sidebar"; +import { RequestListSidebar } from "@/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/request-list-sidebar"; const getSessionRequestsMock = vi.fn(); const { translateMock } = vi.hoisted(() => ({ - translateMock: (key: string) => key, + translateMock: (key: string, values?: Record) => { + if (key === "requestList.itemTitle") { + return `Request #${values?.sequence} - ${values?.model}`; + } + if (key === "requestList.unknownModel") return "Unknown model"; + return key; + }, })); vi.mock("@/lib/api-client/v1/actions/active-sessions", () => ({ @@ -101,4 +107,49 @@ describe("RequestListSidebar", () => { act(() => root.unmount()); }); + + test("localizes the collapsed request title and unknown model fallback", async () => { + getSessionRequestsMock.mockResolvedValue({ + ok: true, + data: { + requests: [ + { + id: 42, + sourceSessionId: "physical-new", + sequence: 1, + displaySequence: 4, + model: null, + statusCode: 200, + costUsd: "0", + createdAt: new Date("2026-08-02T00:04:00.000Z"), + inputTokens: 4, + outputTokens: 4, + errorMessage: null, + }, + ], + total: 1, + hasMore: false, + }, + }); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + + ); + }); + await flushEffects(); + + expect(container.querySelector("button")?.title).toBe("Request #4 - Unknown model"); + + act(() => root.unmount()); + }); }); diff --git a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/request-list-sidebar.tsx b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/request-list-sidebar.tsx index 4ad2e5404..d6e2eb27d 100644 --- a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/request-list-sidebar.tsx +++ b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/request-list-sidebar.tsx @@ -129,7 +129,10 @@ export function RequestListSidebar({ ? "bg-primary text-primary-foreground shadow-sm" : "hover:bg-muted" )} - title={`#${req.displaySequence} - ${req.model || "Unknown"}`} + title={t("requestList.itemTitle", { + sequence: req.displaySequence, + model: req.model ?? t("requestList.unknownModel"), + })} > {req.displaySequence} @@ -256,7 +259,7 @@ export function RequestListSidebar({ !request.model && "text-muted-foreground italic" )} > - {request.model || "Unknown Model"} + {request.model || t("requestList.unknownModel")} diff --git a/src/app/v1/_lib/proxy/session-guard.ts b/src/app/v1/_lib/proxy/session-guard.ts index e7b4a7f03..e04d33911 100644 --- a/src/app/v1/_lib/proxy/session-guard.ts +++ b/src/app/v1/_lib/proxy/session-guard.ts @@ -223,7 +223,7 @@ export class ProxySessionGuard { } // 4.1 获取并设置请求序号(Session 内唯一标识每个请求) - const requestSequence = await SessionManager.getNextRequestSequence(sessionId); + const requestSequence = await SessionManager.getNextRequestSequence(sessionId, keyId); session.setRequestSequence(requestSequence); // 4.2 存储完整请求体与客户端端点(用于 Session 详情调试) diff --git a/src/lib/cache/session-cache.ts b/src/lib/cache/session-cache.ts index c81bcc98e..483eb22c6 100644 --- a/src/lib/cache/session-cache.ts +++ b/src/lib/cache/session-cache.ts @@ -96,6 +96,12 @@ const sessionDetailsCache = new SessionCache<{ apiType: string | null; cacheTtlApplied: string | null; }>(1, 10_000); +const sessionDetailsAliasesCache = new SessionCache>(1, 10_000); +const sessionDetailsOwnersCache = new SessionCache>(1, 10_000); + +function sessionDetailsCacheKey(sessionId: string, userId: number): string { + return `${userId}:${sessionId}`; +} // Store interval ID on globalThis for HMR support const cacheCleanupState = globalThis as unknown as { @@ -113,15 +119,28 @@ export function setActiveSessionsCache( activeSessionsCache.set(key, data); } -export function getSessionDetailsCache(sessionId: string) { - return sessionDetailsCache.get(sessionId); +export function getSessionDetailsCache(sessionId: string, userId: number) { + return sessionDetailsCache.get(sessionDetailsCacheKey(sessionId, userId)); } export function setSessionDetailsCache( sessionId: string, data: Parameters[1] ) { - sessionDetailsCache.set(sessionId, data); + const canonicalKey = sessionDetailsCacheKey(data.sessionId, data.userId); + const cacheKey = sessionDetailsCacheKey(sessionId, data.userId); + sessionDetailsCache.set(cacheKey, data); + + const aliases = sessionDetailsAliasesCache.get(canonicalKey) ?? new Set(); + aliases.add(canonicalKey); + aliases.add(cacheKey); + sessionDetailsAliasesCache.set(canonicalKey, aliases); + + for (const identity of [data.sessionId, sessionId]) { + const owners = sessionDetailsOwnersCache.get(identity) ?? new Set(); + owners.add(canonicalKey); + sessionDetailsOwnersCache.set(identity, owners); + } } export function clearActiveSessionsCache() { @@ -136,7 +155,27 @@ export function clearAllSessionsQueryCache() { } export function clearSessionDetailsCache(sessionId: string) { - sessionDetailsCache.delete(sessionId); + const canonicalKeys = sessionDetailsOwnersCache.get(sessionId) ?? new Set(); + for (const canonicalKey of canonicalKeys) { + const aliases = sessionDetailsAliasesCache.get(canonicalKey) ?? new Set([canonicalKey]); + for (const alias of aliases) { + sessionDetailsCache.delete(alias); + const separatorIndex = alias.indexOf(":"); + if (separatorIndex >= 0) { + const identity = alias.slice(separatorIndex + 1); + const owners = sessionDetailsOwnersCache.get(identity); + if (owners) { + owners.delete(canonicalKey); + if (owners.size === 0) { + sessionDetailsOwnersCache.delete(identity); + } else { + sessionDetailsOwnersCache.set(identity, owners); + } + } + } + } + sessionDetailsAliasesCache.delete(canonicalKey); + } } /** @@ -145,6 +184,8 @@ export function clearSessionDetailsCache(sessionId: string) { export function clearAllCaches() { activeSessionsCache.clear(); sessionDetailsCache.clear(); + sessionDetailsAliasesCache.clear(); + sessionDetailsOwnersCache.clear(); } export function startCacheCleanup(intervalSeconds: number = 60) { @@ -155,6 +196,8 @@ export function startCacheCleanup(intervalSeconds: number = 60) { cacheCleanupState.__CCH_CACHE_CLEANUP_INTERVAL_ID__ = setInterval(() => { activeSessionsCache.cleanup(); sessionDetailsCache.cleanup(); + sessionDetailsAliasesCache.cleanup(); + sessionDetailsOwnersCache.cleanup(); }, intervalSeconds * 1000); } diff --git a/src/lib/session-manager-detail-snapshots.test.ts b/src/lib/session-manager-detail-snapshots.test.ts index c03e150d2..452dafb0a 100644 --- a/src/lib/session-manager-detail-snapshots.test.ts +++ b/src/lib/session-manager-detail-snapshots.test.ts @@ -26,6 +26,18 @@ vi.mock("@/app/v1/_lib/proxy/errors", () => ({ })); const redisStore = new Map(); +const pipelineSetexMock = vi.fn().mockReturnThis(); +const pipelineHsetMock = vi.fn().mockReturnThis(); +const pipelineExpireMock = vi.fn().mockReturnThis(); +const pipelineDelMock = vi.fn().mockReturnThis(); +const pipelineExecMock = vi.fn().mockResolvedValue([]); +const redisPipeline = { + setex: pipelineSetexMock, + hset: pipelineHsetMock, + expire: pipelineExpireMock, + del: pipelineDelMock, + exec: pipelineExecMock, +}; const redisMock = { status: "ready", setex: vi.fn((key: string, _ttl: number, value: string) => { @@ -36,13 +48,7 @@ const redisMock = { set: vi.fn().mockResolvedValue("OK"), expire: vi.fn().mockResolvedValue(1), incr: vi.fn().mockResolvedValue(1), - pipeline: vi.fn(() => ({ - setex: vi.fn().mockReturnThis(), - hset: vi.fn().mockReturnThis(), - expire: vi.fn().mockReturnThis(), - del: vi.fn().mockReturnThis(), - exec: vi.fn().mockResolvedValue([]), - })), + pipeline: vi.fn(() => redisPipeline), }; vi.mock("@/lib/redis", () => ({ @@ -66,10 +72,45 @@ describe("SessionManager detail snapshots", () => { beforeEach(() => { vi.clearAllMocks(); redisStore.clear(); + redisMock.status = "ready"; mockStoreMessages = false; mockStoreSessionResponseBody = true; }); + it("refreshes the sequence TTL and records the immutable request owner", async () => { + redisMock.incr.mockResolvedValueOnce(1); + + await expect(SessionManager.getNextRequestSequence("sess_owner", 42)).resolves.toBe(1); + + expect(redisMock.incr).toHaveBeenCalledWith("session:sess_owner:seq"); + expect(pipelineExpireMock).toHaveBeenCalledWith("session:sess_owner:seq", 300); + expect(pipelineSetexMock).toHaveBeenCalledWith("session:sess_owner:req:1:owner", 300, "42"); + expect(pipelineExecMock).toHaveBeenCalledOnce(); + }); + + it("validates request artifacts against their immutable key owner", async () => { + redisStore.set("session:sess_owner:req:1:owner", "42"); + + await expect(SessionManager.isSessionRequestOwnedByKey("sess_owner", 1, 42)).resolves.toBe( + true + ); + await expect(SessionManager.isSessionRequestOwnedByKey("sess_owner", 1, 43)).resolves.toBe( + false + ); + await expect(SessionManager.isSessionRequestOwnedByKey("sess_owner", 2, 42)).resolves.toBe( + false + ); + }); + + it("fails request artifact ownership checks closed when Redis is unavailable", async () => { + redisMock.status = "end"; + + await expect(SessionManager.isSessionRequestOwnedByKey("sess_owner", 1, 42)).resolves.toBe( + false + ); + expect(redisMock.get).not.toHaveBeenCalled(); + }); + it("stores and retrieves request/response before-after snapshots with TTL and redaction", async () => { await SessionManager.storeSessionRequestPhaseSnapshot( "sess_snap", diff --git a/src/lib/session-manager.ts b/src/lib/session-manager.ts index 03b2ae725..a0fdbaaec 100644 --- a/src/lib/session-manager.ts +++ b/src/lib/session-manager.ts @@ -363,7 +363,7 @@ export class SessionManager { * @param sessionId - Session ID * @returns 请求序号(从 1 开始),Redis 不可用时返回基于时间戳的唯一序号 */ - static async getNextRequestSequence(sessionId: string): Promise { + static async getNextRequestSequence(sessionId: string, keyId: number): Promise { const redis = getRedisClient(); if (redis?.status !== "ready") { // 改进的 fallback:使用时间戳 + 随机数生成伪唯一序号 @@ -379,11 +379,11 @@ export class SessionManager { try { const key = `session:${sessionId}:seq`; const sequence = await redis.incr(key); - - // 首次创建时设置过期时间 - if (sequence === 1) { - await redis.expire(key, SessionManager.SESSION_TTL); - } + const ownerKey = `session:${sessionId}:req:${sequence}:owner`; + const pipeline = redis.pipeline(); + pipeline.expire(key, SessionManager.SESSION_TTL); + pipeline.setex(ownerKey, SessionManager.SESSION_TTL, String(keyId)); + await pipeline.exec(); logger.trace("SessionManager: Got next request sequence", { sessionId, @@ -402,6 +402,28 @@ export class SessionManager { } } + static async isSessionRequestOwnedByKey( + sessionId: string, + requestSequence: number, + expectedKeyId: number + ): Promise { + const redis = getRedisClient(); + if (redis?.status !== "ready") return false; + + try { + const ownerKey = `session:${sessionId}:req:${requestSequence}:owner`; + return (await redis.get(ownerKey)) === String(expectedKeyId); + } catch (error) { + logger.error("SessionManager: Failed to validate request artifact owner", { + error, + sessionId, + requestSequence, + expectedKeyId, + }); + return false; + } + } + /** * 获取 Session 当前的请求计数 * diff --git a/src/lib/session-request-locator.ts b/src/lib/session-request-locator.ts index f216c7287..037401846 100644 --- a/src/lib/session-request-locator.ts +++ b/src/lib/session-request-locator.ts @@ -14,10 +14,11 @@ export async function resolveSessionRequestLocator( identity: string, requestSequence?: number, sourceSessionId?: string, - requestId?: number + requestId?: number, + ownerUserId?: number ): Promise { const normalizedSequence = normalizeRequestSequence(requestSequence); - const identityLocator = await findSessionRequestLocator(identity); + const identityLocator = await findSessionRequestLocator(identity, {}, ownerUserId); if (!identityLocator) { return { @@ -42,11 +43,15 @@ export async function resolveSessionRequestLocator( const locator = normalizedSequence !== null || sourceSessionId || requestId !== undefined - ? await findSessionRequestLocator(identity, { - requestId, - requestSequence: normalizedSequence ?? undefined, - sourceSessionId, - }) + ? await findSessionRequestLocator( + identity, + { + requestId, + requestSequence: normalizedSequence ?? undefined, + sourceSessionId, + }, + ownerUserId + ) : identityLocator; return locator diff --git a/src/repository/message.ts b/src/repository/message.ts index 5e5a70741..a6bf891f4 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -1,6 +1,6 @@ "use server"; -import { and, asc, desc, eq, gt, inArray, isNotNull, isNull, lt, or, sql } from "drizzle-orm"; +import { and, asc, desc, eq, gt, isNotNull, isNull, lt, or, sql } from "drizzle-orm"; import { db, getMessageWriterDb } from "@/drizzle/db"; import { keys as keysTable, messageRequest, providers, usageLedger, users } from "@/drizzle/schema"; import { getEnvConfig } from "@/lib/config/env.schema"; @@ -18,6 +18,7 @@ import type { SpecialSetting } from "@/types/special-settings"; import { LEDGER_AUDIT_CONDITION, LEDGER_BILLING_CONDITION } from "./_shared/ledger-conditions"; import { EXCLUDE_WARMUP_CONDITION } from "./_shared/message-request-conditions"; import { toMessageRequest } from "./_shared/transformers"; +import { isReservedSessionIdentity } from "./_shared/usage-log-filters"; import { type DurableMessageRequestUpdateOptions, enqueueMessageRequestPostTerminalRoutingTraceDurably, @@ -35,17 +36,52 @@ const POST_TERMINAL_ROUTING_TRACE_ACK_TIMEOUT_MS = 3_000; const ledgerSessionIdentity = sql`COALESCE(${usageLedger.sessionIdentity}, ${usageLedger.sessionId})`; const messageSessionIdentity = sql`COALESCE(${messageRequest.sessionIdentity}, ${messageRequest.sessionId})`; -function ledgerSessionLookup(identityOrPhysicalId: string) { - return or( - eq(ledgerSessionIdentity, identityOrPhysicalId), - eq(usageLedger.sessionId, identityOrPhysicalId) +function ledgerSessionLookupForOwner(identityOrPhysicalId: string, ownerUserId?: number) { + const canonicalCondition = isReservedSessionIdentity(identityOrPhysicalId) + ? eq(usageLedger.sessionIdentity, identityOrPhysicalId) + : eq(ledgerSessionIdentity, identityOrPhysicalId); + const lookupCondition = + ownerUserId !== undefined || !isReservedSessionIdentity(identityOrPhysicalId) + ? or(canonicalCondition, eq(usageLedger.sessionId, identityOrPhysicalId)) + : canonicalCondition; + + return and( + lookupCondition, + ownerUserId !== undefined ? eq(usageLedger.userId, ownerUserId) : undefined ); } -function messageSessionLookup(identityOrPhysicalId: string) { - return or( - eq(messageSessionIdentity, identityOrPhysicalId), - eq(messageRequest.sessionId, identityOrPhysicalId) +function ledgerCanonicalSessionLookup(identity: string, ownerUserId: number) { + const canonicalCondition = isReservedSessionIdentity(identity) + ? eq(usageLedger.sessionIdentity, identity) + : eq(ledgerSessionIdentity, identity); + + return and(canonicalCondition, eq(usageLedger.userId, ownerUserId)); +} + +function messageSessionLookup(identityOrPhysicalId: string, ownerUserId?: number) { + const canonicalCondition = isReservedSessionIdentity(identityOrPhysicalId) + ? eq(messageRequest.sessionIdentity, identityOrPhysicalId) + : eq(messageSessionIdentity, identityOrPhysicalId); + const lookupCondition = + ownerUserId !== undefined || !isReservedSessionIdentity(identityOrPhysicalId) + ? or(canonicalCondition, eq(messageRequest.sessionId, identityOrPhysicalId)) + : canonicalCondition; + + return and( + lookupCondition, + ownerUserId !== undefined ? eq(messageRequest.userId, ownerUserId) : undefined + ); +} + +function messageCanonicalSessionLookup(identity: string, ownerUserId?: number) { + const canonicalCondition = isReservedSessionIdentity(identity) + ? eq(messageRequest.sessionIdentity, identity) + : eq(messageSessionIdentity, identity); + + return and( + canonicalCondition, + ownerUserId !== undefined ? eq(messageRequest.userId, ownerUserId) : undefined ); } @@ -1175,7 +1211,8 @@ export async function findMessageRequestBySessionId( * 用于展示会话来源链(原始选择决策) */ export async function findSessionOriginChain( - sessionId: string + sessionId: string, + ownerUserId?: number ): Promise { const [row] = await db .select({ @@ -1185,6 +1222,7 @@ export async function findSessionOriginChain( .where( and( eq(messageRequest.sessionId, sessionId), + ownerUserId !== undefined ? eq(messageRequest.userId, ownerUserId) : undefined, isNull(messageRequest.deletedAt), EXCLUDE_WARMUP_CONDITION, sql`${messageRequest.providerChain} IS NOT NULL`, @@ -1201,9 +1239,9 @@ export async function findSessionOriginChain( /** * 按 (sessionId, requestSequence) 获取请求的审计字段(用于 Session 详情页补齐特殊设置展示) */ -export async function findMessageRequestAuditBySessionIdAndSequence( - sessionId: string, - requestSequence: number +export async function findMessageRequestAuditById( + requestId: number, + ownerUserId?: number ): Promise<{ statusCode: number | null; blockedBy: string | null; @@ -1226,8 +1264,8 @@ export async function findMessageRequestAuditBySessionIdAndSequence( .from(messageRequest) .where( and( - eq(messageRequest.sessionId, sessionId), - eq(messageRequest.requestSequence, requestSequence), + eq(messageRequest.id, requestId), + ownerUserId !== undefined ? eq(messageRequest.userId, ownerUserId) : undefined, isNull(messageRequest.deletedAt) ) ) @@ -1254,7 +1292,10 @@ export async function findMessageRequestAuditBySessionIdAndSequence( * @param sessionId - Session ID * @returns 聚合统计数据,如果 session 不存在返回 null */ -export async function aggregateSessionStats(sessionId: string): Promise<{ +export async function aggregateSessionStats( + sessionId: string, + ownerUserId?: number +): Promise<{ sessionId: string; requestCount: number; totalCostUsd: string; @@ -1289,7 +1330,7 @@ export async function aggregateSessionStats(sessionId: string): Promise<{ lastRequestAt: sql`max(${usageLedger.createdAt})`, }) .from(usageLedger) - .where(and(ledgerSessionLookup(sessionId), LEDGER_BILLING_CONDITION)); + .where(and(ledgerSessionLookupForOwner(sessionId, ownerUserId), LEDGER_BILLING_CONDITION)); const billingStats = stats ?? { requestCount: 0, @@ -1313,7 +1354,7 @@ export async function aggregateSessionStats(sessionId: string): Promise<{ .leftJoin(providers, eq(usageLedger.finalProviderId, providers.id)) .where( and( - ledgerSessionLookup(sessionId), + ledgerSessionLookupForOwner(sessionId, ownerUserId), LEDGER_BILLING_CONDITION, sql`${usageLedger.finalProviderId} IS NOT NULL` ) @@ -1325,7 +1366,7 @@ export async function aggregateSessionStats(sessionId: string): Promise<{ .from(usageLedger) .where( and( - ledgerSessionLookup(sessionId), + ledgerSessionLookupForOwner(sessionId, ownerUserId), LEDGER_BILLING_CONDITION, sql`${usageLedger.model} IS NOT NULL` ) @@ -1337,7 +1378,7 @@ export async function aggregateSessionStats(sessionId: string): Promise<{ .from(usageLedger) .where( and( - ledgerSessionLookup(sessionId), + ledgerSessionLookupForOwner(sessionId, ownerUserId), LEDGER_BILLING_CONDITION, sql`${usageLedger.cacheTtlApplied} IS NOT NULL` ) @@ -1365,7 +1406,7 @@ export async function aggregateSessionStats(sessionId: string): Promise<{ .from(messageRequest) .innerJoin(users, eq(messageRequest.userId, users.id)) .innerJoin(keysTable, eq(messageRequest.key, keysTable.key)) - .where(and(messageSessionLookup(sessionId), isNull(messageRequest.deletedAt))) + .where(and(messageSessionLookup(sessionId, ownerUserId), isNull(messageRequest.deletedAt))) .orderBy(messageRequest.createdAt) .limit(1); @@ -1400,7 +1441,10 @@ export async function aggregateSessionStats(sessionId: string): Promise<{ } /** 解析活跃 Session identity 到可查看的物理 Session/前缀绑定信息。 */ -export async function resolveSessionIdentity(identity: string): Promise<{ +export async function resolveSessionIdentity( + identity: string, + ownerUserId?: number +): Promise<{ identity: string; sourceSessionId: string | null; identityKind: "session_id" | "prefix_affinity" | null; @@ -1418,7 +1462,9 @@ export async function resolveSessionIdentity(identity: string): Promise<{ fingerprintChain: messageRequest.affinityFingerprintChain, }) .from(messageRequest) - .where(and(messageSessionLookup(identity), isNull(messageRequest.deletedAt))) + .where( + and(messageCanonicalSessionLookup(identity, ownerUserId), isNull(messageRequest.deletedAt)) + ) .orderBy(desc(messageRequest.createdAt)); if (rows.length === 0) return null; @@ -1456,7 +1502,8 @@ export type PhysicalSessionSource = { /** Enumerate the physical Session and Provider memberships owned by a public identity. */ export async function listPhysicalSessionSourcesForIdentity( - identity: string + identity: string, + ownerUserId?: number ): Promise { const rows = await db .select({ @@ -1482,7 +1529,7 @@ export async function listPhysicalSessionSourcesForIdentity( .innerJoin(keysTable, eq(messageRequest.key, keysTable.key)) .where( and( - eq(messageSessionIdentity, identity), + messageCanonicalSessionLookup(identity, ownerUserId), isNotNull(messageRequest.sessionId), eq(messageRequest.isReplay, false), isNull(messageRequest.deletedAt), @@ -1490,6 +1537,8 @@ export async function listPhysicalSessionSourcesForIdentity( SELECT COALESCE(latest.session_identity, latest.session_id) FROM message_request latest WHERE latest.session_id = ${messageRequest.sessionId} + AND latest.user_id = ${messageRequest.userId} + AND latest.key = ${messageRequest.key} AND latest.deleted_at IS NULL AND latest.is_replay = false ORDER BY latest.created_at DESC, latest.id DESC @@ -1540,14 +1589,15 @@ export async function listPhysicalSessionSourcesForIdentity( /** 验证物理 Session 是否属于指定的聚合 identity。 */ export async function isSessionSourceForIdentity( identity: string, - sourceSessionId: string + sourceSessionId: string, + ownerUserId?: number ): Promise { const [row] = await db .select({ id: messageRequest.id }) .from(messageRequest) .where( and( - messageSessionLookup(identity), + messageCanonicalSessionLookup(identity, ownerUserId), eq(messageRequest.sessionId, sourceSessionId), isNull(messageRequest.deletedAt) ) @@ -1559,11 +1609,13 @@ export async function isSessionSourceForIdentity( export async function findSessionRequestLocator( identity: string, - selector: { requestId?: number; sourceSessionId?: string; requestSequence?: number } = {} + selector: { requestId?: number; sourceSessionId?: string; requestSequence?: number } = {}, + ownerUserId?: number ): Promise<{ requestId: number; sourceSessionId: string; requestSequence: number; + keyId: number; identityKind: "session_id" | "prefix_affinity"; scopeTag: string | null; fingerprint: string | null; @@ -1573,14 +1625,16 @@ export async function findSessionRequestLocator( requestId: messageRequest.id, sourceSessionId: messageRequest.sessionId, requestSequence: messageRequest.requestSequence, + keyId: keysTable.id, identityKind: messageRequest.sessionIdentityKind, scopeTag: messageRequest.affinityScopeTag, fingerprint: messageRequest.affinityFingerprint, }) .from(messageRequest) + .innerJoin(keysTable, eq(messageRequest.key, keysTable.key)) .where( and( - messageSessionLookup(identity), + messageCanonicalSessionLookup(identity, ownerUserId), isNotNull(messageRequest.sessionId), isNotNull(messageRequest.requestSequence), selector.requestId !== undefined ? eq(messageRequest.id, selector.requestId) : undefined, @@ -1596,12 +1650,15 @@ export async function findSessionRequestLocator( .orderBy(desc(messageRequest.createdAt), desc(messageRequest.id)) .limit(1); - if (!row?.requestId || !row.sourceSessionId || row.requestSequence == null) return null; + if (!row?.requestId || !row.sourceSessionId || row.requestSequence == null || !row.keyId) { + return null; + } return { requestId: row.requestId, sourceSessionId: row.sourceSessionId, requestSequence: row.requestSequence, + keyId: row.keyId, identityKind: row.identityKind === "prefix_affinity" ? "prefix_affinity" : "session_id", scopeTag: row.scopeTag, fingerprint: row.fingerprint, @@ -1616,7 +1673,10 @@ export async function findSessionRequestLocator( * @param sessionIds - Session ID 列表 * @returns 聚合统计数据数组 */ -export async function aggregateMultipleSessionStats(sessionIds: string[]): Promise< +export async function aggregateMultipleSessionStats( + sessionIds: string[], + ownerUserId?: number +): Promise< Array<{ sessionId: string; requestedSessionIds?: string[]; @@ -1651,6 +1711,7 @@ export async function aggregateMultipleSessionStats(sessionIds: string[]): Promi sessionIds.map((id) => sql`${id}`), sql.raw(", ") ); + const ownerCondition = ownerUserId !== undefined ? sql`AND user_id = ${ownerUserId}` : sql``; const userInfoRows = await db.execute(sql` SELECT sid AS requested_session_id, @@ -1680,10 +1741,14 @@ export async function aggregateMultipleSessionStats(sessionIds: string[]): Promi api_type FROM message_request WHERE - (COALESCE(session_identity, session_id) = sid OR session_id = sid) + ( + session_identity = sid + OR session_id = sid + ) AND deleted_at IS NULL + ${ownerCondition} ORDER BY - CASE WHEN COALESCE(session_identity, session_id) = sid THEN 0 ELSE 1 END, + CASE WHEN session_identity = sid THEN 0 ELSE 1 END, created_at DESC, id DESC LIMIT 1 @@ -1754,6 +1819,15 @@ export async function aggregateMultipleSessionStats(sessionIds: string[]): Promi return []; } + const canonicalOwnerCondition = or( + ...canonicalSessionIds.map((canonicalSessionId) => { + const owner = userInfoMap.get(canonicalSessionId)?.userId; + return owner === undefined + ? undefined + : ledgerCanonicalSessionLookup(canonicalSessionId, owner); + }) + ); + // 2. 批量聚合统计(从 usageLedger,单次查询) const statsResults = await db .select({ @@ -1769,7 +1843,7 @@ export async function aggregateMultipleSessionStats(sessionIds: string[]): Promi lastRequestAt: sql`max(${usageLedger.createdAt})`, }) .from(usageLedger) - .where(and(inArray(ledgerSessionIdentity, canonicalSessionIds), LEDGER_BILLING_CONDITION)) + .where(and(canonicalOwnerCondition, LEDGER_BILLING_CONDITION)) .groupBy(ledgerSessionIdentity); // 创建 sessionId → stats 的 Map @@ -1786,7 +1860,7 @@ export async function aggregateMultipleSessionStats(sessionIds: string[]): Promi .leftJoin(providers, eq(usageLedger.finalProviderId, providers.id)) .where( and( - inArray(ledgerSessionIdentity, canonicalSessionIds), + canonicalOwnerCondition, LEDGER_BILLING_CONDITION, sql`${usageLedger.finalProviderId} IS NOT NULL` ) @@ -1815,11 +1889,7 @@ export async function aggregateMultipleSessionStats(sessionIds: string[]): Promi }) .from(usageLedger) .where( - and( - inArray(ledgerSessionIdentity, canonicalSessionIds), - LEDGER_BILLING_CONDITION, - sql`${usageLedger.model} IS NOT NULL` - ) + and(canonicalOwnerCondition, LEDGER_BILLING_CONDITION, sql`${usageLedger.model} IS NOT NULL`) ); // 创建 sessionId → models 的 Map @@ -1843,7 +1913,7 @@ export async function aggregateMultipleSessionStats(sessionIds: string[]): Promi .from(usageLedger) .where( and( - inArray(ledgerSessionIdentity, canonicalSessionIds), + canonicalOwnerCondition, LEDGER_BILLING_CONDITION, sql`${usageLedger.cacheTtlApplied} IS NOT NULL` ) @@ -2118,7 +2188,13 @@ export async function findRequestsBySessionId( const [countResult] = await db .select({ count: sql`count(*)::int` }) .from(messageRequest) - .where(and(eq(messageRequest.sessionId, sessionId), isNull(messageRequest.deletedAt))); + .where( + and( + eq(messageRequest.sessionId, sessionId), + isNotNull(messageRequest.requestSequence), + isNull(messageRequest.deletedAt) + ) + ); const total = countResult?.count ?? 0; @@ -2141,7 +2217,13 @@ export async function findRequestsBySessionId( errorMessage: messageRequest.errorMessage, }) .from(messageRequest) - .where(and(eq(messageRequest.sessionId, sessionId), isNull(messageRequest.deletedAt))) + .where( + and( + eq(messageRequest.sessionId, sessionId), + isNotNull(messageRequest.requestSequence), + isNull(messageRequest.deletedAt) + ) + ) .orderBy( order === "asc" ? asc(messageRequest.requestSequence) : desc(messageRequest.requestSequence) ) @@ -2168,12 +2250,18 @@ export async function findRequestsBySessionId( export async function findRequestsBySessionIdentity( identity: string, - options?: { limit?: number; offset?: number; order?: "asc" | "desc" } + options?: { + limit?: number; + offset?: number; + order?: "asc" | "desc"; + ownerUserId?: number; + } ): Promise>> { - const { limit = 20, offset = 0, order = "desc" } = options || {}; + const { limit = 20, offset = 0, order = "desc", ownerUserId } = options || {}; const where = and( - messageSessionLookup(identity), + messageCanonicalSessionLookup(identity, ownerUserId), isNotNull(messageRequest.sessionId), + isNotNull(messageRequest.requestSequence), eq(messageRequest.isReplay, false), isNull(messageRequest.deletedAt) ); @@ -2188,14 +2276,11 @@ export async function findRequestsBySessionIdentity( sequence: messageRequest.requestSequence, displaySequence: sql`CASE WHEN ${messageRequest.sessionIdentityKind} = 'prefix_affinity' - AND NOT bool_or(COALESCE(${messageRequest.requestSequence}, 1) <> 1) OVER () - THEN COALESCE( - NULLIF(jsonb_array_length(${messageRequest.affinityFingerprintChain}), 0), - row_number() OVER (ORDER BY ${messageRequest.createdAt} ASC, ${messageRequest.id} ASC)::int - ) + THEN row_number() OVER ( + ORDER BY ${messageRequest.createdAt} ASC, ${messageRequest.id} ASC + )::int ELSE COALESCE( ${messageRequest.requestSequence}, - NULLIF(jsonb_array_length(${messageRequest.affinityFingerprintChain}), 0), row_number() OVER (ORDER BY ${messageRequest.createdAt} ASC, ${messageRequest.id} ASC)::int ) END`, @@ -2285,7 +2370,8 @@ export type SessionRequestNavigationTarget = { /** Resolve adjacent requests on the public Session timeline, including cross-source boundaries. */ export async function findAdjacentSessionRequests( identity: string, - requestId: number + requestId: number, + ownerUserId?: number ): Promise<{ prevRequest: SessionRequestNavigationTarget | null; nextRequest: SessionRequestNavigationTarget | null; @@ -2298,7 +2384,7 @@ export async function findAdjacentSessionRequests( .from(messageRequest) .where( and( - messageSessionLookup(identity), + messageCanonicalSessionLookup(identity, ownerUserId), eq(messageRequest.id, requestId), eq(messageRequest.isReplay, false), isNull(messageRequest.deletedAt) @@ -2316,7 +2402,7 @@ export async function findAdjacentSessionRequests( requestSequence: messageRequest.requestSequence, }; const timelineFilter = and( - messageSessionLookup(identity), + messageCanonicalSessionLookup(identity, ownerUserId), isNotNull(messageRequest.sessionId), isNotNull(messageRequest.requestSequence), eq(messageRequest.isReplay, false), diff --git a/tests/unit/actions/active-sessions-detail-snapshots.test.ts b/tests/unit/actions/active-sessions-detail-snapshots.test.ts index d7f736800..b715e7610 100644 --- a/tests/unit/actions/active-sessions-detail-snapshots.test.ts +++ b/tests/unit/actions/active-sessions-detail-snapshots.test.ts @@ -19,6 +19,7 @@ const getSessionUpstreamResponseMetaMock = vi.fn(); const getSessionSpecialSettingsMock = vi.fn(); const getSessionRequestPhaseSnapshotMock = vi.fn(); const getSessionResponsePhaseSnapshotMock = vi.fn(); +const isSessionRequestOwnedByKeyMock = vi.fn(); const aggregateSessionStatsMock = vi.fn(); const aggregateMultipleSessionStatsMock = vi.fn(); @@ -26,7 +27,7 @@ const resolveSessionIdentityMock = vi.fn(); const isSessionSourceForIdentityMock = vi.fn(); const findSessionRequestLocatorMock = vi.fn(); const findAdjacentSessionRequestsMock = vi.fn(); -const findMessageRequestAuditBySessionIdAndSequenceMock = vi.fn(); +const findMessageRequestAuditByIdMock = vi.fn(); vi.mock("@/lib/auth", () => ({ getSession: getSessionMock, @@ -67,6 +68,7 @@ vi.mock("@/lib/session-manager", () => ({ getSessionSpecialSettings: getSessionSpecialSettingsMock, getSessionRequestPhaseSnapshot: getSessionRequestPhaseSnapshotMock, getSessionResponsePhaseSnapshot: getSessionResponsePhaseSnapshotMock, + isSessionRequestOwnedByKey: isSessionRequestOwnedByKeyMock, }, })); @@ -77,7 +79,7 @@ vi.mock("@/repository/message", () => ({ isSessionSourceForIdentity: isSessionSourceForIdentityMock, findSessionRequestLocator: findSessionRequestLocatorMock, findAdjacentSessionRequests: findAdjacentSessionRequestsMock, - findMessageRequestAuditBySessionIdAndSequence: findMessageRequestAuditBySessionIdAndSequenceMock, + findMessageRequestAuditById: findMessageRequestAuditByIdMock, })); describe("getSessionDetails - additive detail snapshots contract", () => { @@ -123,6 +125,7 @@ describe("getSessionDetails - additive detail snapshots contract", () => { requestId: selector.requestId ?? 101, sourceSessionId: selector.sourceSessionId ?? identity, requestSequence: selector.requestSequence ?? 1, + keyId: 1, identityKind: identity.startsWith("pfx:") ? "prefix_affinity" : "session_id", scopeTag: identity.startsWith("pfx:") ? "scope" : null, fingerprint: identity.startsWith("pfx:") ? "fingerprint" : null, @@ -130,7 +133,8 @@ describe("getSessionDetails - additive detail snapshots contract", () => { ); findAdjacentSessionRequestsMock.mockResolvedValue({ prevRequest: null, nextRequest: null }); - findMessageRequestAuditBySessionIdAndSequenceMock.mockResolvedValue(null); + findMessageRequestAuditByIdMock.mockResolvedValue(null); + isSessionRequestOwnedByKeyMock.mockResolvedValue(true); getSessionRequestCountMock.mockResolvedValue(1); hasAnySessionMessagesMock.mockResolvedValue(true); @@ -242,17 +246,19 @@ describe("getSessionDetails - additive detail snapshots contract", () => { if (!result.ok) return; expect(result.data.currentSourceSessionId).toBe("physical-selected"); expect(result.data.currentSequence).toBe(1); - expect(findSessionRequestLocatorMock).toHaveBeenCalledWith("pfx:scope:fingerprint", { - requestId: 101, - requestSequence: 1, - sourceSessionId: "physical-selected", - }); - expect(getSessionRequestBodyMock).toHaveBeenCalledWith("physical-selected", 1); - expect(findAdjacentSessionRequestsMock).toHaveBeenCalledWith("pfx:scope:fingerprint", 101); - expect(findMessageRequestAuditBySessionIdAndSequenceMock).toHaveBeenCalledWith( - "physical-selected", + expect(findSessionRequestLocatorMock).toHaveBeenCalledWith( + "pfx:scope:fingerprint", + { + requestId: 101, + requestSequence: 1, + sourceSessionId: "physical-selected", + }, 1 ); + expect(getSessionRequestBodyMock).toHaveBeenCalledWith("physical-selected", 1); + expect(findAdjacentSessionRequestsMock).toHaveBeenCalledWith("pfx:scope:fingerprint", 101, 1); + expect(findMessageRequestAuditByIdMock).toHaveBeenCalledWith(101, 1); + expect(isSessionRequestOwnedByKeyMock).toHaveBeenCalledWith("physical-selected", 1, 1); }); test("rejects a physical source and sequence that do not belong to the prefix identity", async () => { @@ -296,7 +302,7 @@ describe("getSessionDetails - additive detail snapshots contract", () => { errorCode: "SESSION_REQUEST_SELECTOR_INCOMPLETE", }); expect(findSessionRequestLocatorMock).toHaveBeenCalledTimes(1); - expect(findSessionRequestLocatorMock).toHaveBeenCalledWith("pfx:scope:fingerprint"); + expect(findSessionRequestLocatorMock).toHaveBeenCalledWith("pfx:scope:fingerprint", {}, 1); }); test("builds before-after snapshots from new snapshot getters", async () => { @@ -436,8 +442,10 @@ describe("getSessionDetails - additive detail snapshots contract", () => { test("falls back to the latest request sequence when requestSequence is omitted", async () => { getSessionRequestCountMock.mockResolvedValue(3); findSessionRequestLocatorMock.mockResolvedValueOnce({ + requestId: 103, sourceSessionId: "sess_x", requestSequence: 3, + keyId: 1, identityKind: "session_id", scopeTag: null, fingerprint: null, @@ -528,12 +536,40 @@ describe("getSessionDetails - additive detail snapshots contract", () => { const result = await hasSessionMessages("pfx:scope:root", undefined, "physical-a", 203); expect(result).toEqual({ ok: true, data: true }); - expect(findSessionRequestLocatorMock).toHaveBeenLastCalledWith("sess_x", { - requestId: 203, - requestSequence: undefined, - sourceSessionId: "physical-a", - }); + expect(findSessionRequestLocatorMock).toHaveBeenLastCalledWith( + "sess_x", + { + requestId: 203, + requestSequence: undefined, + sourceSessionId: "physical-a", + }, + 1 + ); expect(getSessionMessagesMock).toHaveBeenCalledWith("physical-a", 1); expect(hasAnySessionMessagesMock).not.toHaveBeenCalled(); }); + + test("fails closed before reading request details when the owner marker is missing", async () => { + isSessionRequestOwnedByKeyMock.mockResolvedValueOnce(false); + const { getSessionDetails } = await import("@/actions/active-sessions"); + + await expect(getSessionDetails("sess_x", 1)).resolves.toEqual({ + ok: false, + error: "Session 请求详情已过期", + }); + + expect(getSessionRequestBodyMock).not.toHaveBeenCalled(); + expect(getSessionMessagesMock).not.toHaveBeenCalled(); + expect(findMessageRequestAuditByIdMock).not.toHaveBeenCalled(); + }); + + test("hides the details link when the request owner marker is missing", async () => { + isSessionRequestOwnedByKeyMock.mockResolvedValueOnce(false); + const { hasSessionMessages } = await import("@/actions/active-sessions"); + + await expect(hasSessionMessages("sess_x", 1)).resolves.toEqual({ ok: true, data: false }); + + expect(getSessionMessagesMock).not.toHaveBeenCalled(); + expect(hasAnySessionMessagesMock).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/actions/active-sessions-requests.test.ts b/tests/unit/actions/active-sessions-requests.test.ts index c7f58e6ed..d06425a46 100644 --- a/tests/unit/actions/active-sessions-requests.test.ts +++ b/tests/unit/actions/active-sessions-requests.test.ts @@ -61,6 +61,7 @@ describe("getSessionRequests public identity contract", () => { limit: 5, offset: 5, order: "desc", + ownerUserId: 1, }); expect(findRequestsBySessionIdMock).not.toHaveBeenCalled(); }); @@ -76,6 +77,28 @@ describe("getSessionRequests public identity contract", () => { limit: 20, offset: 0, order: "desc", + ownerUserId: 1, + }); + }); + + test("resolves a physical alias inside the authenticated user scope", async () => { + getSessionMock.mockResolvedValue({ user: { id: 7, role: "user" } }); + aggregateMultipleSessionStatsMock.mockResolvedValue([ + { + sessionId: "pfx:scope:fingerprint", + userId: 7, + }, + ]); + const { getSessionRequests } = await import("@/actions/active-sessions"); + + await expect(getSessionRequests("client-session-id")).resolves.toMatchObject({ ok: true }); + + expect(aggregateMultipleSessionStatsMock).toHaveBeenCalledWith(["client-session-id"], 7); + expect(findRequestsBySessionIdentityMock).toHaveBeenCalledWith("pfx:scope:fingerprint", { + limit: 20, + offset: 0, + order: "desc", + ownerUserId: 7, }); }); }); diff --git a/tests/unit/actions/active-sessions-special-settings.test.ts b/tests/unit/actions/active-sessions-special-settings.test.ts index 9cfe2989f..35094be94 100644 --- a/tests/unit/actions/active-sessions-special-settings.test.ts +++ b/tests/unit/actions/active-sessions-special-settings.test.ts @@ -17,12 +17,13 @@ const getSessionUpstreamResponseMetaMock = vi.fn(); const getSessionSpecialSettingsMock = vi.fn(); const getSessionRequestPhaseSnapshotMock = vi.fn(); const getSessionResponsePhaseSnapshotMock = vi.fn(); +const isSessionRequestOwnedByKeyMock = vi.fn(); const aggregateSessionStatsMock = vi.fn(); const aggregateMultipleSessionStatsMock = vi.fn(); const findSessionRequestLocatorMock = vi.fn(); const findAdjacentSessionRequestsMock = vi.fn(); -const findMessageRequestAuditBySessionIdAndSequenceMock = vi.fn(); +const findMessageRequestAuditByIdMock = vi.fn(); vi.mock("@/lib/auth", () => ({ getSession: getSessionMock, @@ -62,6 +63,7 @@ vi.mock("@/lib/session-manager", () => ({ getSessionSpecialSettings: getSessionSpecialSettingsMock, getSessionRequestPhaseSnapshot: getSessionRequestPhaseSnapshotMock, getSessionResponsePhaseSnapshot: getSessionResponsePhaseSnapshotMock, + isSessionRequestOwnedByKey: isSessionRequestOwnedByKeyMock, }, })); @@ -70,7 +72,7 @@ vi.mock("@/repository/message", () => ({ aggregateMultipleSessionStats: aggregateMultipleSessionStatsMock, findSessionRequestLocator: findSessionRequestLocatorMock, findAdjacentSessionRequests: findAdjacentSessionRequestsMock, - findMessageRequestAuditBySessionIdAndSequence: findMessageRequestAuditBySessionIdAndSequenceMock, + findMessageRequestAuditById: findMessageRequestAuditByIdMock, })); describe("getSessionDetails - unified specialSettings", () => { @@ -106,14 +108,17 @@ describe("getSessionDetails - unified specialSettings", () => { return stats ? [{ ...stats, sessionId: stats.sessionId ?? sessionIds[0] }] : []; }); findSessionRequestLocatorMock.mockResolvedValue({ + requestId: 101, sourceSessionId: "sess_x", requestSequence: 1, + keyId: 1, identityKind: "session_id", scopeTag: null, fingerprint: null, }); findAdjacentSessionRequestsMock.mockResolvedValue({ prevRequest: null, nextRequest: null }); + isSessionRequestOwnedByKeyMock.mockResolvedValue(true); getSessionRequestCountMock.mockResolvedValue(1); getSessionRequestBodyMock.mockResolvedValue(null); @@ -130,7 +135,7 @@ describe("getSessionDetails - unified specialSettings", () => { test("当 Redis specialSettings 为空时,应由 DB 审计字段派生特殊设置", async () => { getSessionSpecialSettingsMock.mockResolvedValue(null); - findMessageRequestAuditBySessionIdAndSequenceMock.mockResolvedValue({ + findMessageRequestAuditByIdMock.mockResolvedValue({ statusCode: 200, blockedBy: "warmup", blockedReason: JSON.stringify({ reason: "anthropic_warmup_intercepted" }), @@ -147,6 +152,8 @@ describe("getSessionDetails - unified specialSettings", () => { const types = (result.data.specialSettings ?? []).map((s) => s.type).sort(); expect(types).toEqual(["anthropic_cache_ttl_header_override", "guard_intercept"].sort()); + expect(isSessionRequestOwnedByKeyMock).toHaveBeenCalledWith("sess_x", 1, 1); + expect(findMessageRequestAuditByIdMock).toHaveBeenCalledWith(101, 1); }); test("当 Redis 与 DB 同时存在 specialSettings 时,应合并并去重", async () => { @@ -163,7 +170,7 @@ describe("getSessionDetails - unified specialSettings", () => { }, ]); - findMessageRequestAuditBySessionIdAndSequenceMock.mockResolvedValue({ + findMessageRequestAuditByIdMock.mockResolvedValue({ statusCode: 200, blockedBy: "warmup", blockedReason: JSON.stringify({ reason: "anthropic_warmup_intercepted" }), diff --git a/tests/unit/actions/active-sessions-termination.test.ts b/tests/unit/actions/active-sessions-termination.test.ts index 426970f1c..af4948be5 100644 --- a/tests/unit/actions/active-sessions-termination.test.ts +++ b/tests/unit/actions/active-sessions-termination.test.ts @@ -75,10 +75,14 @@ describe("active Session termination identity contract", () => { terminateSessionMock.mockResolvedValue(true); terminateSessionsBatchMock.mockResolvedValue(0); terminateObservedSessionMock.mockResolvedValue(true); - listPhysicalSessionSourcesForIdentityMock.mockResolvedValue([ - { sessionId: "physical-session", userId: 1, keyId: 11, providerIds: [41, 42] }, - { sessionId: "physical-session-2", userId: 1, keyId: 12, providerIds: [43] }, - ]); + listPhysicalSessionSourcesForIdentityMock.mockImplementation(async (identity: string) => + identity === "pfx:scope:tip" + ? [ + { sessionId: "physical-session", userId: 1, keyId: 11, providerIds: [41, 42] }, + { sessionId: "physical-session-2", userId: 1, keyId: 12, providerIds: [43] }, + ] + : [{ sessionId: identity, userId: 1, keyId: 13, providerIds: [] }] + ); }); test("terminating a prefix identity clears only physical bindings still owned by its key and providers", async () => { @@ -89,7 +93,7 @@ describe("active Session termination identity contract", () => { data: undefined, }); expect(invalidateMock).toHaveBeenCalledWith("scope", "tip", ["tip", "parent", "root"]); - expect(listPhysicalSessionSourcesForIdentityMock).toHaveBeenCalledWith("pfx:scope:tip"); + expect(listPhysicalSessionSourcesForIdentityMock).toHaveBeenCalledWith("pfx:scope:tip", 1); expect(terminateSessionMock).toHaveBeenCalledTimes(2); expect(terminateSessionMock).toHaveBeenCalledWith("physical-session", [41, 42], 11); expect(terminateSessionMock).toHaveBeenCalledWith("physical-session-2", [43], 12); @@ -111,7 +115,7 @@ describe("active Session termination identity contract", () => { ok: true, data: undefined, }); - expect(resolveSessionIdentityMock).toHaveBeenCalledWith("pfx:scope:tip"); + expect(resolveSessionIdentityMock).toHaveBeenCalledWith("pfx:scope:tip", 1); expect(invalidateMock).toHaveBeenCalledWith("scope", "tip", ["tip", "parent", "root"]); expect(clearSessionDetailsCacheMock).toHaveBeenCalledWith("physical-session"); expect(clearSessionDetailsCacheMock).toHaveBeenCalledWith("pfx:scope:tip"); @@ -174,6 +178,13 @@ describe("active Session termination identity contract", () => { }); test("treats a client-controlled pfx-prefixed physical Session as a physical Session", async () => { + aggregateMultipleSessionStatsMock.mockResolvedValueOnce([ + { + sessionId: "sid:key-bound-client-session", + requestedSessionIds: ["pfx:foreign-scope:foreign-fingerprint"], + userId: 1, + }, + ]); resolveSessionIdentityMock.mockResolvedValue({ sourceSessionId: "pfx:foreign-scope:foreign-fingerprint", identityKind: "session_id", @@ -181,13 +192,31 @@ describe("active Session termination identity contract", () => { fingerprint: null, fingerprints: [], }); + listPhysicalSessionSourcesForIdentityMock.mockResolvedValueOnce([ + { + sessionId: "pfx:foreign-scope:foreign-fingerprint", + userId: 1, + keyId: 21, + providerIds: [], + }, + ]); const { terminateActiveSession } = await import("@/actions/active-sessions"); const result = await terminateActiveSession("pfx:foreign-scope:foreign-fingerprint"); expect(result).toEqual({ ok: true, data: undefined }); - expect(terminateSessionMock).toHaveBeenCalledWith("pfx:foreign-scope:foreign-fingerprint"); + expect(resolveSessionIdentityMock).toHaveBeenCalledWith("sid:key-bound-client-session", 1); + expect(listPhysicalSessionSourcesForIdentityMock).toHaveBeenCalledWith( + "sid:key-bound-client-session", + 1 + ); + expect(terminateSessionMock).toHaveBeenCalledWith( + "pfx:foreign-scope:foreign-fingerprint", + undefined, + 21 + ); expect(invalidateMock).not.toHaveBeenCalled(); + expect(terminateObservedSessionMock).toHaveBeenCalledWith("sid:key-bound-client-session"); }); test("batch termination applies prefix and physical Session semantics independently", async () => { @@ -229,14 +258,14 @@ describe("active Session termination identity contract", () => { processedCount: 2, }); expect(invalidateMock).toHaveBeenCalledWith("scope", "tip", ["tip", "parent"]); - expect(listPhysicalSessionSourcesForIdentityMock).toHaveBeenCalledWith("pfx:scope:tip"); + expect(listPhysicalSessionSourcesForIdentityMock).toHaveBeenCalledWith("pfx:scope:tip", 1); expect(terminateSessionMock).toHaveBeenCalledTimes(3); expect(terminateSessionMock).toHaveBeenCalledWith("physical-session", [41, 42], 11); expect(terminateSessionMock).toHaveBeenCalledWith("physical-session-2", [43], 12); - expect(terminateSessionMock).toHaveBeenCalledWith("physical-session"); + expect(terminateSessionMock).toHaveBeenCalledWith("physical-session", undefined, 13); expect(terminateSessionsBatchMock).not.toHaveBeenCalled(); expect(terminateObservedSessionMock).toHaveBeenCalledWith("pfx:scope:tip"); - expect(terminateObservedSessionMock).toHaveBeenCalledWith("physical-session"); + expect(terminateObservedSessionMock).not.toHaveBeenCalledWith("physical-session"); }); test("batch termination clears physical aliases and canonical detail caches", async () => { @@ -325,6 +354,8 @@ describe("active Session termination identity contract", () => { }), }); expect(maxActiveResolutions).toBe(20); + expect(listPhysicalSessionSourcesForIdentityMock).toHaveBeenCalledTimes(21); + expect(terminateSessionMock).toHaveBeenCalledTimes(21); }); test("batch termination isolates a rejected identity and still clears caches", async () => { @@ -360,7 +391,7 @@ describe("active Session termination identity contract", () => { processedCount: 2, }, }); - expect(terminateSessionMock).toHaveBeenCalledWith("physical-success"); + expect(terminateSessionMock).toHaveBeenCalledWith("physical-success", undefined, 13); expect(clearActiveSessionsCacheMock).toHaveBeenCalledOnce(); expect(clearAllSessionsQueryCacheMock).toHaveBeenCalledOnce(); expect(clearSessionDetailsCacheMock).toHaveBeenCalledWith("physical-failed"); diff --git a/tests/unit/actions/session-origin-chain-integration.test.ts b/tests/unit/actions/session-origin-chain-integration.test.ts index fdb700ad8..6a6a52808 100644 --- a/tests/unit/actions/session-origin-chain-integration.test.ts +++ b/tests/unit/actions/session-origin-chain-integration.test.ts @@ -14,16 +14,19 @@ describe("getSessionOriginChain", () => { }, ]; - const aggregateSessionStatsMock = vi.fn().mockResolvedValue({ userId: 1 }); + const aggregateMultipleSessionStatsMock = vi + .fn() + .mockResolvedValue([{ sessionId: "test-session", userId: 1 }]); const findSessionRequestLocatorMock = vi.fn().mockResolvedValue({ identityKind: "direct", sourceSessionId: "test-session", requestSequence: 1, + keyId: 1, }); const findSessionOriginChainMock = vi.fn().mockResolvedValue(firstRequestChain); vi.doMock("@/repository/message", () => ({ - aggregateSessionStats: aggregateSessionStatsMock, + aggregateMultipleSessionStats: aggregateMultipleSessionStatsMock, findSessionOriginChain: findSessionOriginChainMock, findSessionRequestLocator: findSessionRequestLocatorMock, })); @@ -56,7 +59,7 @@ describe("getSessionOriginChain", () => { } expect(result.data[0]?.reason).toBe("initial_selection"); - expect(findSessionRequestLocatorMock).toHaveBeenCalledWith("test-session"); - expect(findSessionOriginChainMock).toHaveBeenCalledWith("test-session"); + expect(findSessionRequestLocatorMock).toHaveBeenCalledWith("test-session", {}, 1); + expect(findSessionOriginChainMock).toHaveBeenCalledWith("test-session", 1); }); }); diff --git a/tests/unit/actions/session-origin-chain.test.ts b/tests/unit/actions/session-origin-chain.test.ts index 6b4784e51..4eb859732 100644 --- a/tests/unit/actions/session-origin-chain.test.ts +++ b/tests/unit/actions/session-origin-chain.test.ts @@ -4,7 +4,7 @@ import type { ProviderChainItem } from "@/types/message"; const getSessionMock = vi.fn(); const findSessionOriginChainMock = vi.fn(); const findSessionRequestLocatorMock = vi.fn(); -const aggregateSessionStatsMock = vi.fn(); +const aggregateMultipleSessionStatsMock = vi.fn(); const findKeyListMock = vi.fn(); const dbSelectMock = vi.fn(); @@ -19,7 +19,7 @@ vi.mock("@/lib/auth", () => ({ vi.mock("@/repository/message", () => ({ findSessionOriginChain: findSessionOriginChainMock, findSessionRequestLocator: findSessionRequestLocatorMock, - aggregateSessionStats: aggregateSessionStatsMock, + aggregateMultipleSessionStats: aggregateMultipleSessionStatsMock, })); vi.mock("@/repository/key", () => ({ @@ -42,15 +42,19 @@ describe("getSessionOriginChain", () => { dbLimitMock.mockResolvedValue([{ id: 1 }]); findKeyListMock.mockResolvedValue([{ key: "user-key-1" }]); - aggregateSessionStatsMock.mockResolvedValue({ userId: 2 }); + aggregateMultipleSessionStatsMock.mockResolvedValue([ + { sessionId: "pfx:scope:fingerprint", userId: 2 }, + ]); findSessionRequestLocatorMock.mockReset(); findSessionRequestLocatorMock.mockImplementation( async ( identity: string, selector: { sourceSessionId?: string; requestSequence?: number } = {} ) => ({ + requestId: 101, sourceSessionId: selector.sourceSessionId ?? identity, requestSequence: selector.requestSequence ?? 1, + keyId: 1, identityKind: identity.startsWith("pfx:") ? "prefix_affinity" : "session_id", scopeTag: identity.startsWith("pfx:") ? "scope" : null, fingerprint: identity.startsWith("pfx:") ? "fingerprint" : null, @@ -74,7 +78,7 @@ describe("getSessionOriginChain", () => { const result = await getSessionOriginChain("pfx:scope:fingerprint", 2, "physical-selected"); expect(result).toEqual({ ok: true, data: chain }); - expect(findSessionOriginChainMock).toHaveBeenCalledWith("physical-selected"); + expect(findSessionOriginChainMock).toHaveBeenCalledWith("physical-selected", 2); expect(findKeyListMock).not.toHaveBeenCalled(); expect(dbSelectMock).not.toHaveBeenCalled(); }); @@ -95,8 +99,8 @@ describe("getSessionOriginChain", () => { const result = await getSessionOriginChain("pfx:scope:fingerprint", 2, "physical-selected"); expect(result).toEqual({ ok: true, data: chain }); - expect(aggregateSessionStatsMock).toHaveBeenCalledWith("pfx:scope:fingerprint"); - expect(findSessionOriginChainMock).toHaveBeenCalledWith("physical-selected"); + expect(aggregateMultipleSessionStatsMock).toHaveBeenCalledWith(["pfx:scope:fingerprint"], 2); + expect(findSessionOriginChainMock).toHaveBeenCalledWith("physical-selected", 2); }); test("unauthenticated: returns not logged in", async () => { @@ -113,7 +117,9 @@ describe("getSessionOriginChain", () => { test("non-admin without access: returns unauthorized error", async () => { getSessionMock.mockResolvedValue({ user: { id: 3, role: "user" } }); - aggregateSessionStatsMock.mockResolvedValue({ userId: 4 }); + aggregateMultipleSessionStatsMock.mockResolvedValue([ + { sessionId: "sess-other-user", userId: 4 }, + ]); const { getSessionOriginChain } = await import("@/actions/session-origin-chain"); const result = await getSessionOriginChain("sess-other-user"); @@ -134,13 +140,16 @@ describe("getSessionOriginChain", () => { test("not found: returns ok with null data", async () => { getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + aggregateMultipleSessionStatsMock.mockResolvedValue([ + { sessionId: "sess-not-found", userId: 1 }, + ]); findSessionOriginChainMock.mockResolvedValue(null); const { getSessionOriginChain } = await import("@/actions/session-origin-chain"); const result = await getSessionOriginChain("sess-not-found"); expect(result).toEqual({ ok: true, data: null }); - expect(findSessionOriginChainMock).toHaveBeenCalledWith("sess-not-found"); + expect(findSessionOriginChainMock).toHaveBeenCalledWith("sess-not-found", 1); expect(findKeyListMock).not.toHaveBeenCalled(); expect(dbSelectMock).not.toHaveBeenCalled(); }); diff --git a/tests/unit/actions/session-response.test.ts b/tests/unit/actions/session-response.test.ts index 8f6cd2b01..4e20591eb 100644 --- a/tests/unit/actions/session-response.test.ts +++ b/tests/unit/actions/session-response.test.ts @@ -1,17 +1,21 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; const getSessionMock = vi.fn(); -const aggregateSessionStatsMock = vi.fn(); +const aggregateMultipleSessionStatsMock = vi.fn(); const findSessionRequestLocatorMock = vi.fn(); const getSessionResponseMock = vi.fn(); +const isSessionRequestOwnedByKeyMock = vi.fn(); vi.mock("@/lib/auth", () => ({ getSession: getSessionMock })); vi.mock("@/repository/message", () => ({ - aggregateSessionStats: aggregateSessionStatsMock, + aggregateMultipleSessionStats: aggregateMultipleSessionStatsMock, findSessionRequestLocator: findSessionRequestLocatorMock, })); vi.mock("@/lib/session-manager", () => ({ - SessionManager: { getSessionResponse: getSessionResponseMock }, + SessionManager: { + getSessionResponse: getSessionResponseMock, + isSessionRequestOwnedByKey: isSessionRequestOwnedByKeyMock, + }, })); vi.mock("@/lib/logger", () => ({ logger: { warn: vi.fn(), error: vi.fn() }, @@ -21,18 +25,25 @@ describe("getSessionResponse request locator", () => { beforeEach(() => { vi.clearAllMocks(); getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); - aggregateSessionStatsMock.mockResolvedValue({ userId: 1 }); + aggregateMultipleSessionStatsMock.mockResolvedValue([ + { sessionId: "pfx:scope:fingerprint", userId: 1 }, + ]); + isSessionRequestOwnedByKeyMock.mockResolvedValue(true); findSessionRequestLocatorMock .mockResolvedValueOnce({ + requestId: 104, sourceSessionId: "physical-latest", requestSequence: 4, + keyId: 1, identityKind: "prefix_affinity", scopeTag: "scope", fingerprint: "fingerprint", }) .mockResolvedValueOnce({ + requestId: 102, sourceSessionId: "physical-selected", requestSequence: 2, + keyId: 1, identityKind: "prefix_affinity", scopeTag: "scope", fingerprint: "fingerprint", @@ -47,10 +58,31 @@ describe("getSessionResponse request locator", () => { getSessionResponse("pfx:scope:fingerprint", 2, "physical-selected") ).resolves.toEqual({ ok: true, data: "response-body" }); - expect(findSessionRequestLocatorMock).toHaveBeenNthCalledWith(2, "pfx:scope:fingerprint", { - requestSequence: 2, - sourceSessionId: "physical-selected", - }); + expect(findSessionRequestLocatorMock).toHaveBeenNthCalledWith( + 2, + "pfx:scope:fingerprint", + { + requestId: undefined, + requestSequence: 2, + sourceSessionId: "physical-selected", + }, + 1 + ); + expect(isSessionRequestOwnedByKeyMock).toHaveBeenCalledWith("physical-selected", 2, 1); expect(getSessionResponseMock).toHaveBeenCalledWith("physical-selected", 2); }); + + test("fails closed before reading Redis when the request owner marker is missing", async () => { + isSessionRequestOwnedByKeyMock.mockResolvedValueOnce(false); + const { getSessionResponse } = await import("@/actions/session-response"); + + await expect( + getSessionResponse("pfx:scope:fingerprint", 2, "physical-selected") + ).resolves.toEqual({ + ok: false, + error: "响应体已过期(5分钟 TTL)或尚未记录", + }); + + expect(getSessionResponseMock).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/frontend/api-error-i18n.test.ts b/tests/unit/frontend/api-error-i18n.test.ts index 7f9091859..b3349d27c 100644 --- a/tests/unit/frontend/api-error-i18n.test.ts +++ b/tests/unit/frontend/api-error-i18n.test.ts @@ -109,6 +109,8 @@ describe("v1 API error i18n mapping", () => { expect(dashboard.sessions.status.error).toBeTruthy(); expect(dashboard.sessions.details.canonicalSessionId).toBeTruthy(); expect(dashboard.sessions.details.clientSessionId).toBeTruthy(); + expect(dashboard.sessions.requestList.itemTitle).toBeTruthy(); + expect(dashboard.sessions.requestList.unknownModel).toBeTruthy(); expect(dashboard.logs.details.metadata.canonicalSessionId).toBeTruthy(); expect(dashboard.logs.details.metadata.clientSessionId).toBeTruthy(); } diff --git a/tests/unit/lib/cache/session-cache.test.ts b/tests/unit/lib/cache/session-cache.test.ts index 56e412f31..6e2f6ca30 100644 --- a/tests/unit/lib/cache/session-cache.test.ts +++ b/tests/unit/lib/cache/session-cache.test.ts @@ -187,7 +187,7 @@ describe("SessionCache(Session 数据缓存层)", () => { expect(getActiveSessionsCache("all_sessions")).toBeNull(); clearSessionDetailsCache("s_1"); - expect(getSessionDetailsCache("s_1")).toBeNull(); + expect(getSessionDetailsCache("s_1", 1)).toBeNull(); // 再次写入后,clearAllCaches 应清空两类缓存 setActiveSessionsCache([], "active_sessions"); @@ -215,7 +215,73 @@ describe("SessionCache(Session 数据缓存层)", () => { clearAllCaches(); expect(getActiveSessionsCache()).toBeNull(); - expect(getSessionDetailsCache("s_2")).toBeNull(); + expect(getSessionDetailsCache("s_2", 1)).toBeNull(); + }); + + test("clearing a canonical Session detail cache also clears every cached physical alias", async () => { + const { getSessionDetailsCache, setSessionDetailsCache, clearSessionDetailsCache } = + await loadSessionCache(); + const details = { + sessionId: "pfx:scope:tip", + requestCount: 1, + totalCostUsd: "0", + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreationTokens: 0, + totalCacheReadTokens: 0, + totalDurationMs: 0, + firstRequestAt: null, + lastRequestAt: null, + providers: [], + models: [], + userName: "u", + userId: 1, + keyName: "k", + keyId: 1, + userAgent: null, + apiType: null, + cacheTtlApplied: null, + }; + + setSessionDetailsCache("pfx:scope:tip", details); + setSessionDetailsCache("physical-a", details); + setSessionDetailsCache("physical-b", details); + + clearSessionDetailsCache("pfx:scope:tip"); + + expect(getSessionDetailsCache("pfx:scope:tip", 1)).toBeNull(); + expect(getSessionDetailsCache("physical-a", 1)).toBeNull(); + expect(getSessionDetailsCache("physical-b", 1)).toBeNull(); + }); + + test("does not share a same-named Session detail cache across owners", async () => { + const { getSessionDetailsCache, setSessionDetailsCache } = await loadSessionCache(); + const details = { + sessionId: "shared-session", + requestCount: 1, + totalCostUsd: "0", + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreationTokens: 0, + totalCacheReadTokens: 0, + totalDurationMs: 0, + firstRequestAt: null, + lastRequestAt: null, + providers: [], + models: [], + userName: "owner-one", + userId: 1, + keyName: "key-one", + keyId: 1, + userAgent: null, + apiType: null, + cacheTtlApplied: null, + }; + + setSessionDetailsCache("shared-session", details); + + expect(getSessionDetailsCache("shared-session", 1)).toEqual(details); + expect(getSessionDetailsCache("shared-session", 2)).toBeNull(); }); test("startCacheCleanup/stopCacheCleanup:应幂等且能清理过期条目", async () => { diff --git a/tests/unit/lib/session-request-locator.test.ts b/tests/unit/lib/session-request-locator.test.ts index c9492df2b..2f0c63282 100644 --- a/tests/unit/lib/session-request-locator.test.ts +++ b/tests/unit/lib/session-request-locator.test.ts @@ -27,6 +27,7 @@ describe("resolveSessionRequestLocator", () => { requestId: 108, sourceSessionId: "physical-latest", requestSequence: 8, + keyId: 23, identityKind: "prefix_affinity", scopeTag: "scope", fingerprint: "fingerprint", @@ -46,6 +47,7 @@ describe("resolveSessionRequestLocator", () => { requestId: 108, sourceSessionId: "physical-latest", requestSequence: 8, + keyId: 23, identityKind: "prefix_affinity", scopeTag: "scope", fingerprint: "fingerprint", @@ -54,6 +56,7 @@ describe("resolveSessionRequestLocator", () => { requestId: 107, sourceSessionId: "physical-selected", requestSequence: 7, + keyId: 23, identityKind: "prefix_affinity", scopeTag: "scope", fingerprint: "fingerprint", @@ -68,13 +71,60 @@ describe("resolveSessionRequestLocator", () => { requestId: 107, sourceSessionId: "physical-selected", requestSequence: 7, + keyId: 23, }, }); - expect(findSessionRequestLocatorMock).toHaveBeenLastCalledWith("pfx:scope:fingerprint", { - requestId: 107, - requestSequence: undefined, - sourceSessionId: undefined, - }); + expect(findSessionRequestLocatorMock).toHaveBeenLastCalledWith( + "pfx:scope:fingerprint", + { + requestId: 107, + requestSequence: undefined, + sourceSessionId: undefined, + }, + undefined + ); + }); + + it("forwards the resolved owner scope to both locator queries", async () => { + findSessionRequestLocatorMock + .mockResolvedValueOnce({ + requestId: 108, + sourceSessionId: "physical-latest", + requestSequence: 8, + keyId: 23, + identityKind: "prefix_affinity", + scopeTag: "scope", + fingerprint: "fingerprint", + }) + .mockResolvedValueOnce({ + requestId: 107, + sourceSessionId: "physical-selected", + requestSequence: 7, + keyId: 23, + identityKind: "prefix_affinity", + scopeTag: "scope", + fingerprint: "fingerprint", + }); + const { resolveSessionRequestLocator } = await import("@/lib/session-request-locator"); + + await resolveSessionRequestLocator("pfx:scope:fingerprint", undefined, undefined, 107, 23); + + expect(findSessionRequestLocatorMock).toHaveBeenNthCalledWith( + 1, + "pfx:scope:fingerprint", + {}, + 23 + ); + expect(findSessionRequestLocatorMock).toHaveBeenNthCalledWith( + 2, + "pfx:scope:fingerprint", + { + requestId: 107, + requestSequence: undefined, + sourceSessionId: undefined, + }, + 23 + ); }); it("returns the source-mismatch code when the selected physical request is outside the identity", async () => { @@ -83,6 +133,7 @@ describe("resolveSessionRequestLocator", () => { requestId: 108, sourceSessionId: "physical-latest", requestSequence: 8, + keyId: 23, identityKind: "prefix_affinity", scopeTag: "scope", fingerprint: "fingerprint", @@ -96,10 +147,14 @@ describe("resolveSessionRequestLocator", () => { ok: false, errorCode: "SESSION_REQUEST_SOURCE_MISMATCH", }); - expect(findSessionRequestLocatorMock).toHaveBeenLastCalledWith("pfx:scope:fingerprint", { - requestId: 107, - requestSequence: 7, - sourceSessionId: "physical-selected", - }); + expect(findSessionRequestLocatorMock).toHaveBeenLastCalledWith( + "pfx:scope:fingerprint", + { + requestId: 107, + requestSequence: 7, + sourceSessionId: "physical-selected", + }, + undefined + ); }); }); diff --git a/tests/unit/repository/message-aggregate-multiple-session-stats.test.ts b/tests/unit/repository/message-aggregate-multiple-session-stats.test.ts index 981e5ae1f..2894d2f78 100644 --- a/tests/unit/repository/message-aggregate-multiple-session-stats.test.ts +++ b/tests/unit/repository/message-aggregate-multiple-session-stats.test.ts @@ -199,7 +199,7 @@ describe("message repository aggregateMultipleSessionStats", () => { const ownerQuery = sqlText(boundary.execute.mock.calls.at(0)?.at(0)); expect(ownerQuery).toContain("unnest"); expect(ownerQuery).toContain("session-without-owner"); - expect(ownerQuery).toContain("order by case when coalesce"); + expect(ownerQuery).toContain("order by case when session_identity"); expect(ownerQuery).toContain("created_at desc, id desc"); }); @@ -273,6 +273,36 @@ describe("message repository aggregateMultipleSessionStats", () => { expect(ownerQuery).toContain("requested_session_id"); }); + test("resolves a physical Session id inside the authenticated owner scope", async () => { + const canonicalId = "pfx:scope:root"; + boundary.select.mockReturnValueOnce(createDrizzleQuery([statsRow(canonicalId, 1)])); + boundary.selectDistinct + .mockReturnValueOnce(createDrizzleQuery([])) + .mockReturnValueOnce(createDrizzleQuery([])) + .mockReturnValueOnce(createDrizzleQuery([])); + boundary.execute.mockResolvedValueOnce([ + { + requested_session_id: "physical-a", + session_id: canonicalId, + session_identity_kind: "prefix_affinity", + session_fingerprint: "root", + user_name: "Alice", + user_id: 7, + key_name: "Key A", + key_id: 101, + user_agent: null, + api_type: "claude", + }, + ]); + + await aggregateMultipleSessionStats(["physical-a"], 7); + + const ownerQuery = sqlText(boundary.execute.mock.calls.at(0)?.at(0)).toLowerCase(); + expect(ownerQuery).toContain("session_id = sid"); + expect(ownerQuery).toContain("and user_id ="); + expect(ownerQuery).toContain(" 7"); + }); + test("deduplicates current public and physical aliases using the latest identity", async () => { const canonicalId = "pfx:scope:new"; boundary.select.mockReturnValueOnce(createDrizzleQuery([statsRow(canonicalId, 1)])); @@ -314,7 +344,7 @@ describe("message repository aggregateMultipleSessionStats", () => { }), ]); const ownerQuery = sqlText(boundary.execute.mock.calls.at(0)?.at(0)).toLowerCase(); - expect(ownerQuery).toContain("order by case when coalesce"); + expect(ownerQuery).toContain("order by case when session_identity"); expect(ownerQuery).toContain("created_at desc, id desc"); }); @@ -343,7 +373,8 @@ describe("message repository aggregateMultipleSessionStats", () => { await aggregateMultipleSessionStats([canonicalId]); const ownerQuery = sqlText(boundary.execute.mock.calls.at(0)?.at(0)).toLowerCase(); - expect(ownerQuery).toContain("case when coalesce"); + expect(ownerQuery).toContain("or session_id = sid"); + expect(ownerQuery).toContain("case when session_identity"); expect(ownerQuery).toContain("= sid then 0 else 1 end"); expect(ownerQuery).toContain("created_at desc, id desc"); }); diff --git a/tests/unit/repository/message-aggregate-session-stats.test.ts b/tests/unit/repository/message-aggregate-session-stats.test.ts index 7aa420a41..4f955dcf5 100644 --- a/tests/unit/repository/message-aggregate-session-stats.test.ts +++ b/tests/unit/repository/message-aggregate-session-stats.test.ts @@ -126,7 +126,7 @@ describe("message repository aggregateSessionStats", () => { expect(sqlText(stats.trace.where)).toContain("blocked_by"); }); - test("按 session identity 聚合并兼容 migration 前的 sessionId", async () => { + test("reserved session identity uses the canonical lookup without a physical alias fallback", async () => { const stats = createDrizzleQuery([]); boundary.select.mockReturnValueOnce(stats).mockReturnValueOnce(createDrizzleQuery([])); boundary.selectDistinct @@ -139,7 +139,7 @@ describe("message repository aggregateSessionStats", () => { const whereSql = sqlText(stats.trace.where); expect(whereSql).toContain("session_identity"); expect(whereSql).toContain("session_id"); - expect(whereSql.match(/pfx:scope123:fp-deep/g)).toHaveLength(2); + expect(whereSql.match(/pfx:scope123:fp-deep/g)).toHaveLength(1); }); test("returns populated statistics and preserves a single cache TTL", async () => { diff --git a/tests/unit/repository/message-public-readback.test.ts b/tests/unit/repository/message-public-readback.test.ts index 939fcee11..cb97a4417 100644 --- a/tests/unit/repository/message-public-readback.test.ts +++ b/tests/unit/repository/message-public-readback.test.ts @@ -194,7 +194,7 @@ describe("message public readback", () => { expect(boundary.select).toHaveBeenCalledTimes(1); }); - it("returns the public audit projection for a session sequence", async () => { + it("returns the public audit projection for a request id scoped to its owner", async () => { vi.resetModules(); const auditRow = { statusCode: 403, @@ -216,11 +216,17 @@ describe("message public readback", () => { ], }; const boundary = installReadBoundaries([[auditRow]]); - const { findMessageRequestAuditBySessionIdAndSequence } = await import("@/repository/message"); + const { findMessageRequestAuditById } = await import("@/repository/message"); - const result = await findMessageRequestAuditBySessionIdAndSequence("audit-session", 4); + const result = await findMessageRequestAuditById(1_099, 17); expect(result).toEqual(auditRow); expect(boundary.events).toEqual(["from", "where", "limit"]); + const where = sqlText(boundary.whereConditions[0]); + expect(where).toContain("id"); + expect(where).toContain("1099"); + expect(where).toContain("user_id"); + expect(where).toContain("17"); + expect(where).toContain("deleted_at"); }); }); diff --git a/tests/unit/repository/message-session-request-query.test.ts b/tests/unit/repository/message-session-request-query.test.ts index c8442f280..cbea06c08 100644 --- a/tests/unit/repository/message-session-request-query.test.ts +++ b/tests/unit/repository/message-session-request-query.test.ts @@ -64,7 +64,7 @@ describe("message repository session request queries", () => { { id: 31, sessionId: "session-requests", - sequence: null, + sequence: 1, displaySequence: 1, model: "model-a", statusCode: 200, @@ -215,14 +215,57 @@ describe("message repository session request queries", () => { expect(sqlText(rows.trace.where)).toContain("false"); expect(sqlText(rows.trace.orderBy)).toContain("created_at desc"); expect(sqlText(rows.trace.orderBy)).toContain("id desc"); + const selection = sqlText(boundary.select.mock.calls.at(1)?.at(0)).toLowerCase(); + expect(selection).toContain("then row_number()"); + expect(selection).not.toContain("jsonb_array_length"); + expect(sqlText(count.trace.where)).toContain("request_sequence is not null"); + expect(sqlText(rows.trace.where)).toContain("request_sequence is not null"); + }); + + test("scopes canonical request timelines to the resolved owner without a physical alias union", async () => { + const count = createDrizzleQuery([{ count: 1 }]); + const rows = createDrizzleQuery([]); + boundary.select.mockReturnValueOnce(count).mockReturnValueOnce(rows); + + await findRequestsBySessionIdentity("shared-session", { ownerUserId: 17 } as never); + + const countWhere = sqlText(count.trace.where); + const rowsWhere = sqlText(rows.trace.where); + expect(countWhere).toContain("user_id"); + expect(rowsWhere).toContain("user_id"); + expect(countWhere.match(/shared-session/g)).toHaveLength(1); + expect(rowsWhere.match(/shared-session/g)).toHaveLength(1); + }); + + test("does not treat a reserved canonical identity as a physical Session alias", async () => { + const locator = createDrizzleQuery([ + { + requestId: 205, + sourceSessionId: "physical-a", + requestSequence: 2, + keyId: 17, + identityKind: "prefix_affinity", + scopeTag: "scope", + fingerprint: "fingerprint", + }, + ]); + boundary.select.mockReturnValueOnce(locator); + + await expect( + findSessionRequestLocator("pfx:scope:fingerprint", { requestId: 205 }) + ).resolves.toEqual(expect.objectContaining({ requestId: 205, keyId: 17 })); + + const where = sqlText(locator.trace.where); + expect(where.match(/pfx:scope:fingerprint/g)).toHaveLength(1); }); - test("accepts a physical Session id when resolving a request locator", async () => { + test("uses an exact canonical identity after a physical Session id is resolved", async () => { const locator = createDrizzleQuery([ { requestId: 204, sourceSessionId: "physical-a", requestSequence: 1, + keyId: 17, identityKind: "prefix_affinity", scopeTag: "scope", fingerprint: "fingerprint", @@ -234,6 +277,7 @@ describe("message repository session request queries", () => { requestId: 204, sourceSessionId: "physical-a", requestSequence: 1, + keyId: 17, identityKind: "prefix_affinity", scopeTag: "scope", fingerprint: "fingerprint", @@ -243,6 +287,32 @@ describe("message repository session request queries", () => { expect(where).toContain("physical-a"); expect(where).toContain("session_identity"); expect(where).toContain("session_id"); + expect(where.match(/physical-a/g)).toHaveLength(1); + }); + + test("filters requests without a stable selector from navigable request lists", async () => { + const count = createDrizzleQuery([{ count: 1 }]); + const rows = createDrizzleQuery([ + { + id: 33, + sessionId: "session-stable", + sequence: 3, + displaySequence: 3, + model: "model-stable", + statusCode: 200, + costUsd: "0", + createdAt: secondCreatedAt, + inputTokens: 1, + outputTokens: 1, + errorMessage: null, + }, + ]); + boundary.select.mockReturnValueOnce(count).mockReturnValueOnce(rows); + + await findRequestsBySessionId("session-stable"); + + expect(sqlText(count.trace.where)).toContain("request_sequence is not null"); + expect(sqlText(rows.trace.where)).toContain("request_sequence is not null"); }); test("resolves an exact request locator inside a prefix identity", async () => { @@ -251,6 +321,7 @@ describe("message repository session request queries", () => { requestId: 203, sourceSessionId: "physical-a", requestSequence: 3, + keyId: 17, identityKind: "prefix_affinity", scopeTag: "scope", fingerprint: "fingerprint", @@ -268,6 +339,7 @@ describe("message repository session request queries", () => { requestId: 203, sourceSessionId: "physical-a", requestSequence: 3, + keyId: 17, identityKind: "prefix_affinity", scopeTag: "scope", fingerprint: "fingerprint", From e8956e85d246946394d5c8542b93974295398864 Mon Sep 17 00:00:00 2001 From: ding113 Date: Sun, 2 Aug 2026 20:13:21 +0800 Subject: [PATCH 3/5] fix(session): harden request ownership and detail artifact lifecycle Anchors session detail lookups, origin chain queries, and termination cleanup to the immutable key owner of each request rather than the session-level canonical identity. This prevents cross-owner data leakage when multiple API keys share a physical session ID. - resolveSessionRequestLocator now resolves directly by requestId when provided, returning canonicalSessionId, keyId, and userId so callers can scope all downstream reads to the request owner - findSessionOriginChain queries by requestId and keyId, selecting the nearest initial-selection provider chain within the same key epoch instead of the first request in the session - getNextRequestSequence uses an atomic Lua script to PERSIST the sequence key and write the owner marker in a single round trip - storeSessionResponse, storeSessionResponseHeaders, storeSessionUpstreamResponseMeta, and phase snapshot methods now accept and refresh the keyId owner marker, keeping late-arriving response artifacts readable - getSessionDetails and hasSessionMessages resolve the locator before loading stats when a requestId is supplied, then use the locator's userId for all subsequent queries; Redis-backed artifacts are skipped (returning null) when the owner marker is missing instead of failing closed, while database-backed audit data is still returned - terminateResolvedSessionIdentity now always calls terminateObservedSession and returns sourceSessionIds so the caller can invalidate detail caches for every physical alias - terminateSession fails closed when the owner key lookup throws with an expected keyId, preventing unauthorized cleanup - Scoped getSessionMessages and getSessionResponse no longer fall through to legacy unscoped keys when the per-request key is absent --- src/actions/active-sessions.ts | 208 ++++++++++++------ src/actions/session-origin-chain.ts | 3 +- src/app/v1/_lib/proxy/forwarder.ts | 9 +- src/app/v1/_lib/proxy/response-handler.ts | 49 +++-- src/app/v1/_lib/proxy/warmup-guard.ts | 12 +- .../session-manager-detail-snapshots.test.ts | 56 ++++- src/lib/session-manager.ts | 92 ++++++-- src/lib/session-request-locator.ts | 23 +- src/repository/message.ts | 69 +++++- .../active-sessions-detail-snapshots.test.ts | 80 ++++++- .../active-sessions-special-settings.test.ts | 2 + .../active-sessions-termination.test.ts | 29 ++- .../session-origin-chain-integration.test.ts | 3 +- .../unit/actions/session-origin-chain.test.ts | 6 +- .../session-manager-terminate-session.test.ts | 34 +++ .../unit/lib/session-request-locator.test.ts | 80 ++----- .../response-handler-lease-decrement.test.ts | 3 +- tests/unit/proxy/warmup-guard.test.ts | 13 +- .../repository/message-origin-chain.test.ts | 73 +++++- .../message-session-readback.test.ts | 19 +- .../message-session-request-query.test.ts | 14 +- 21 files changed, 662 insertions(+), 215 deletions(-) diff --git a/src/actions/active-sessions.ts b/src/actions/active-sessions.ts index d672357ad..e46e3e5e7 100644 --- a/src/actions/active-sessions.ts +++ b/src/actions/active-sessions.ts @@ -12,7 +12,6 @@ import { extractAfterRequestMessages, isSessionMessages } from "@/lib/session-de import { resolveSessionRequestLocator } from "@/lib/session-request-locator"; import { normalizeRequestSequence } from "@/lib/utils/request-sequence"; import { buildUnifiedSpecialSettings } from "@/lib/utils/special-settings"; -import { isReservedSessionIdentity } from "@/repository/_shared/usage-log-filters"; import { type ActiveSessionInfo, DEFAULT_SESSION_DETAIL_VIEW_MODE, @@ -70,7 +69,7 @@ async function terminateResolvedSessionIdentity( resolution: ResolvedSessionIdentity | null, ownerUserId: number, dependencies?: SessionTerminationDependencies -): Promise { +): Promise<{ terminated: boolean; sourceSessionIds: string[] }> { const { SessionManager, SessionTracker, @@ -94,10 +93,13 @@ async function terminateResolvedSessionIdentity( ) ); const terminated = outcomes.some(Boolean); - if (terminated && isReservedSessionIdentity(identity)) { + if (terminated) { await SessionTracker.terminateObservedSession(identity); } - return terminated; + return { + terminated, + sourceSessionIds: physicalSources.map((source) => source.sessionId), + }; } const invalidated = await getAffinityStore().invalidate( @@ -105,7 +107,7 @@ async function terminateResolvedSessionIdentity( resolution.fingerprint, [...new Set([resolution.fingerprint, ...resolution.fingerprints])] ); - if (!invalidated) return false; + if (!invalidated) return { terminated: false, sourceSessionIds: [] }; const physicalSources = await listPhysicalSessionSourcesForIdentity(identity, ownerUserId); @@ -125,7 +127,10 @@ async function terminateResolvedSessionIdentity( } await SessionTracker.terminateObservedSession(identity); - return true; + return { + terminated: true, + sourceSessionIds: physicalSources.map((source) => source.sessionId), + }; } function normalizeRequestSnapshot( @@ -764,11 +769,29 @@ export async function hasSessionMessages( const isAdmin = authSession.user.role === "admin"; const currentUserId = authSession.user.id; - // 检查 Session 所有权(需要从数据库获取 userId) - const sessionStats = await loadCanonicalSessionStats( - sessionId, - isAdmin ? undefined : currentUserId - ); + let locatorResult: Awaited> | null = null; + let sessionStats: CanonicalSessionStats | null; + + if (requestId !== undefined) { + locatorResult = await resolveSessionRequestLocator( + sessionId, + requestSequence, + requestedSourceSessionId, + requestId, + isAdmin ? undefined : currentUserId + ); + if (!locatorResult.ok) return locatorResult; + sessionStats = await loadCanonicalSessionStats( + locatorResult.locator.canonicalSessionId, + locatorResult.locator.userId + ); + } else { + // 检查 Session 所有权(需要从数据库获取 userId) + sessionStats = await loadCanonicalSessionStats( + sessionId, + isAdmin ? undefined : currentUserId + ); + } if (!sessionStats) { return { @@ -788,11 +811,11 @@ export async function hasSessionMessages( }; } - const locatorResult = await resolveSessionRequestLocator( + locatorResult ??= await resolveSessionRequestLocator( sessionStats.sessionId, requestSequence, requestedSourceSessionId, - requestId, + undefined, sessionStats.userId ); if (!locatorResult.ok) return locatorResult; @@ -887,30 +910,47 @@ export async function getSessionDetails( const isAdmin = authSession.user.role === "admin"; const currentUserId = authSession.user.id; - // 1. 尝试从缓存获取统计数据 - const cachedStats = isAdmin ? null : getSessionDetailsCache(sessionId, currentUserId); - + let locatorResult: Awaited> | null = null; let sessionStats: CanonicalSessionStats | null; - if (cachedStats) { - logger.debug(`[SessionCache] Session details cache hit: ${sessionId}`); - sessionStats = cachedStats; - } else { - // 2. 从数据库查询 - sessionStats = await loadCanonicalSessionStats( + if (requestId !== undefined) { + locatorResult = await resolveSessionRequestLocator( sessionId, + requestSequence, + requestedSourceSessionId, + requestId, isAdmin ? undefined : currentUserId ); + if (!locatorResult.ok) return locatorResult; + + sessionStats = await loadCanonicalSessionStats( + locatorResult.locator.canonicalSessionId, + locatorResult.locator.userId + ); + } else { + // 1. 尝试从缓存获取统计数据 + const cachedStats = isAdmin ? null : getSessionDetailsCache(sessionId, currentUserId); - // 3. 写入缓存 - if (sessionStats) { - setSessionDetailsCache(sessionId, sessionStats); - if (sessionStats.sessionId !== sessionId) { - setSessionDetailsCache(sessionStats.sessionId, sessionStats); + if (cachedStats) { + logger.debug(`[SessionCache] Session details cache hit: ${sessionId}`); + sessionStats = cachedStats; + } else { + // 2. 从数据库查询 + sessionStats = await loadCanonicalSessionStats( + sessionId, + isAdmin ? undefined : currentUserId + ); + + // 3. 写入缓存 + if (sessionStats) { + setSessionDetailsCache(sessionId, sessionStats); + if (sessionStats.sessionId !== sessionId) { + setSessionDetailsCache(sessionStats.sessionId, sessionStats); + } } - } - logger.debug(`[SessionCache] Session details fetched and cached: ${sessionId}`); + logger.debug(`[SessionCache] Session details fetched and cached: ${sessionId}`); + } } // 4. 权限检查:管理员可查看所有,普通用户只能查看自己的 @@ -932,11 +972,11 @@ export async function getSessionDetails( } const canonicalSessionId = sessionStats.sessionId; - const locatorResult = await resolveSessionRequestLocator( + locatorResult ??= await resolveSessionRequestLocator( canonicalSessionId, requestSequence, requestedSourceSessionId, - requestId, + undefined, sessionStats.userId ); if (!locatorResult.ok) return locatorResult; @@ -944,6 +984,7 @@ export async function getSessionDetails( const sourceSessionId = locatorResult.locator.sourceSessionId; const effectiveSequence = locatorResult.locator.requestSequence; const effectiveRequestId = locatorResult.locator.requestId; + const requestOwnerUserId = locatorResult.locator.userId; // 5. 请求 locator 已同时验证 identity、物理 Session 和序号,后续所有读取必须复用它。 const { SessionManager } = await import("@/lib/session-manager"); @@ -957,7 +998,7 @@ export async function getSessionDetails( : await findAdjacentSessionRequests( canonicalSessionId, effectiveRequestId, - sessionStats.userId + requestOwnerUserId ); const parseJsonStringOrNull = (value: unknown): unknown => { @@ -982,18 +1023,11 @@ export async function getSessionDetails( } }; - if ( - !(await SessionManager.isSessionRequestOwnedByKey( - sourceSessionId, - effectiveSequence, - locatorResult.locator.keyId - )) - ) { - return { - ok: false, - error: "Session 请求详情已过期", - }; - } + const redisArtifactsOwned = await SessionManager.isSessionRequestOwnedByKey( + sourceSessionId, + effectiveSequence, + locatorResult.locator.keyId + ); // 6. 并行获取 messages、requestBody 和 response(不缓存,因为这些数据较大) const [ @@ -1012,20 +1046,58 @@ export async function getSessionDetails( responseSnapshotBefore, responseSnapshotAfter, ] = await Promise.all([ - SessionManager.getSessionRequestBody(sourceSessionId, effectiveSequence), - SessionManager.getSessionMessages(sourceSessionId, effectiveSequence), - SessionManager.getSessionResponse(sourceSessionId, effectiveSequence), - SessionManager.getSessionRequestHeaders(sourceSessionId, effectiveSequence), - SessionManager.getSessionResponseHeaders(sourceSessionId, effectiveSequence), - SessionManager.getSessionClientRequestMeta(sourceSessionId, effectiveSequence), - SessionManager.getSessionUpstreamRequestMeta(sourceSessionId, effectiveSequence), - SessionManager.getSessionUpstreamResponseMeta(sourceSessionId, effectiveSequence), - SessionManager.getSessionSpecialSettings(sourceSessionId, effectiveSequence), - findMessageRequestAuditById(effectiveRequestId, sessionStats.userId), - SessionManager.getSessionRequestPhaseSnapshot(sourceSessionId, "before", effectiveSequence), - SessionManager.getSessionRequestPhaseSnapshot(sourceSessionId, "after", effectiveSequence), - SessionManager.getSessionResponsePhaseSnapshot(sourceSessionId, "before", effectiveSequence), - SessionManager.getSessionResponsePhaseSnapshot(sourceSessionId, "after", effectiveSequence), + redisArtifactsOwned + ? SessionManager.getSessionRequestBody(sourceSessionId, effectiveSequence) + : null, + redisArtifactsOwned + ? SessionManager.getSessionMessages(sourceSessionId, effectiveSequence) + : null, + redisArtifactsOwned + ? SessionManager.getSessionResponse(sourceSessionId, effectiveSequence) + : null, + redisArtifactsOwned + ? SessionManager.getSessionRequestHeaders(sourceSessionId, effectiveSequence) + : null, + redisArtifactsOwned + ? SessionManager.getSessionResponseHeaders(sourceSessionId, effectiveSequence) + : null, + redisArtifactsOwned + ? SessionManager.getSessionClientRequestMeta(sourceSessionId, effectiveSequence) + : null, + redisArtifactsOwned + ? SessionManager.getSessionUpstreamRequestMeta(sourceSessionId, effectiveSequence) + : null, + redisArtifactsOwned + ? SessionManager.getSessionUpstreamResponseMeta(sourceSessionId, effectiveSequence) + : null, + redisArtifactsOwned + ? SessionManager.getSessionSpecialSettings(sourceSessionId, effectiveSequence) + : null, + findMessageRequestAuditById(effectiveRequestId, requestOwnerUserId), + redisArtifactsOwned + ? SessionManager.getSessionRequestPhaseSnapshot( + sourceSessionId, + "before", + effectiveSequence + ) + : null, + redisArtifactsOwned + ? SessionManager.getSessionRequestPhaseSnapshot(sourceSessionId, "after", effectiveSequence) + : null, + redisArtifactsOwned + ? SessionManager.getSessionResponsePhaseSnapshot( + sourceSessionId, + "before", + effectiveSequence + ) + : null, + redisArtifactsOwned + ? SessionManager.getSessionResponsePhaseSnapshot( + sourceSessionId, + "after", + effectiveSequence + ) + : null, ]); // 兼容:历史/异常数据可能是 JSON 字符串(前端需要根级对象/数组) @@ -1290,13 +1362,13 @@ export async function terminateActiveSession(sessionId: string): Promise(); const terminationChunkSize = 20; const terminationDependencies = await loadSessionTerminationDependencies(); const ownerByCanonicalId = new Map( @@ -1465,7 +1541,9 @@ export async function terminateActiveSessionsBatch( const outcomes = await Promise.allSettled( chunk.map(async (identity) => { const ownerUserId = ownerByCanonicalId.get(identity); - if (ownerUserId === undefined) return false; + if (ownerUserId === undefined) { + return { terminated: false, sourceSessionIds: [] }; + } const resolution = await resolveSessionIdentity(identity, ownerUserId); return terminateResolvedSessionIdentity( identity, @@ -1476,8 +1554,11 @@ export async function terminateActiveSessionsBatch( }) ); for (const [index, outcome] of outcomes.entries()) { - if (outcome.status === "fulfilled" && outcome.value) { + if (outcome.status === "fulfilled" && outcome.value.terminated) { successCount += 1; + for (const sourceSessionId of outcome.value.sourceSessionIds) { + terminatedSourceSessionIds.add(sourceSessionId); + } } else if (outcome.status === "rejected") { logger.warn("Batch Session termination item failed", { identity: chunk[index], @@ -1500,6 +1581,9 @@ export async function terminateActiveSessionsBatch( // 清除每个终止 Session 的 canonical 和请求 alias 详情缓存 const allowedCanonicalIds = new Set(allowedSessionIds); const sessionDetailCacheIds = new Set(allowedSessionIds); + for (const sourceSessionId of terminatedSourceSessionIds) { + sessionDetailCacheIds.add(sourceSessionId); + } for (const session of sessionsData) { if (!allowedCanonicalIds.has(session.sessionId)) continue; for (const requestedId of session.requestedSessionIds ?? []) { diff --git a/src/actions/session-origin-chain.ts b/src/actions/session-origin-chain.ts index c60f10365..0afe1c7c8 100644 --- a/src/actions/session-origin-chain.ts +++ b/src/actions/session-origin-chain.ts @@ -40,7 +40,8 @@ export async function getSessionOriginChain( if (!locatorResult.ok) return locatorResult; const chain = await findSessionOriginChain( - locatorResult.locator.sourceSessionId, + locatorResult.locator.requestId, + locatorResult.locator.keyId, sessionStats.userId ); return { ok: true, data: chain ?? null }; diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 3f60a1c9b..a0e8a8701 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -761,7 +761,8 @@ async function persistResponseBeforeSnapshot( headers: snapshot.headers, meta: snapshot.meta, }, - session.requestSequence + session.requestSequence, + session.authState?.key?.id ?? session.messageContext?.key?.id ?? undefined ).catch((err) => { logger.error("Failed to store response before snapshot meta:", err); }); @@ -8328,13 +8329,15 @@ export class ProxyForwarder { void SessionManager.storeSessionResponseHeaders( session.sessionId, responseHeaders, - session.requestSequence + session.requestSequence, + session.authState?.key?.id ?? session.messageContext?.key?.id ?? undefined ).catch((err) => logger.error("Failed to store response headers:", err)); void SessionManager.storeSessionUpstreamResponseMeta( session.sessionId, { url, statusCode: undiciRes.statusCode }, - session.requestSequence + session.requestSequence, + session.authState?.key?.id ?? session.messageContext?.key?.id ?? undefined ).catch((err) => logger.error("Failed to store upstream response meta:", err)); if (!deferDetailSnapshotPersistence) { diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index 31d7d8f40..efd564ceb 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -89,6 +89,10 @@ const STREAM_STATS_TAIL_CHUNKS = 8192; const STREAM_STATS_TRUNCATED_MARKER = "\n\n: [cch_truncated]\n\n"; const RESPONSE_TEXT_ENCODER = new TextEncoder(); +function getSessionRequestOwnerKeyId(session: ProxySession): number | undefined { + return session.authState?.key?.id ?? session.messageContext?.key?.id ?? undefined; +} + type BoundedStreamTextSnapshot = { text: string; truncated: boolean; @@ -2503,7 +2507,8 @@ export class ProxyResponseHandler { statusCode: targetResponse.status, }, }, - session.requestSequence + session.requestSequence, + getSessionRequestOwnerKeyId(session) ); responseAfterSnapshotTask?.catch((err) => { logger.error("[ResponseHandler] Failed to store response after snapshot:", err); @@ -2558,7 +2563,8 @@ export class ProxyResponseHandler { void SessionManager.storeSessionResponse( session.sessionId, responseText, - session.requestSequence + session.requestSequence, + getSessionRequestOwnerKeyId(session) ).catch((err) => { logger.error("[ResponseHandler] Failed to store response:", err); }); @@ -2567,7 +2573,8 @@ export class ProxyResponseHandler { session.sessionId, "before", { body: beforeBody }, - session.requestSequence + session.requestSequence, + getSessionRequestOwnerKeyId(session) ); responseBeforeSnapshotTask?.catch((err) => { logger.error("[ResponseHandler] Failed to store response before snapshot:", err); @@ -2577,7 +2584,8 @@ export class ProxyResponseHandler { session.sessionId, "after", { body: responseText }, - session.requestSequence + session.requestSequence, + getSessionRequestOwnerKeyId(session) ); responseAfterSnapshotTask?.catch((err) => { logger.error("[ResponseHandler] Failed to store response after snapshot:", err); @@ -2832,7 +2840,8 @@ export class ProxyResponseHandler { statusCode: response.status, }, }, - session.requestSequence + session.requestSequence, + getSessionRequestOwnerKeyId(session) ); responseAfterMetaTask?.catch((err) => { logger.error("[ResponseHandler] Failed to store non-stream response after meta:", err); @@ -3131,7 +3140,8 @@ export class ProxyResponseHandler { void SessionManager.storeSessionResponse( session.sessionId, responseText, - session.requestSequence + session.requestSequence, + getSessionRequestOwnerKeyId(session) ).catch((err) => { logger.error("[ResponseHandler] Failed to store response:", err); }); @@ -3140,7 +3150,8 @@ export class ProxyResponseHandler { session.sessionId, "before", { body: beforeBody }, - session.requestSequence + session.requestSequence, + getSessionRequestOwnerKeyId(session) ); responseBeforeSnapshotTask?.catch((err) => { logger.error("[ResponseHandler] Failed to store response before snapshot:", err); @@ -3839,7 +3850,8 @@ export class ProxyResponseHandler { void SessionManager.storeSessionResponse( session.sessionId, allContent, - session.requestSequence + session.requestSequence, + getSessionRequestOwnerKeyId(session) ).catch((err) => { logger.error("[ResponseHandler] Failed to store stream passthrough response:", err); }); @@ -3848,7 +3860,8 @@ export class ProxyResponseHandler { session.sessionId, "before", { body: allContent }, - session.requestSequence + session.requestSequence, + getSessionRequestOwnerKeyId(session) ); responseBeforeSnapshotTask?.catch((err) => { logger.error("[ResponseHandler] Failed to store response before snapshot:", err); @@ -3858,7 +3871,8 @@ export class ProxyResponseHandler { session.sessionId, "after", { body: allContent }, - session.requestSequence + session.requestSequence, + getSessionRequestOwnerKeyId(session) ); responseAfterSnapshotTask?.catch((err) => { logger.error("[ResponseHandler] Failed to store response after snapshot:", err); @@ -4044,7 +4058,8 @@ export class ProxyResponseHandler { statusCode: response.status, }, }, - session.requestSequence + session.requestSequence, + getSessionRequestOwnerKeyId(session) ); responseAfterMetaTask?.catch((err) => { logger.error("[ResponseHandler] Failed to store stream response after meta:", err); @@ -4394,7 +4409,8 @@ export class ProxyResponseHandler { void SessionManager.storeSessionResponse( session.sessionId, allContent, - session.requestSequence + session.requestSequence, + getSessionRequestOwnerKeyId(session) ).catch((err) => { logger.error("[ResponseHandler] Failed to store response:", err); }); @@ -4403,7 +4419,8 @@ export class ProxyResponseHandler { session.sessionId, "after", { body: allContent }, - session.requestSequence + session.requestSequence, + getSessionRequestOwnerKeyId(session) ); responseAfterSnapshotTask?.catch((err) => { logger.error("[ResponseHandler] Failed to store response after snapshot:", err); @@ -4413,7 +4430,8 @@ export class ProxyResponseHandler { session.sessionId, "before", { body: beforeBody }, - session.requestSequence + session.requestSequence, + getSessionRequestOwnerKeyId(session) ); responseBeforeSnapshotTask?.catch((err) => { logger.error("[ResponseHandler] Failed to store response before snapshot:", err); @@ -5242,7 +5260,8 @@ export class ProxyResponseHandler { statusCode: response.status, }, }, - session.requestSequence + session.requestSequence, + getSessionRequestOwnerKeyId(session) ); responseAfterMetaTask?.catch((err) => { logger.error("[ResponseHandler] Failed to store stream response after meta:", err); diff --git a/src/app/v1/_lib/proxy/warmup-guard.ts b/src/app/v1/_lib/proxy/warmup-guard.ts index 654264797..208fa4aa8 100644 --- a/src/app/v1/_lib/proxy/warmup-guard.ts +++ b/src/app/v1/_lib/proxy/warmup-guard.ts @@ -48,8 +48,13 @@ export class ProxyWarmupGuard { if (session.sessionId && session.shouldPersistSessionDebugArtifacts()) { const seq = session.getRequestSequence(); await Promise.allSettled([ - SessionManager.storeSessionResponse(session.sessionId, responseText, seq), - SessionManager.storeSessionResponseHeaders(session.sessionId, responseHeaders, seq), + SessionManager.storeSessionResponse(session.sessionId, responseText, seq, authState.key.id), + SessionManager.storeSessionResponseHeaders( + session.sessionId, + responseHeaders, + seq, + authState.key.id + ), SessionManager.storeSessionUpstreamRequestMeta( session.sessionId, { url: WARMUP_UPSTREAM_META_URL, method: session.method }, @@ -58,7 +63,8 @@ export class ProxyWarmupGuard { SessionManager.storeSessionUpstreamResponseMeta( session.sessionId, { url: WARMUP_UPSTREAM_META_URL, statusCode: 200 }, - seq + seq, + authState.key.id ), ]); } diff --git a/src/lib/session-manager-detail-snapshots.test.ts b/src/lib/session-manager-detail-snapshots.test.ts index 452dafb0a..a35ae9cd1 100644 --- a/src/lib/session-manager-detail-snapshots.test.ts +++ b/src/lib/session-manager-detail-snapshots.test.ts @@ -48,6 +48,7 @@ const redisMock = { set: vi.fn().mockResolvedValue("OK"), expire: vi.fn().mockResolvedValue(1), incr: vi.fn().mockResolvedValue(1), + eval: vi.fn().mockResolvedValue(1), pipeline: vi.fn(() => redisPipeline), }; @@ -77,15 +78,21 @@ describe("SessionManager detail snapshots", () => { mockStoreSessionResponseBody = true; }); - it("refreshes the sequence TTL and records the immutable request owner", async () => { - redisMock.incr.mockResolvedValueOnce(1); + it("atomically persists the request sequence while expiring its owner marker", async () => { + redisMock.eval.mockResolvedValueOnce(1); await expect(SessionManager.getNextRequestSequence("sess_owner", 42)).resolves.toBe(1); - expect(redisMock.incr).toHaveBeenCalledWith("session:sess_owner:seq"); - expect(pipelineExpireMock).toHaveBeenCalledWith("session:sess_owner:seq", 300); - expect(pipelineSetexMock).toHaveBeenCalledWith("session:sess_owner:req:1:owner", 300, "42"); - expect(pipelineExecMock).toHaveBeenCalledOnce(); + expect(redisMock.eval).toHaveBeenCalledWith( + expect.stringContaining("redis.call('PERSIST', KEYS[1])"), + 1, + "session:sess_owner:seq", + "session:sess_owner:req:", + "300", + "42" + ); + expect(redisMock.incr).not.toHaveBeenCalled(); + expect(redisMock.pipeline).not.toHaveBeenCalled(); }); it("validates request artifacts against their immutable key owner", async () => { @@ -111,6 +118,43 @@ describe("SessionManager detail snapshots", () => { expect(redisMock.get).not.toHaveBeenCalled(); }); + it("refreshes the request owner when a late response snapshot is stored", async () => { + await SessionManager.storeSessionResponsePhaseSnapshot( + "sess_late_response", + "after", + { body: "late response" }, + 7, + 42 + ); + + expect(redisMock.setex).toHaveBeenCalledWith( + "session:sess_late_response:req:7:owner", + 300, + "42" + ); + }); + + it("does not use legacy messages for a missing scoped request", async () => { + redisStore.set( + "session:sess_scoped_messages:messages", + JSON.stringify([{ role: "user", content: "legacy" }]) + ); + + await expect(SessionManager.getSessionMessages("sess_scoped_messages", 7)).resolves.toBeNull(); + await expect(SessionManager.getSessionMessages("sess_scoped_messages")).resolves.toEqual([ + { role: "user", content: "legacy" }, + ]); + }); + + it("does not use legacy response for a missing scoped request", async () => { + redisStore.set("session:sess_scoped_response:response", "legacy response"); + + await expect(SessionManager.getSessionResponse("sess_scoped_response", 7)).resolves.toBeNull(); + await expect(SessionManager.getSessionResponse("sess_scoped_response")).resolves.toBe( + "legacy response" + ); + }); + it("stores and retrieves request/response before-after snapshots with TTL and redaction", async () => { await SessionManager.storeSessionRequestPhaseSnapshot( "sess_snap", diff --git a/src/lib/session-manager.ts b/src/lib/session-manager.ts index a0fdbaaec..4128f8e90 100644 --- a/src/lib/session-manager.ts +++ b/src/lib/session-manager.ts @@ -378,12 +378,24 @@ export class SessionManager { try { const key = `session:${sessionId}:seq`; - const sequence = await redis.incr(key); - const ownerKey = `session:${sessionId}:req:${sequence}:owner`; - const pipeline = redis.pipeline(); - pipeline.expire(key, SessionManager.SESSION_TTL); - pipeline.setex(ownerKey, SessionManager.SESSION_TTL, String(keyId)); - await pipeline.exec(); + const rawSequence = await redis.eval( + ` + local sequence = redis.call('INCR', KEYS[1]) + redis.call('PERSIST', KEYS[1]) + local ownerKey = ARGV[1] .. sequence .. ':owner' + redis.call('SETEX', ownerKey, ARGV[2], ARGV[3]) + return sequence + `, + 1, + key, + `session:${sessionId}:req:`, + String(SessionManager.SESSION_TTL), + String(keyId) + ); + const sequence = Number(rawSequence); + if (!Number.isSafeInteger(sequence) || sequence <= 0) { + throw new Error("Redis returned an invalid request sequence"); + } logger.trace("SessionManager: Got next request sequence", { sessionId, @@ -424,6 +436,20 @@ export class SessionManager { } } + private static async refreshSessionRequestOwner( + redis: NonNullable>, + sessionId: string, + requestSequence: number, + keyId?: number + ): Promise { + if (keyId === undefined) return; + await redis.setex( + `session:${sessionId}:req:${requestSequence}:owner`, + SessionManager.SESSION_TTL, + String(keyId) + ); + } + /** * 获取 Session 当前的请求计数 * @@ -1936,13 +1962,13 @@ export class SessionManager { if (redis?.status !== "ready") return null; try { - // 优先尝试新格式 - if (requestSequence) { - const newKey = `session:${sessionId}:req:${requestSequence}:messages`; + if (requestSequence !== undefined) { + const sequence = normalizeRequestSequence(requestSequence); + if (sequence === null) return null; + + const newKey = `session:${sessionId}:req:${sequence}:messages`; const messagesJson = await redis.get(newKey); - if (messagesJson) { - return JSON.parse(messagesJson); - } + return messagesJson ? JSON.parse(messagesJson) : null; } // 向后兼容:尝试旧格式 @@ -2024,7 +2050,8 @@ export class SessionManager { static async storeSessionResponse( sessionId: string, response: string | object, - requestSequence?: number + requestSequence?: number, + keyId?: number ): Promise { // 允许通过环境变量显式关闭响应体存储(例如隐私/节省 Redis 内存)。 // 注意:这里仅关闭“写入 Redis”这一步;调用方仍然可能在内存中读取响应体用于统计或错误检测。 @@ -2057,9 +2084,13 @@ export class SessionManager { // 新格式:session:{sessionId}:req:{sequence}:response(独立存储每个请求) // 旧格式:session:{sessionId}:response(向后兼容) - const key = requestSequence - ? `session:${sessionId}:req:${requestSequence}:response` + const sequence = normalizeRequestSequence(requestSequence); + const key = sequence + ? `session:${sessionId}:req:${sequence}:response` : `session:${sessionId}:response`; + if (sequence) { + await SessionManager.refreshSessionRequestOwner(redis, sessionId, sequence, keyId); + } await redis.setex(key, SessionManager.SESSION_TTL, responseString); logger.trace("SessionManager: Stored session response", { sessionId, @@ -2309,13 +2340,15 @@ export class SessionManager { static async storeSessionUpstreamResponseMeta( sessionId: string, meta: { url: string | URL; statusCode: number }, - requestSequence?: number + requestSequence?: number, + keyId?: number ): Promise { const redis = getRedisClient(); if (redis?.status !== "ready") return; try { const sequence = normalizeRequestSequence(requestSequence) ?? 1; + await SessionManager.refreshSessionRequestOwner(redis, sessionId, sequence, keyId); const key = `session:${sessionId}:req:${sequence}:upstreamResMeta`; const payload: SessionResponseMeta = { url: sanitizeUrl(meta.url), @@ -2378,13 +2411,15 @@ export class SessionManager { static async storeSessionResponseHeaders( sessionId: string, headers: Headers, - requestSequence?: number + requestSequence?: number, + keyId?: number ): Promise { const redis = getRedisClient(); if (redis?.status !== "ready") return; try { const sequence = normalizeRequestSequence(requestSequence) ?? 1; + await SessionManager.refreshSessionRequestOwner(redis, sessionId, sequence, keyId); const key = `session:${sessionId}:req:${sequence}:resHeaders`; const headersJson = JSON.stringify(headersToSanitizedObject(headers)); await redis.setex(key, SessionManager.SESSION_TTL, headersJson); @@ -2456,11 +2491,13 @@ export class SessionManager { if (redis?.status !== "ready") return null; try { - // 优先尝试新格式 - if (requestSequence) { - const newKey = `session:${sessionId}:req:${requestSequence}:response`; + if (requestSequence !== undefined) { + const sequence = normalizeRequestSequence(requestSequence); + if (sequence === null) return null; + + const newKey = `session:${sessionId}:req:${sequence}:response`; const response = await redis.get(newKey); - if (response) return response; + return response; } // 向后兼容:尝试旧格式 @@ -2617,13 +2654,15 @@ export class SessionManager { sessionId: string, phase: SessionDetailViewMode, snapshot: SessionDetailResponseSnapshotInput, - requestSequence?: number + requestSequence?: number, + keyId?: number ): Promise { const redis = getRedisClient(); if (redis?.status !== "ready") return; try { const sequence = normalizeRequestSequence(requestSequence) ?? 1; + await SessionManager.refreshSessionRequestOwner(redis, sessionId, sequence, keyId); const writes: Array> = []; if ("body" in snapshot) { @@ -2952,6 +2991,15 @@ export class SessionManager { return false; } } catch (lookupError) { + if (expectedKeyId !== undefined) { + logger.warn("SessionManager: Failed to verify session owner before termination", { + sessionId, + expectedKeyId, + error: lookupError, + }); + return false; + } + // Redis 查询失败不应阻止清理操作,继续执行删除 logger.warn( "SessionManager: Failed to lookup session binding info, continuing with cleanup", diff --git a/src/lib/session-request-locator.ts b/src/lib/session-request-locator.ts index 037401846..f885bc680 100644 --- a/src/lib/session-request-locator.ts +++ b/src/lib/session-request-locator.ts @@ -18,6 +18,26 @@ export async function resolveSessionRequestLocator( ownerUserId?: number ): Promise { const normalizedSequence = normalizeRequestSequence(requestSequence); + + if (requestId !== undefined) { + const locator = await findSessionRequestLocator( + identity, + { + requestId, + requestSequence: normalizedSequence ?? undefined, + sourceSessionId, + }, + ownerUserId + ); + return locator + ? { ok: true, locator } + : { + ok: false, + error: BUSINESS_ERRORS.SESSION_REQUEST_SOURCE_MISMATCH, + errorCode: BUSINESS_ERRORS.SESSION_REQUEST_SOURCE_MISMATCH, + }; + } + const identityLocator = await findSessionRequestLocator(identity, {}, ownerUserId); if (!identityLocator) { @@ -42,11 +62,10 @@ export async function resolveSessionRequestLocator( } const locator = - normalizedSequence !== null || sourceSessionId || requestId !== undefined + normalizedSequence !== null || sourceSessionId ? await findSessionRequestLocator( identity, { - requestId, requestSequence: normalizedSequence ?? undefined, sourceSessionId, }, diff --git a/src/repository/message.ts b/src/repository/message.ts index a6bf891f4..3664efc44 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -1,6 +1,7 @@ "use server"; -import { and, asc, desc, eq, gt, isNotNull, isNull, lt, or, sql } from "drizzle-orm"; +import { and, asc, desc, eq, gt, isNotNull, isNull, lt, lte, or, sql } from "drizzle-orm"; +import { alias } from "drizzle-orm/pg-core"; import { db, getMessageWriterDb } from "@/drizzle/db"; import { keys as keysTable, messageRequest, providers, usageLedger, users } from "@/drizzle/schema"; import { getEnvConfig } from "@/lib/config/env.schema"; @@ -1207,29 +1208,62 @@ export async function findMessageRequestBySessionId( } /** - * 根据 sessionId 查询该 session 首条非 warmup 请求的 providerChain - * 用于展示会话来源链(原始选择决策) + * 查询选中请求所属 key epoch 内最近且不晚于该请求的初始 providerChain。 */ export async function findSessionOriginChain( - sessionId: string, - ownerUserId?: number + requestId: number, + keyId: number, + ownerUserId: number ): Promise { + const selectedRequest = alias(messageRequest, "selected_message_request"); + const requestBoundary = or( + and( + isNotNull(selectedRequest.createdAt), + or( + lt(messageRequest.createdAt, selectedRequest.createdAt), + and( + eq(messageRequest.createdAt, selectedRequest.createdAt), + lte(messageRequest.id, selectedRequest.id) + ) + ) + ), + and(isNull(selectedRequest.createdAt), lte(messageRequest.id, selectedRequest.id)) + ); + const [row] = await db .select({ providerChain: messageRequest.providerChain, }) .from(messageRequest) + .innerJoin(keysTable, eq(messageRequest.key, keysTable.key)) + .innerJoin( + selectedRequest, + and( + eq(selectedRequest.id, requestId), + eq(selectedRequest.key, keysTable.key), + eq(selectedRequest.userId, ownerUserId), + isNotNull(selectedRequest.sessionId), + isNull(selectedRequest.deletedAt), + eq(messageRequest.sessionId, selectedRequest.sessionId), + eq(messageRequest.userId, selectedRequest.userId), + requestBoundary + ) + ) .where( and( - eq(messageRequest.sessionId, sessionId), - ownerUserId !== undefined ? eq(messageRequest.userId, ownerUserId) : undefined, + eq(keysTable.id, keyId), + eq(messageRequest.userId, ownerUserId), isNull(messageRequest.deletedAt), EXCLUDE_WARMUP_CONDITION, sql`${messageRequest.providerChain} IS NOT NULL`, sql`${messageRequest.providerChain} @> '[{"reason": "initial_selection"}]'::jsonb` ) ) - .orderBy(asc(messageRequest.requestSequence)) + .orderBy( + sql`CASE WHEN ${selectedRequest.createdAt} IS NULL THEN ${messageRequest.id} END DESC`, + desc(messageRequest.createdAt), + desc(messageRequest.id) + ) .limit(1); if (!row?.providerChain) return null; @@ -1613,9 +1647,11 @@ export async function findSessionRequestLocator( ownerUserId?: number ): Promise<{ requestId: number; + canonicalSessionId: string; sourceSessionId: string; requestSequence: number; keyId: number; + userId: number; identityKind: "session_id" | "prefix_affinity"; scopeTag: string | null; fingerprint: string | null; @@ -1623,9 +1659,11 @@ export async function findSessionRequestLocator( const [row] = await db .select({ requestId: messageRequest.id, + canonicalSessionId: messageSessionIdentity, sourceSessionId: messageRequest.sessionId, requestSequence: messageRequest.requestSequence, keyId: keysTable.id, + userId: messageRequest.userId, identityKind: messageRequest.sessionIdentityKind, scopeTag: messageRequest.affinityScopeTag, fingerprint: messageRequest.affinityFingerprint, @@ -1634,7 +1672,9 @@ export async function findSessionRequestLocator( .innerJoin(keysTable, eq(messageRequest.key, keysTable.key)) .where( and( - messageCanonicalSessionLookup(identity, ownerUserId), + selector.requestId !== undefined + ? messageSessionLookup(identity, ownerUserId) + : messageCanonicalSessionLookup(identity, ownerUserId), isNotNull(messageRequest.sessionId), isNotNull(messageRequest.requestSequence), selector.requestId !== undefined ? eq(messageRequest.id, selector.requestId) : undefined, @@ -1650,15 +1690,24 @@ export async function findSessionRequestLocator( .orderBy(desc(messageRequest.createdAt), desc(messageRequest.id)) .limit(1); - if (!row?.requestId || !row.sourceSessionId || row.requestSequence == null || !row.keyId) { + if ( + !row?.requestId || + !row.canonicalSessionId || + !row.sourceSessionId || + row.requestSequence == null || + !row.keyId || + !row.userId + ) { return null; } return { requestId: row.requestId, + canonicalSessionId: row.canonicalSessionId, sourceSessionId: row.sourceSessionId, requestSequence: row.requestSequence, keyId: row.keyId, + userId: row.userId, identityKind: row.identityKind === "prefix_affinity" ? "prefix_affinity" : "session_id", scopeTag: row.scopeTag, fingerprint: row.fingerprint, diff --git a/tests/unit/actions/active-sessions-detail-snapshots.test.ts b/tests/unit/actions/active-sessions-detail-snapshots.test.ts index b715e7610..939d240a8 100644 --- a/tests/unit/actions/active-sessions-detail-snapshots.test.ts +++ b/tests/unit/actions/active-sessions-detail-snapshots.test.ts @@ -123,9 +123,11 @@ describe("getSessionDetails - additive detail snapshots contract", () => { selector: { requestId?: number; sourceSessionId?: string; requestSequence?: number } = {} ) => ({ requestId: selector.requestId ?? 101, + canonicalSessionId: identity, sourceSessionId: selector.sourceSessionId ?? identity, requestSequence: selector.requestSequence ?? 1, keyId: 1, + userId: 1, identityKind: identity.startsWith("pfx:") ? "prefix_affinity" : "session_id", scopeTag: identity.startsWith("pfx:") ? "scope" : null, fingerprint: identity.startsWith("pfx:") ? "fingerprint" : null, @@ -253,7 +255,7 @@ describe("getSessionDetails - additive detail snapshots contract", () => { requestSequence: 1, sourceSessionId: "physical-selected", }, - 1 + undefined ); expect(getSessionRequestBodyMock).toHaveBeenCalledWith("physical-selected", 1); expect(findAdjacentSessionRequestsMock).toHaveBeenCalledWith("pfx:scope:fingerprint", 101, 1); @@ -261,6 +263,50 @@ describe("getSessionDetails - additive detail snapshots contract", () => { expect(isSessionRequestOwnedByKeyMock).toHaveBeenCalledWith("physical-selected", 1, 1); }); + test("anchors an admin request-id lookup to the selected row owner", async () => { + findSessionRequestLocatorMock.mockResolvedValueOnce({ + requestId: 202, + canonicalSessionId: "sid:owner-two", + sourceSessionId: "shared-client-session", + requestSequence: 4, + keyId: 22, + userId: 2, + identityKind: "session_id", + scopeTag: null, + fingerprint: null, + }); + aggregateMultipleSessionStatsMock.mockResolvedValueOnce([ + { + sessionId: "sid:owner-two", + userId: 2, + requestCount: 1, + providers: [], + models: [], + }, + ]); + + const { getSessionDetails } = await import("@/actions/active-sessions"); + const result = await getSessionDetails( + "shared-client-session", + 4, + "shared-client-session", + 202 + ); + + expect(result.ok).toBe(true); + expect(findSessionRequestLocatorMock).toHaveBeenCalledWith( + "shared-client-session", + { + requestId: 202, + requestSequence: 4, + sourceSessionId: "shared-client-session", + }, + undefined + ); + expect(aggregateMultipleSessionStatsMock).toHaveBeenCalledWith(["sid:owner-two"], 2); + expect(findMessageRequestAuditByIdMock).toHaveBeenCalledWith(202, 2); + }); + test("rejects a physical source and sequence that do not belong to the prefix identity", async () => { aggregateSessionStatsMock.mockResolvedValue({ sessionId: "pfx:scope:fingerprint", @@ -537,30 +583,46 @@ describe("getSessionDetails - additive detail snapshots contract", () => { expect(result).toEqual({ ok: true, data: true }); expect(findSessionRequestLocatorMock).toHaveBeenLastCalledWith( - "sess_x", + "pfx:scope:root", { requestId: 203, requestSequence: undefined, sourceSessionId: "physical-a", }, - 1 + undefined ); expect(getSessionMessagesMock).toHaveBeenCalledWith("physical-a", 1); expect(hasAnySessionMessagesMock).not.toHaveBeenCalled(); }); - test("fails closed before reading request details when the owner marker is missing", async () => { + test("keeps database-backed details when the Redis owner marker is missing", async () => { isSessionRequestOwnedByKeyMock.mockResolvedValueOnce(false); + findMessageRequestAuditByIdMock.mockResolvedValueOnce({ + statusCode: 429, + blockedBy: "rate_limit", + blockedReason: "quota exceeded", + cacheTtlApplied: null, + context1mApplied: null, + swapCacheTtlApplied: null, + specialSettings: null, + }); const { getSessionDetails } = await import("@/actions/active-sessions"); - await expect(getSessionDetails("sess_x", 1)).resolves.toEqual({ - ok: false, - error: "Session 请求详情已过期", - }); + const result = await getSessionDetails("sess_x", 1); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.requestBody).toBeNull(); + expect(result.data.messages).toBeNull(); + expect(result.data.response).toBeNull(); expect(getSessionRequestBodyMock).not.toHaveBeenCalled(); expect(getSessionMessagesMock).not.toHaveBeenCalled(); - expect(findMessageRequestAuditByIdMock).not.toHaveBeenCalled(); + expect(findMessageRequestAuditByIdMock).toHaveBeenCalledWith(101, 1); + expect(result.data.specialSettings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "guard_intercept", guard: "rate_limit" }), + ]) + ); }); test("hides the details link when the request owner marker is missing", async () => { diff --git a/tests/unit/actions/active-sessions-special-settings.test.ts b/tests/unit/actions/active-sessions-special-settings.test.ts index 35094be94..4e3aff82b 100644 --- a/tests/unit/actions/active-sessions-special-settings.test.ts +++ b/tests/unit/actions/active-sessions-special-settings.test.ts @@ -109,9 +109,11 @@ describe("getSessionDetails - unified specialSettings", () => { }); findSessionRequestLocatorMock.mockResolvedValue({ requestId: 101, + canonicalSessionId: "sess_x", sourceSessionId: "sess_x", requestSequence: 1, keyId: 1, + userId: 1, identityKind: "session_id", scopeTag: null, fingerprint: null, diff --git a/tests/unit/actions/active-sessions-termination.test.ts b/tests/unit/actions/active-sessions-termination.test.ts index af4948be5..d5a4b3fbd 100644 --- a/tests/unit/actions/active-sessions-termination.test.ts +++ b/tests/unit/actions/active-sessions-termination.test.ts @@ -119,6 +119,32 @@ describe("active Session termination identity contract", () => { expect(invalidateMock).toHaveBeenCalledWith("scope", "tip", ["tip", "parent", "root"]); expect(clearSessionDetailsCacheMock).toHaveBeenCalledWith("physical-session"); expect(clearSessionDetailsCacheMock).toHaveBeenCalledWith("pfx:scope:tip"); + expect(clearSessionDetailsCacheMock).toHaveBeenCalledWith("physical-session-2"); + }); + + test("ordinary identity termination clears observed state after physical cleanup", async () => { + aggregateMultipleSessionStatsMock.mockResolvedValueOnce([ + { + sessionId: "ordinary-client-session", + requestedSessionIds: ["ordinary-client-session"], + userId: 1, + }, + ]); + resolveSessionIdentityMock.mockResolvedValueOnce({ + sourceSessionId: "ordinary-client-session", + identityKind: "session_id", + scopeTag: null, + fingerprint: null, + fingerprints: [], + }); + + const { terminateActiveSession } = await import("@/actions/active-sessions"); + + await expect(terminateActiveSession("ordinary-client-session")).resolves.toEqual({ + ok: true, + data: undefined, + }); + expect(terminateObservedSessionMock).toHaveBeenCalledWith("ordinary-client-session"); }); test("prefix termination fails when affinity invalidation fails", async () => { @@ -265,7 +291,7 @@ describe("active Session termination identity contract", () => { expect(terminateSessionMock).toHaveBeenCalledWith("physical-session", undefined, 13); expect(terminateSessionsBatchMock).not.toHaveBeenCalled(); expect(terminateObservedSessionMock).toHaveBeenCalledWith("pfx:scope:tip"); - expect(terminateObservedSessionMock).not.toHaveBeenCalledWith("physical-session"); + expect(terminateObservedSessionMock).toHaveBeenCalledWith("physical-session"); }); test("batch termination clears physical aliases and canonical detail caches", async () => { @@ -285,6 +311,7 @@ describe("active Session termination identity contract", () => { expect(clearSessionDetailsCacheMock).toHaveBeenCalledWith("physical-session"); expect(clearSessionDetailsCacheMock).toHaveBeenCalledWith("pfx:scope:tip"); + expect(clearSessionDetailsCacheMock).toHaveBeenCalledWith("physical-session-2"); }); test("batch counts affinity invalidation failures instead of observed cleanup results", async () => { diff --git a/tests/unit/actions/session-origin-chain-integration.test.ts b/tests/unit/actions/session-origin-chain-integration.test.ts index 6a6a52808..b71b2a8ca 100644 --- a/tests/unit/actions/session-origin-chain-integration.test.ts +++ b/tests/unit/actions/session-origin-chain-integration.test.ts @@ -18,6 +18,7 @@ describe("getSessionOriginChain", () => { .fn() .mockResolvedValue([{ sessionId: "test-session", userId: 1 }]); const findSessionRequestLocatorMock = vi.fn().mockResolvedValue({ + requestId: 101, identityKind: "direct", sourceSessionId: "test-session", requestSequence: 1, @@ -60,6 +61,6 @@ describe("getSessionOriginChain", () => { expect(result.data[0]?.reason).toBe("initial_selection"); expect(findSessionRequestLocatorMock).toHaveBeenCalledWith("test-session", {}, 1); - expect(findSessionOriginChainMock).toHaveBeenCalledWith("test-session", 1); + expect(findSessionOriginChainMock).toHaveBeenCalledWith(101, 1, 1); }); }); diff --git a/tests/unit/actions/session-origin-chain.test.ts b/tests/unit/actions/session-origin-chain.test.ts index 4eb859732..95a8ff328 100644 --- a/tests/unit/actions/session-origin-chain.test.ts +++ b/tests/unit/actions/session-origin-chain.test.ts @@ -78,7 +78,7 @@ describe("getSessionOriginChain", () => { const result = await getSessionOriginChain("pfx:scope:fingerprint", 2, "physical-selected"); expect(result).toEqual({ ok: true, data: chain }); - expect(findSessionOriginChainMock).toHaveBeenCalledWith("physical-selected", 2); + expect(findSessionOriginChainMock).toHaveBeenCalledWith(101, 1, 2); expect(findKeyListMock).not.toHaveBeenCalled(); expect(dbSelectMock).not.toHaveBeenCalled(); }); @@ -100,7 +100,7 @@ describe("getSessionOriginChain", () => { expect(result).toEqual({ ok: true, data: chain }); expect(aggregateMultipleSessionStatsMock).toHaveBeenCalledWith(["pfx:scope:fingerprint"], 2); - expect(findSessionOriginChainMock).toHaveBeenCalledWith("physical-selected", 2); + expect(findSessionOriginChainMock).toHaveBeenCalledWith(101, 1, 2); }); test("unauthenticated: returns not logged in", async () => { @@ -149,7 +149,7 @@ describe("getSessionOriginChain", () => { const result = await getSessionOriginChain("sess-not-found"); expect(result).toEqual({ ok: true, data: null }); - expect(findSessionOriginChainMock).toHaveBeenCalledWith("sess-not-found", 1); + expect(findSessionOriginChainMock).toHaveBeenCalledWith(101, 1, 1); expect(findKeyListMock).not.toHaveBeenCalled(); expect(dbSelectMock).not.toHaveBeenCalled(); }); diff --git a/tests/unit/lib/session-manager-terminate-session.test.ts b/tests/unit/lib/session-manager-terminate-session.test.ts index 05f6ac1b2..237ac8c7d 100644 --- a/tests/unit/lib/session-manager-terminate-session.test.ts +++ b/tests/unit/lib/session-manager-terminate-session.test.ts @@ -111,6 +111,40 @@ describe("SessionManager.terminateSession", () => { expect(pipelineRef.zrem).not.toHaveBeenCalledWith(getUserActiveSessionsKey(123), sessionId); }); + it("fails closed when the owner key lookup rejects during scoped termination", async () => { + const sessionId = "sess_owner_lookup_failure"; + redisClientRef.get.mockImplementation(async (key: string) => { + if (key === `session:${sessionId}:provider`) return "42"; + if (key === `session:${sessionId}:key`) throw new Error("owner lookup failed"); + return null; + }); + redisClientRef.hget.mockResolvedValue("123"); + + const { SessionManager } = await import("@/lib/session-manager"); + + await expect(SessionManager.terminateSession(sessionId, undefined, 7)).resolves.toBe(false); + expect(redisClientRef.pipeline).not.toHaveBeenCalled(); + expect(bindingMocks.readOrReconcileSessionBinding).not.toHaveBeenCalled(); + expect(bindingMocks.mutateLegacySessionBindingSafely).not.toHaveBeenCalled(); + }); + + it("fails closed when an ancillary owner lookup rejects during scoped termination", async () => { + const sessionId = "sess_owner_metadata_lookup_failure"; + redisClientRef.get.mockImplementation(async (key: string) => { + if (key === `session:${sessionId}:provider`) return "42"; + if (key === `session:${sessionId}:key`) return "7"; + return null; + }); + redisClientRef.hget.mockRejectedValue(new Error("user lookup failed")); + + const { SessionManager } = await import("@/lib/session-manager"); + + await expect(SessionManager.terminateSession(sessionId, undefined, 7)).resolves.toBe(false); + expect(redisClientRef.pipeline).not.toHaveBeenCalled(); + expect(bindingMocks.readOrReconcileSessionBinding).not.toHaveBeenCalled(); + expect(bindingMocks.mutateLegacySessionBindingSafely).not.toHaveBeenCalled(); + }); + it("迟到 cleanup 仅删除仍绑定到预期 provider 的 session", async () => { const { SessionManager } = await import("@/lib/session-manager"); diff --git a/tests/unit/lib/session-request-locator.test.ts b/tests/unit/lib/session-request-locator.test.ts index 2f0c63282..1109ccc62 100644 --- a/tests/unit/lib/session-request-locator.test.ts +++ b/tests/unit/lib/session-request-locator.test.ts @@ -42,25 +42,15 @@ describe("resolveSessionRequestLocator", () => { }); it("resolves a prefix request by stable request id without the legacy source tuple", async () => { - findSessionRequestLocatorMock - .mockResolvedValueOnce({ - requestId: 108, - sourceSessionId: "physical-latest", - requestSequence: 8, - keyId: 23, - identityKind: "prefix_affinity", - scopeTag: "scope", - fingerprint: "fingerprint", - }) - .mockResolvedValueOnce({ - requestId: 107, - sourceSessionId: "physical-selected", - requestSequence: 7, - keyId: 23, - identityKind: "prefix_affinity", - scopeTag: "scope", - fingerprint: "fingerprint", - }); + findSessionRequestLocatorMock.mockResolvedValueOnce({ + requestId: 107, + sourceSessionId: "physical-selected", + requestSequence: 7, + keyId: 23, + identityKind: "prefix_affinity", + scopeTag: "scope", + fingerprint: "fingerprint", + }); const { resolveSessionRequestLocator } = await import("@/lib/session-request-locator"); await expect( @@ -85,38 +75,22 @@ describe("resolveSessionRequestLocator", () => { ); }); - it("forwards the resolved owner scope to both locator queries", async () => { - findSessionRequestLocatorMock - .mockResolvedValueOnce({ - requestId: 108, - sourceSessionId: "physical-latest", - requestSequence: 8, - keyId: 23, - identityKind: "prefix_affinity", - scopeTag: "scope", - fingerprint: "fingerprint", - }) - .mockResolvedValueOnce({ - requestId: 107, - sourceSessionId: "physical-selected", - requestSequence: 7, - keyId: 23, - identityKind: "prefix_affinity", - scopeTag: "scope", - fingerprint: "fingerprint", - }); + it("forwards the resolved owner scope to the exact request-id query", async () => { + findSessionRequestLocatorMock.mockResolvedValueOnce({ + requestId: 107, + sourceSessionId: "physical-selected", + requestSequence: 7, + keyId: 23, + identityKind: "prefix_affinity", + scopeTag: "scope", + fingerprint: "fingerprint", + }); const { resolveSessionRequestLocator } = await import("@/lib/session-request-locator"); await resolveSessionRequestLocator("pfx:scope:fingerprint", undefined, undefined, 107, 23); - expect(findSessionRequestLocatorMock).toHaveBeenNthCalledWith( - 1, - "pfx:scope:fingerprint", - {}, - 23 - ); - expect(findSessionRequestLocatorMock).toHaveBeenNthCalledWith( - 2, + expect(findSessionRequestLocatorMock).toHaveBeenCalledOnce(); + expect(findSessionRequestLocatorMock).toHaveBeenCalledWith( "pfx:scope:fingerprint", { requestId: 107, @@ -128,17 +102,7 @@ describe("resolveSessionRequestLocator", () => { }); it("returns the source-mismatch code when the selected physical request is outside the identity", async () => { - findSessionRequestLocatorMock - .mockResolvedValueOnce({ - requestId: 108, - sourceSessionId: "physical-latest", - requestSequence: 8, - keyId: 23, - identityKind: "prefix_affinity", - scopeTag: "scope", - fingerprint: "fingerprint", - }) - .mockResolvedValueOnce(null); + findSessionRequestLocatorMock.mockResolvedValueOnce(null); const { resolveSessionRequestLocator } = await import("@/lib/session-request-locator"); await expect( diff --git a/tests/unit/proxy/response-handler-lease-decrement.test.ts b/tests/unit/proxy/response-handler-lease-decrement.test.ts index 83f0de8c3..0f4716200 100644 --- a/tests/unit/proxy/response-handler-lease-decrement.test.ts +++ b/tests/unit/proxy/response-handler-lease-decrement.test.ts @@ -648,7 +648,8 @@ describe("Lease Budget Decrement after trackCostToRedis", () => { body: expect.stringContaining('"type":"message"'), meta: expect.objectContaining({ statusCode: 200 }), }), - session.requestSequence + session.requestSequence, + 456 ); expect(updateMessageRequestDetailsDurably).toHaveBeenCalledWith( messageId, diff --git a/tests/unit/proxy/warmup-guard.test.ts b/tests/unit/proxy/warmup-guard.test.ts index 66ff8361e..a42df8ef6 100644 --- a/tests/unit/proxy/warmup-guard.test.ts +++ b/tests/unit/proxy/warmup-guard.test.ts @@ -142,11 +142,17 @@ describe("ProxyWarmupGuard.ensure", () => { ); expect(storeSessionResponseMock).toHaveBeenCalledTimes(1); - expect(storeSessionResponseMock).toHaveBeenCalledWith("session_test", expect.any(String), 2); + expect(storeSessionResponseMock).toHaveBeenCalledWith( + "session_test", + expect.any(String), + 2, + 456 + ); expect(storeSessionResponseHeadersMock).toHaveBeenCalledWith( "session_test", expect.any(Headers), - 2 + 2, + 456 ); expect(storeSessionUpstreamRequestMetaMock).toHaveBeenCalledWith( "session_test", @@ -156,7 +162,8 @@ describe("ProxyWarmupGuard.ensure", () => { expect(storeSessionUpstreamResponseMetaMock).toHaveBeenCalledWith( "session_test", { url: "/__cch__/warmup", statusCode: 200 }, - 2 + 2, + 456 ); expect(dbInsertMock).toHaveBeenCalledTimes(1); diff --git a/tests/unit/repository/message-origin-chain.test.ts b/tests/unit/repository/message-origin-chain.test.ts index be38e7628..c2e0617a7 100644 --- a/tests/unit/repository/message-origin-chain.test.ts +++ b/tests/unit/repository/message-origin-chain.test.ts @@ -41,6 +41,7 @@ function sqlToString(sqlObj: unknown): string { function createThenableQuery( result: T, opts?: { + innerJoinArgs?: unknown[]; whereArgs?: unknown[]; orderByArgs?: unknown[]; limitArgs?: unknown[]; @@ -49,6 +50,10 @@ function createThenableQuery( const query: any = Promise.resolve(result); query.from = vi.fn(() => query); + query.innerJoin = vi.fn((...args: unknown[]) => { + opts?.innerJoinArgs?.push(args); + return query; + }); query.where = vi.fn((arg: unknown) => { opts?.whereArgs?.push(arg); return query; @@ -66,6 +71,55 @@ function createThenableQuery( } describe("repository/message findSessionOriginChain", () => { + test("resolves the nearest origin chain within the selected request owner and key", async () => { + vi.resetModules(); + + const originWhereArgs: unknown[] = []; + const originJoinArgs: unknown[] = []; + const originOrderByArgs: unknown[] = []; + const chain: ProviderChainItem[] = [ + { + id: 401, + name: "provider-selected-origin", + reason: "initial_selection", + }, + ]; + + const selectMock = vi.fn(() => + createThenableQuery([{ providerChain: chain }], { + innerJoinArgs: originJoinArgs, + whereArgs: originWhereArgs, + orderByArgs: originOrderByArgs, + }) + ); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { findSessionOriginChain } = await import("@/repository/message"); + const result = await findSessionOriginChain(777, 55, 9); + + expect(result).toEqual(chain); + expect(selectMock).toHaveBeenCalledTimes(1); + expect(originWhereArgs).toHaveLength(1); + + const originWhereSql = sqlToString([originJoinArgs, originWhereArgs[0]]).toLowerCase(); + expect(originWhereSql).toContain("session_id"); + expect(originWhereSql).toContain("user_id"); + expect(originWhereSql).toContain("created_at"); + expect(originWhereSql).toContain("initial_selection"); + + const originOrderSql = sqlToString(originOrderByArgs[0]).toLowerCase(); + expect(originOrderSql).toContain("case"); + expect(originOrderSql).toContain("created_at"); + expect(originOrderSql).toContain("id"); + expect(originOrderSql).toContain("desc"); + }); + test("happy path: 返回 session 首条非 warmup 的完整 providerChain", async () => { vi.resetModules(); @@ -95,7 +149,7 @@ describe("repository/message findSessionOriginChain", () => { })); const { findSessionOriginChain } = await import("@/repository/message"); - const result = await findSessionOriginChain("session-happy"); + const result = await findSessionOriginChain(101, 7, 9); expect(result).toEqual(chain); expect(whereArgs.length).toBeGreaterThan(0); @@ -107,8 +161,9 @@ describe("repository/message findSessionOriginChain", () => { expect(orderByArgs.length).toBeGreaterThan(0); const orderSql = sqlToString(orderByArgs[0]).toLowerCase(); - expect(orderSql).toContain("request_sequence"); - expect(orderSql).toContain("asc"); + expect(orderSql).toContain("created_at"); + expect(orderSql).toContain("id"); + expect(orderSql).toContain("desc"); expect(limitArgs).toEqual([1]); }); @@ -136,7 +191,7 @@ describe("repository/message findSessionOriginChain", () => { })); const { findSessionOriginChain } = await import("@/repository/message"); - const result = await findSessionOriginChain("session-warmup-first"); + const result = await findSessionOriginChain(202, 7, 9); expect(result).toEqual(chain); }); @@ -154,7 +209,7 @@ describe("repository/message findSessionOriginChain", () => { })); const { findSessionOriginChain } = await import("@/repository/message"); - const result = await findSessionOriginChain("session-not-found"); + const result = await findSessionOriginChain(303, 7, 9); expect(result).toBeNull(); }); @@ -172,7 +227,7 @@ describe("repository/message findSessionOriginChain", () => { })); const { findSessionOriginChain } = await import("@/repository/message"); - const result = await findSessionOriginChain("session-all-warmup"); + const result = await findSessionOriginChain(404, 7, 9); expect(result).toBeNull(); }); @@ -190,7 +245,7 @@ describe("repository/message findSessionOriginChain", () => { })); const { findSessionOriginChain } = await import("@/repository/message"); - const result = await findSessionOriginChain("session-null-provider-chain"); + const result = await findSessionOriginChain(505, 7, 9); expect(result).toBeNull(); }); @@ -208,7 +263,7 @@ describe("repository/message findSessionOriginChain", () => { })); const { findSessionOriginChain } = await import("@/repository/message"); - const result = await findSessionOriginChain("session-all-reuse"); + const result = await findSessionOriginChain(606, 7, 9); expect(result).toBeNull(); }); @@ -238,7 +293,7 @@ describe("repository/message findSessionOriginChain", () => { })); const { findSessionOriginChain } = await import("@/repository/message"); - await findSessionOriginChain("session-jsonb-filter"); + await findSessionOriginChain(707, 7, 9); expect(whereArgs.length).toBeGreaterThan(0); const whereSql = sqlToString(whereArgs[0]).toLowerCase(); diff --git a/tests/unit/repository/message-session-readback.test.ts b/tests/unit/repository/message-session-readback.test.ts index d290852bb..70e26ec51 100644 --- a/tests/unit/repository/message-session-readback.test.ts +++ b/tests/unit/repository/message-session-readback.test.ts @@ -33,9 +33,13 @@ function createLimitSelect(responses: readonly (readonly unknown[])[]) { whereConditions.push(condition); return { limit, orderBy }; }); + const innerJoin = vi.fn((_table: unknown, _condition: unknown) => { + events.push("innerJoin"); + return { innerJoin, where }; + }); const from = vi.fn((_table: unknown) => { events.push("from"); - return { where }; + return { innerJoin, where }; }); return { from }; }); @@ -178,7 +182,7 @@ describe("message session readback", () => { expect(boundary.select).toHaveBeenCalledTimes(1); }); - it("returns the first initial-selection provider chain by request sequence", async () => { + it("returns the nearest initial-selection provider chain for the selected request", async () => { vi.resetModules(); const providerChain = [ { @@ -191,10 +195,17 @@ describe("message session readback", () => { const boundary = installLimitBoundaries([[{ providerChain }]]); const { findSessionOriginChain } = await import("@/repository/message"); - const result = await findSessionOriginChain("session-readback"); + const result = await findSessionOriginChain(1_101, 1, 41); expect(result).toEqual(providerChain); - expect(boundary.events).toEqual(["from", "where", "orderBy", "limit"]); + expect(boundary.events).toEqual([ + "from", + "innerJoin", + "innerJoin", + "where", + "orderBy", + "limit", + ]); }); it("returns paged requests in repository order with the legacy sequence fallback", async () => { diff --git a/tests/unit/repository/message-session-request-query.test.ts b/tests/unit/repository/message-session-request-query.test.ts index cbea06c08..2ec10386e 100644 --- a/tests/unit/repository/message-session-request-query.test.ts +++ b/tests/unit/repository/message-session-request-query.test.ts @@ -241,9 +241,11 @@ describe("message repository session request queries", () => { const locator = createDrizzleQuery([ { requestId: 205, + canonicalSessionId: "pfx:scope:fingerprint", sourceSessionId: "physical-a", requestSequence: 2, keyId: 17, + userId: 9, identityKind: "prefix_affinity", scopeTag: "scope", fingerprint: "fingerprint", @@ -259,13 +261,15 @@ describe("message repository session request queries", () => { expect(where.match(/pfx:scope:fingerprint/g)).toHaveLength(1); }); - test("uses an exact canonical identity after a physical Session id is resolved", async () => { + test("accepts a physical Session id for an exact request locator", async () => { const locator = createDrizzleQuery([ { requestId: 204, + canonicalSessionId: "pfx:scope:fingerprint", sourceSessionId: "physical-a", requestSequence: 1, keyId: 17, + userId: 9, identityKind: "prefix_affinity", scopeTag: "scope", fingerprint: "fingerprint", @@ -275,9 +279,11 @@ describe("message repository session request queries", () => { await expect(findSessionRequestLocator("physical-a", { requestId: 204 })).resolves.toEqual({ requestId: 204, + canonicalSessionId: "pfx:scope:fingerprint", sourceSessionId: "physical-a", requestSequence: 1, keyId: 17, + userId: 9, identityKind: "prefix_affinity", scopeTag: "scope", fingerprint: "fingerprint", @@ -287,7 +293,7 @@ describe("message repository session request queries", () => { expect(where).toContain("physical-a"); expect(where).toContain("session_identity"); expect(where).toContain("session_id"); - expect(where.match(/physical-a/g)).toHaveLength(1); + expect(where.match(/physical-a/g)).toHaveLength(2); }); test("filters requests without a stable selector from navigable request lists", async () => { @@ -319,9 +325,11 @@ describe("message repository session request queries", () => { const locator = createDrizzleQuery([ { requestId: 203, + canonicalSessionId: "pfx:scope:fingerprint", sourceSessionId: "physical-a", requestSequence: 3, keyId: 17, + userId: 9, identityKind: "prefix_affinity", scopeTag: "scope", fingerprint: "fingerprint", @@ -337,9 +345,11 @@ describe("message repository session request queries", () => { }) ).resolves.toEqual({ requestId: 203, + canonicalSessionId: "pfx:scope:fingerprint", sourceSessionId: "physical-a", requestSequence: 3, keyId: 17, + userId: 9, identityKind: "prefix_affinity", scopeTag: "scope", fingerprint: "fingerprint", From 49dc670064081e6a88d3c5165451e81b00255f34 Mon Sep 17 00:00:00 2001 From: ding113 Date: Sun, 2 Aug 2026 20:31:14 +0800 Subject: [PATCH 4/5] test(cache): align session detail fixtures with identity metadata Add sessionIdentityKind and sessionFingerprint to each setSessionDetailsCache fixture so the cached session details satisfy the canonical identity contract enforced by the cache layer. --- tests/unit/lib/cache/session-cache.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/unit/lib/cache/session-cache.test.ts b/tests/unit/lib/cache/session-cache.test.ts index 6e2f6ca30..41689da03 100644 --- a/tests/unit/lib/cache/session-cache.test.ts +++ b/tests/unit/lib/cache/session-cache.test.ts @@ -159,6 +159,8 @@ describe("SessionCache(Session 数据缓存层)", () => { setActiveSessionsCache([], "all_sessions"); setSessionDetailsCache("s_1", { sessionId: "s_1", + sessionIdentityKind: "session_id", + sessionFingerprint: null, requestCount: 1, totalCostUsd: "0", totalInputTokens: 0, @@ -193,6 +195,8 @@ describe("SessionCache(Session 数据缓存层)", () => { setActiveSessionsCache([], "active_sessions"); setSessionDetailsCache("s_2", { sessionId: "s_2", + sessionIdentityKind: "session_id", + sessionFingerprint: null, requestCount: 1, totalCostUsd: "0", totalInputTokens: 0, @@ -223,6 +227,8 @@ describe("SessionCache(Session 数据缓存层)", () => { await loadSessionCache(); const details = { sessionId: "pfx:scope:tip", + sessionIdentityKind: "prefix_affinity" as const, + sessionFingerprint: "tip", requestCount: 1, totalCostUsd: "0", totalInputTokens: 0, @@ -258,6 +264,8 @@ describe("SessionCache(Session 数据缓存层)", () => { const { getSessionDetailsCache, setSessionDetailsCache } = await loadSessionCache(); const details = { sessionId: "shared-session", + sessionIdentityKind: "session_id" as const, + sessionFingerprint: null, requestCount: 1, totalCostUsd: "0", totalInputTokens: 0, From a7fecc9537286b3510d27c443463d69cfb703233 Mon Sep 17 00:00:00 2001 From: ding113 Date: Sun, 2 Aug 2026 20:41:44 +0800 Subject: [PATCH 5/5] fix(message): match legacy null-identity sessions for owner-scoped reserved lookups Reserved session identities with an ownerUserId now also match message_request rows whose session_identity is null but whose session_id equals the queried identity. This supports legacy physical session ids that use reserved prefixes. The fallback is only added when ownerUserId is present so that unscoped canonical lookups remain narrow and do not pick up unrelated null-identity rows. --- src/repository/message.ts | 7 ++++- .../message-session-request-query.test.ts | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/repository/message.ts b/src/repository/message.ts index 3664efc44..f6a5d6b9b 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -77,7 +77,12 @@ function messageSessionLookup(identityOrPhysicalId: string, ownerUserId?: number function messageCanonicalSessionLookup(identity: string, ownerUserId?: number) { const canonicalCondition = isReservedSessionIdentity(identity) - ? eq(messageRequest.sessionIdentity, identity) + ? ownerUserId !== undefined + ? or( + eq(messageRequest.sessionIdentity, identity), + and(isNull(messageRequest.sessionIdentity), eq(messageRequest.sessionId, identity)) + ) + : eq(messageRequest.sessionIdentity, identity) : eq(messageSessionIdentity, identity); return and( diff --git a/tests/unit/repository/message-session-request-query.test.ts b/tests/unit/repository/message-session-request-query.test.ts index 2ec10386e..4de3c23aa 100644 --- a/tests/unit/repository/message-session-request-query.test.ts +++ b/tests/unit/repository/message-session-request-query.test.ts @@ -237,6 +237,34 @@ describe("message repository session request queries", () => { expect(rowsWhere.match(/shared-session/g)).toHaveLength(1); }); + test("includes a legacy null-identity physical fallback for owner-scoped reserved identities", async () => { + const count = createDrizzleQuery([{ count: 1 }]); + const rows = createDrizzleQuery([]); + boundary.select.mockReturnValueOnce(count).mockReturnValueOnce(rows); + + await findRequestsBySessionIdentity("pfx:legacy-client", { ownerUserId: 17 } as never); + + for (const where of [sqlText(count.trace.where), sqlText(rows.trace.where)]) { + expect(where).toContain("user_id"); + expect(where).toContain("is null"); + expect(where).toContain("session_id"); + expect(where.match(/pfx:legacy-client/g)).toHaveLength(2); + } + }); + + test("does not add the legacy physical fallback for unscoped reserved identities", async () => { + const count = createDrizzleQuery([{ count: 1 }]); + const rows = createDrizzleQuery([]); + boundary.select.mockReturnValueOnce(count).mockReturnValueOnce(rows); + + await findRequestsBySessionIdentity("pfx:canonical", {} as never); + + for (const where of [sqlText(count.trace.where), sqlText(rows.trace.where)]) { + expect(where).not.toContain("session_identity is null"); + expect(where.match(/pfx:canonical/g)).toHaveLength(1); + } + }); + test("does not treat a reserved canonical identity as a physical Session alias", async () => { const locator = createDrizzleQuery([ {