diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index da56878e2..02fffca1e 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." }, @@ -934,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 13b6a99d4..2ec011c52 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 を設定してください。" }, @@ -934,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 a5eb3f2dc..14a281b3f 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." }, @@ -934,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 7da08116f..ea3d3885c 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。" }, @@ -934,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 35ffc49a3..3f338fc5c 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。" }, @@ -934,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 215d974d9..e46e3e5e7 100644 --- a/src/actions/active-sessions.ts +++ b/src/actions/active-sessions.ts @@ -27,6 +27,19 @@ type ResolvedSessionIdentity = NonNullable< Awaited> >; +type CanonicalSessionStats = Awaited< + ReturnType +>[number]; + +async function loadCanonicalSessionStats( + sessionId: string, + ownerUserId?: number +): Promise { + const { aggregateMultipleSessionStats } = await import("@/repository/message"); + const [sessionStats] = await aggregateMultipleSessionStats([sessionId], ownerUserId); + return sessionStats ?? null; +} + type SessionTerminationDependencies = { SessionManager: typeof import("@/lib/session-manager").SessionManager; SessionTracker: typeof import("@/lib/session-tracker").SessionTracker; @@ -54,8 +67,9 @@ async function loadSessionTerminationDependencies(): Promise { +): Promise<{ terminated: boolean; sourceSessionIds: string[] }> { const { SessionManager, SessionTracker, @@ -68,13 +82,24 @@ 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 + ) + ) ); + const terminated = outcomes.some(Boolean); if (terminated) { await SessionTracker.terminateObservedSession(identity); } - return terminated; + return { + terminated, + sourceSessionIds: physicalSources.map((source) => source.sessionId), + }; } const invalidated = await getAffinityStore().invalidate( @@ -82,9 +107,9 @@ 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); + const physicalSources = await listPhysicalSessionSourcesForIdentity(identity, ownerUserId); for (const source of physicalSources) { if ( @@ -102,7 +127,10 @@ async function terminateResolvedSessionIdentity( } await SessionTracker.terminateObservedSession(identity); - return true; + return { + terminated: true, + sourceSessionIds: physicalSources.map((source) => source.sessionId), + }; } function normalizeRequestSnapshot( @@ -647,8 +675,10 @@ 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, + isAdmin ? undefined : currentUserId + ); if (!sessionStats) { return { @@ -670,13 +700,24 @@ export async function getSessionMessages( // 3. 获取 messages const locatorResult = await resolveSessionRequestLocator( - sessionId, + 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 @@ -712,7 +753,8 @@ export async function getSessionMessages( export async function hasSessionMessages( sessionId: string, requestSequence?: number, - requestedSourceSessionId?: string + requestedSourceSessionId?: string, + requestId?: number ): Promise> { try { // 验证用户权限 @@ -727,9 +769,29 @@ export async function hasSessionMessages( const isAdmin = authSession.user.role === "admin"; const currentUserId = authSession.user.id; - // 检查 Session 所有权(需要从数据库获取 userId) - const { aggregateSessionStats } = await import("@/repository/message"); - const sessionStats = await aggregateSessionStats(sessionId); + 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 { @@ -749,18 +811,29 @@ export async function hasSessionMessages( }; } - const locatorResult = await resolveSessionRequestLocator( - sessionId, + locatorResult ??= await resolveSessionRequestLocator( + sessionStats.sessionId, requestSequence, - requestedSourceSessionId + requestedSourceSessionId, + undefined, + 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 (normalizeRequestSequence(requestSequence) !== null) { + if (requestId !== undefined || normalizeRequestSequence(requestSequence) !== null) { const messages = await SessionManager.getSessionMessages( sourceSessionId, locatorResult.locator.requestSequence @@ -814,9 +887,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; @@ -838,27 +910,47 @@ export async function getSessionDetails( const isAdmin = authSession.user.role === "admin"; const currentUserId = authSession.user.id; - // 1. 尝试从缓存获取统计数据 - const cachedStats = getSessionDetailsCache(sessionId); + let locatorResult: Awaited> | null = null; + let sessionStats: CanonicalSessionStats | null; - let sessionStats: Awaited< - ReturnType - > | null; + if (requestId !== undefined) { + locatorResult = await resolveSessionRequestLocator( + sessionId, + requestSequence, + requestedSourceSessionId, + requestId, + isAdmin ? undefined : currentUserId + ); + if (!locatorResult.ok) return locatorResult; - if (cachedStats) { - logger.debug(`[SessionCache] Session details cache hit: ${sessionId}`); - sessionStats = cachedStats; + sessionStats = await loadCanonicalSessionStats( + locatorResult.locator.canonicalSessionId, + locatorResult.locator.userId + ); } else { - // 2. 从数据库查询 - const { aggregateSessionStats } = await import("@/repository/message"); - sessionStats = await aggregateSessionStats(sessionId); + // 1. 尝试从缓存获取统计数据 + const cachedStats = isAdmin ? null : getSessionDetailsCache(sessionId, currentUserId); - // 3. 写入缓存 - if (sessionStats) { - setSessionDetailsCache(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. 权限检查:管理员可查看所有,普通用户只能查看自己的 @@ -879,27 +971,35 @@ export async function getSessionDetails( }; } - const locatorResult = await resolveSessionRequestLocator( - sessionId, + const canonicalSessionId = sessionStats.sessionId; + locatorResult ??= await resolveSessionRequestLocator( + canonicalSessionId, requestSequence, requestedSourceSessionId, - requestId + undefined, + sessionStats.userId ); if (!locatorResult.ok) return locatorResult; 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"); - const { findAdjacentSessionRequests, findMessageRequestAuditBySessionIdAndSequence } = - await import("@/repository/message"); + const { findAdjacentSessionRequests, findMessageRequestAuditById } = await import( + "@/repository/message" + ); const adjacent = effectiveSequence == null ? { prevRequest: null, nextRequest: null } - : await findAdjacentSessionRequests(sessionId, effectiveRequestId); + : await findAdjacentSessionRequests( + canonicalSessionId, + effectiveRequestId, + requestOwnerUserId + ); const parseJsonStringOrNull = (value: unknown): unknown => { if (typeof value !== "string") return value; @@ -923,6 +1023,12 @@ export async function getSessionDetails( } }; + const redisArtifactsOwned = await SessionManager.isSessionRequestOwnedByKey( + sourceSessionId, + effectiveSequence, + locatorResult.locator.keyId + ); + // 6. 并行获取 messages、requestBody 和 response(不缓存,因为这些数据较大) const [ requestBody, @@ -940,22 +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), - effectiveSequence - ? findMessageRequestAuditBySessionIdAndSequence(sourceSessionId, effectiveSequence) - : Promise.resolve(null), - 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 字符串(前端需要根级对象/数组) @@ -1049,6 +1191,7 @@ export async function getSessionDetails( snapshots: effectiveSnapshots, specialSettings: unifiedSpecialSettings, sessionStats, + canonicalSessionId, currentSourceSessionId: sourceSessionId, currentSequence: effectiveSequence ?? null, prevRequest: adjacent.prevRequest, @@ -1075,19 +1218,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 +1258,10 @@ 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, + isAdmin ? undefined : currentUserId + ); if (!sessionStats) { return { @@ -1137,10 +1283,11 @@ 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, + ownerUserId: sessionStats.userId, }); return { @@ -1186,7 +1333,10 @@ export async function terminateActiveSession(sessionId: string): Promise(); const terminationChunkSize = 20; const terminationDependencies = await loadSessionTerminationDependencies(); + const ownerByCanonicalId = new Map( + sessionsData.map((session) => [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 { terminated: false, sourceSessionIds: [] }; + } + const resolution = await resolveSessionIdentity(identity, ownerUserId); + return terminateResolvedSessionIdentity( + identity, + resolution, + ownerUserId, + terminationDependencies + ); }) ); 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], @@ -1399,8 +1578,19 @@ 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 sourceSessionId of terminatedSourceSessionIds) { + sessionDetailCacheIds.add(sourceSessionId); + } + 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/actions/session-origin-chain.ts b/src/actions/session-origin-chain.ts index b95c1d301..0afe1c7c8 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,19 @@ 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.requestId, + locatorResult.locator.keyId, + 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 feecf2ed5..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 @@ -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", @@ -419,6 +422,15 @@ function renderClientWithIntl(node: ReactNode) { return { container, + rerender: (nextNode: ReactNode) => { + act(() => { + root.render( + + {nextNode} + + ); + }); + }, unmount: () => { act(() => root.unmount()); container.remove(); @@ -447,6 +459,7 @@ describe("error-details-dialog layout", () => { sourceSessionId="physical-a" sessionIdentityKind="prefix_affinity" requestSequence={3} + requestId={203} /> ); @@ -454,7 +467,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 +483,7 @@ describe("error-details-dialog layout", () => { sourceSessionId="physical-a" sessionIdentityKind="prefix_affinity" requestSequence={3} + requestId={203} /> ); @@ -482,6 +496,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(); }); @@ -606,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( 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) + setHasMessages(false); + 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); + } else { + setHasMessages(false); } }) .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); } }); } else { + ++messageCheckRequestIdRef.current; 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 +257,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..16bd4ef22 --- /dev/null +++ b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/request-list-sidebar.test.tsx @@ -0,0 +1,155 @@ +/** + * @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 "@/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/request-list-sidebar"; + +const getSessionRequestsMock = vi.fn(); +const { translateMock } = vi.hoisted(() => ({ + 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", () => ({ + 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()); + }); + + 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 2e2233ae0..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 @@ -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,12 @@ export function RequestListSidebar({ ? "bg-primary text-primary-foreground shadow-sm" : "hover:bg-muted" )} - title={`#${req.sequence} - ${req.model || "Unknown"}`} + title={t("requestList.itemTitle", { + sequence: req.displaySequence, + model: req.model ?? t("requestList.unknownModel"), + })} > - {req.sequence} + {req.displaySequence} {/* Tiny status dot */} - #{request.sequence} + #{request.displaySequence} - {request.model || "Unknown Model"} + {request.model || t("requestList.unknownModel")}
diff --git a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client-actions.test.tsx b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client-actions.test.tsx index c4acd3055..518992188 100644 --- a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client-actions.test.tsx +++ b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client-actions.test.tsx @@ -181,6 +181,7 @@ function buildDetailsData( snapshots: SessionDetailSnapshots | null; sessionStats: unknown | null; currentSourceSessionId: string | null; + canonicalSessionId: string; currentSequence: number | null; prevRequest: { requestId: number; sourceSessionId: string; requestSequence: number } | null; nextRequest: { requestId: number; sourceSessionId: string; requestSequence: number } | null; @@ -203,6 +204,7 @@ function buildDetailsData( snapshots: createSnapshots(), specialSettings: null, sessionStats: null, + canonicalSessionId: "pfx:scope:canonical", currentSourceSessionId: "physical-current", currentSequence: 7, prevRequest: null, diff --git a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx index 9ee35839c..6a0d96638 100644 --- a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx +++ b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx @@ -93,6 +93,8 @@ export function SessionMessagesClient() { useState< Extract>, { 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/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/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/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/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..483eb22c6 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; @@ -93,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 { @@ -110,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() { @@ -133,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); + } } /** @@ -142,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) { @@ -152,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..a35ae9cd1 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,8 @@ 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([]), - })), + eval: vi.fn().mockResolvedValue(1), + pipeline: vi.fn(() => redisPipeline), }; vi.mock("@/lib/redis", () => ({ @@ -66,10 +73,88 @@ describe("SessionManager detail snapshots", () => { beforeEach(() => { vi.clearAllMocks(); redisStore.clear(); + redisMock.status = "ready"; mockStoreMessages = false; mockStoreSessionResponseBody = true; }); + 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.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 () => { + 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("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 03b2ae725..4128f8e90 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:使用时间戳 + 随机数生成伪唯一序号 @@ -378,11 +378,23 @@ 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 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", { @@ -402,6 +414,42 @@ 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; + } + } + + 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 当前的请求计数 * @@ -1914,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; } // 向后兼容:尝试旧格式 @@ -2002,7 +2050,8 @@ export class SessionManager { static async storeSessionResponse( sessionId: string, response: string | object, - requestSequence?: number + requestSequence?: number, + keyId?: number ): Promise { // 允许通过环境变量显式关闭响应体存储(例如隐私/节省 Redis 内存)。 // 注意:这里仅关闭“写入 Redis”这一步;调用方仍然可能在内存中读取响应体用于统计或错误检测。 @@ -2035,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, @@ -2287,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), @@ -2356,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); @@ -2434,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; } // 向后兼容:尝试旧格式 @@ -2595,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) { @@ -2930,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 f216c7287..f885bc680 100644 --- a/src/lib/session-request-locator.ts +++ b/src/lib/session-request-locator.ts @@ -14,10 +14,31 @@ 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); + + 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) { return { @@ -41,12 +62,15 @@ export async function resolveSessionRequestLocator( } const locator = - normalizedSequence !== null || sourceSessionId || requestId !== undefined - ? await findSessionRequestLocator(identity, { - requestId, - requestSequence: normalizedSequence ?? undefined, - sourceSessionId, - }) + normalizedSequence !== null || sourceSessionId + ? await findSessionRequestLocator( + identity, + { + requestSequence: normalizedSequence ?? undefined, + sourceSessionId, + }, + ownerUserId + ) : identityLocator; return locator diff --git a/src/repository/message.ts b/src/repository/message.ts index 448555221..f6a5d6b9b 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -1,6 +1,7 @@ "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, 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"; @@ -18,6 +19,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 +37,57 @@ 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) + ? ownerUserId !== undefined + ? or( + eq(messageRequest.sessionIdentity, identity), + and(isNull(messageRequest.sessionIdentity), eq(messageRequest.sessionId, identity)) + ) + : eq(messageRequest.sessionIdentity, identity) + : eq(messageSessionIdentity, identity); + + return and( + canonicalCondition, + ownerUserId !== undefined ? eq(messageRequest.userId, ownerUserId) : undefined ); } @@ -1171,27 +1213,62 @@ export async function findMessageRequestBySessionId( } /** - * 根据 sessionId 查询该 session 首条非 warmup 请求的 providerChain - * 用于展示会话来源链(原始选择决策) + * 查询选中请求所属 key epoch 内最近且不晚于该请求的初始 providerChain。 */ export async function findSessionOriginChain( - sessionId: string + 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), + 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; @@ -1201,9 +1278,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 +1303,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 +1331,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 +1369,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 +1393,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 +1405,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 +1417,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 +1445,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 +1480,11 @@ 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; scopeTag: string | null; @@ -1409,6 +1493,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 +1501,9 @@ export async function resolveSessionIdentity(identity: string): Promise<{ fingerprintChain: messageRequest.affinityFingerprintChain, }) .from(messageRequest) - .where(and(eq(messageSessionIdentity, identity), isNull(messageRequest.deletedAt))) + .where( + and(messageCanonicalSessionLookup(identity, ownerUserId), isNull(messageRequest.deletedAt)) + ) .orderBy(desc(messageRequest.createdAt)); if (rows.length === 0) return null; @@ -1436,6 +1523,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, @@ -1453,7 +1541,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({ @@ -1479,7 +1568,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), @@ -1487,6 +1576,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 @@ -1537,14 +1628,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( - eq(messageSessionIdentity, identity), + messageCanonicalSessionLookup(identity, ownerUserId), eq(messageRequest.sessionId, sourceSessionId), isNull(messageRequest.deletedAt) ) @@ -1556,11 +1648,15 @@ 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; + canonicalSessionId: string; sourceSessionId: string; requestSequence: number; + keyId: number; + userId: number; identityKind: "session_id" | "prefix_affinity"; scopeTag: string | null; fingerprint: string | null; @@ -1568,16 +1664,22 @@ 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, }) .from(messageRequest) + .innerJoin(keysTable, eq(messageRequest.key, keysTable.key)) .where( and( - eq(messageSessionIdentity, identity), + selector.requestId !== undefined + ? messageSessionLookup(identity, ownerUserId) + : messageCanonicalSessionLookup(identity, ownerUserId), isNotNull(messageRequest.sessionId), isNotNull(messageRequest.requestSequence), selector.requestId !== undefined ? eq(messageRequest.id, selector.requestId) : undefined, @@ -1593,12 +1695,24 @@ 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.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, @@ -1613,7 +1727,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[]; @@ -1648,6 +1765,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, @@ -1677,10 +1795,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 @@ -1751,6 +1873,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({ @@ -1766,7 +1897,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 @@ -1783,7 +1914,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` ) @@ -1812,11 +1943,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 @@ -1840,7 +1967,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` ) @@ -2098,6 +2225,7 @@ export async function findRequestsBySessionId( id: number; sourceSessionId: string; sequence: number; + displaySequence: number; model: string | null; statusCode: number | null; costUsd: string | null; @@ -2114,7 +2242,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; @@ -2124,6 +2258,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, @@ -2133,7 +2271,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) ) @@ -2145,6 +2289,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, @@ -2159,12 +2304,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 = "asc" } = options || {}; + const { limit = 20, offset = 0, order = "desc", ownerUserId } = options || {}; const where = and( - eq(messageSessionIdentity, identity), + messageCanonicalSessionLookup(identity, ownerUserId), isNotNull(messageRequest.sessionId), + isNotNull(messageRequest.requestSequence), eq(messageRequest.isReplay, false), isNull(messageRequest.deletedAt) ); @@ -2177,6 +2328,16 @@ export async function findRequestsBySessionIdentity( id: messageRequest.id, sessionId: messageRequest.sessionId, sequence: messageRequest.requestSequence, + displaySequence: sql`CASE + WHEN ${messageRequest.sessionIdentityKind} = 'prefix_affinity' + THEN row_number() OVER ( + ORDER BY ${messageRequest.createdAt} ASC, ${messageRequest.id} ASC + )::int + ELSE COALESCE( + ${messageRequest.requestSequence}, + row_number() OVER (ORDER BY ${messageRequest.createdAt} ASC, ${messageRequest.id} ASC)::int + ) + END`, model: messageRequest.model, statusCode: messageRequest.statusCode, costUsd: messageRequest.costUsd, @@ -2202,6 +2363,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, @@ -2262,7 +2424,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; @@ -2275,7 +2438,7 @@ export async function findAdjacentSessionRequests( .from(messageRequest) .where( and( - eq(messageSessionIdentity, identity), + messageCanonicalSessionLookup(identity, ownerUserId), eq(messageRequest.id, requestId), eq(messageRequest.isReplay, false), isNull(messageRequest.deletedAt) @@ -2293,7 +2456,7 @@ export async function findAdjacentSessionRequests( requestSequence: messageRequest.requestSequence, }; const timelineFilter = and( - eq(messageSessionIdentity, identity), + messageCanonicalSessionLookup(identity, ownerUserId), 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..939d240a8 100644 --- a/tests/unit/actions/active-sessions-detail-snapshots.test.ts +++ b/tests/unit/actions/active-sessions-detail-snapshots.test.ts @@ -19,13 +19,15 @@ 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 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, @@ -66,16 +68,18 @@ vi.mock("@/lib/session-manager", () => ({ getSessionSpecialSettings: getSessionSpecialSettingsMock, getSessionRequestPhaseSnapshot: getSessionRequestPhaseSnapshotMock, getSessionResponsePhaseSnapshot: getSessionResponsePhaseSnapshotMock, + isSessionRequestOwnedByKey: isSessionRequestOwnedByKeyMock, }, })); vi.mock("@/repository/message", () => ({ aggregateSessionStats: aggregateSessionStatsMock, + aggregateMultipleSessionStats: aggregateMultipleSessionStatsMock, resolveSessionIdentity: resolveSessionIdentityMock, isSessionSourceForIdentity: isSessionSourceForIdentityMock, findSessionRequestLocator: findSessionRequestLocatorMock, findAdjacentSessionRequests: findAdjacentSessionRequestsMock, - findMessageRequestAuditBySessionIdAndSequence: findMessageRequestAuditBySessionIdAndSequenceMock, + findMessageRequestAuditById: findMessageRequestAuditByIdMock, })); describe("getSessionDetails - additive detail snapshots contract", () => { @@ -107,6 +111,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( @@ -115,8 +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, @@ -124,7 +135,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); @@ -236,17 +248,63 @@ 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(findSessionRequestLocatorMock).toHaveBeenCalledWith( + "pfx:scope:fingerprint", + { + requestId: 101, + requestSequence: 1, + sourceSessionId: "physical-selected", + }, + undefined + ); expect(getSessionRequestBodyMock).toHaveBeenCalledWith("physical-selected", 1); - expect(findAdjacentSessionRequestsMock).toHaveBeenCalledWith("pfx:scope:fingerprint", 101); - expect(findMessageRequestAuditBySessionIdAndSequenceMock).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("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 () => { @@ -290,7 +348,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 () => { @@ -430,8 +488,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, @@ -515,4 +575,63 @@ 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( + "pfx:scope:root", + { + requestId: 203, + requestSequence: undefined, + sourceSessionId: "physical-a", + }, + undefined + ); + expect(getSessionMessagesMock).toHaveBeenCalledWith("physical-a", 1); + expect(hasAnySessionMessagesMock).not.toHaveBeenCalled(); + }); + + 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"); + + 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).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 () => { + 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 b63ecef2d..d06425a46 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 }); }); @@ -53,7 +61,44 @@ describe("getSessionRequests public identity contract", () => { limit: 5, offset: 5, order: "desc", + ownerUserId: 1, }); 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", + 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 30ea1a58b..4e3aff82b 100644 --- a/tests/unit/actions/active-sessions-special-settings.test.ts +++ b/tests/unit/actions/active-sessions-special-settings.test.ts @@ -17,11 +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, @@ -61,14 +63,16 @@ vi.mock("@/lib/session-manager", () => ({ getSessionSpecialSettings: getSessionSpecialSettingsMock, getSessionRequestPhaseSnapshot: getSessionRequestPhaseSnapshotMock, getSessionResponsePhaseSnapshot: getSessionResponsePhaseSnapshotMock, + isSessionRequestOwnedByKey: isSessionRequestOwnedByKeyMock, }, })); vi.mock("@/repository/message", () => ({ aggregateSessionStats: aggregateSessionStatsMock, + aggregateMultipleSessionStats: aggregateMultipleSessionStatsMock, findSessionRequestLocator: findSessionRequestLocatorMock, findAdjacentSessionRequests: findAdjacentSessionRequestsMock, - findMessageRequestAuditBySessionIdAndSequence: findMessageRequestAuditBySessionIdAndSequenceMock, + findMessageRequestAuditById: findMessageRequestAuditByIdMock, })); describe("getSessionDetails - unified specialSettings", () => { @@ -99,15 +103,24 @@ 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({ + requestId: 101, + canonicalSessionId: "sess_x", sourceSessionId: "sess_x", requestSequence: 1, + keyId: 1, + userId: 1, identityKind: "session_id", scopeTag: null, fingerprint: null, }); findAdjacentSessionRequestsMock.mockResolvedValue({ prevRequest: null, nextRequest: null }); + isSessionRequestOwnedByKeyMock.mockResolvedValue(true); getSessionRequestCountMock.mockResolvedValue(1); getSessionRequestBodyMock.mockResolvedValue(null); @@ -124,7 +137,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" }), @@ -141,6 +154,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 () => { @@ -157,7 +172,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 41e88fb10..d5a4b3fbd 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,10 +115,36 @@ 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"); + 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 () => { @@ -174,6 +204,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 +218,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,16 +284,36 @@ 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"); }); + 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"); + expect(clearSessionDetailsCacheMock).toHaveBeenCalledWith("physical-session-2"); + }); + test("batch counts affinity invalidation failures instead of observed cleanup results", async () => { aggregateMultipleSessionStatsMock.mockResolvedValue([ { sessionId: "pfx:scope:tip", userId: 1 }, @@ -306,6 +381,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 () => { @@ -341,7 +418,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..b71b2a8ca 100644 --- a/tests/unit/actions/session-origin-chain-integration.test.ts +++ b/tests/unit/actions/session-origin-chain-integration.test.ts @@ -14,16 +14,20 @@ describe("getSessionOriginChain", () => { }, ]; - const aggregateSessionStatsMock = vi.fn().mockResolvedValue({ userId: 1 }); + const aggregateMultipleSessionStatsMock = vi + .fn() + .mockResolvedValue([{ sessionId: "test-session", userId: 1 }]); const findSessionRequestLocatorMock = vi.fn().mockResolvedValue({ + requestId: 101, 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 +60,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(101, 1, 1); }); }); diff --git a/tests/unit/actions/session-origin-chain.test.ts b/tests/unit/actions/session-origin-chain.test.ts index 6b4784e51..95a8ff328 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(101, 1, 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(101, 1, 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(101, 1, 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/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..b3349d27c 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,30 @@ 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.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..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, @@ -187,12 +189,14 @@ 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"); setSessionDetailsCache("s_2", { sessionId: "s_2", + sessionIdentityKind: "session_id", + sessionFingerprint: null, requestCount: 1, totalCostUsd: "0", totalInputTokens: 0, @@ -215,7 +219,77 @@ 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", + sessionIdentityKind: "prefix_affinity" as const, + sessionFingerprint: "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", + sessionIdentityKind: "session_id" as const, + sessionFingerprint: null, + 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-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 c9492df2b..1109ccc62 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", @@ -41,23 +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, - identityKind: "prefix_affinity", - scopeTag: "scope", - fingerprint: "fingerprint", - }) - .mockResolvedValueOnce({ - requestId: 107, - sourceSessionId: "physical-selected", - requestSequence: 7, - 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( @@ -68,26 +61,48 @@ describe("resolveSessionRequestLocator", () => { requestId: 107, sourceSessionId: "physical-selected", requestSequence: 7, + keyId: 23, }, }); - expect(findSessionRequestLocatorMock).toHaveBeenLastCalledWith("pfx:scope:fingerprint", { + expect(findSessionRequestLocatorMock).toHaveBeenLastCalledWith( + "pfx:scope:fingerprint", + { + requestId: 107, + requestSequence: undefined, + sourceSessionId: undefined, + }, + undefined + ); + }); + + it("forwards the resolved owner scope to the exact request-id query", async () => { + findSessionRequestLocatorMock.mockResolvedValueOnce({ requestId: 107, - requestSequence: undefined, - sourceSessionId: undefined, + 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).toHaveBeenCalledOnce(); + expect(findSessionRequestLocatorMock).toHaveBeenCalledWith( + "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 () => { - findSessionRequestLocatorMock - .mockResolvedValueOnce({ - requestId: 108, - sourceSessionId: "physical-latest", - requestSequence: 8, - identityKind: "prefix_affinity", - scopeTag: "scope", - fingerprint: "fingerprint", - }) - .mockResolvedValueOnce(null); + findSessionRequestLocatorMock.mockResolvedValueOnce(null); const { resolveSessionRequestLocator } = await import("@/lib/session-request-locator"); await expect( @@ -96,10 +111,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/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-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-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-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-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 94d3b92c0..4de3c23aa 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"); @@ -61,7 +64,8 @@ describe("message repository session request queries", () => { { id: 31, sessionId: "session-requests", - sequence: null, + sequence: 1, + 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,24 +199,165 @@ 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"); + 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("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([ + { + requestId: 205, + canonicalSessionId: "pfx:scope:fingerprint", + sourceSessionId: "physical-a", + requestSequence: 2, + keyId: 17, + userId: 9, + 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 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", + }, + ]); + boundary.select.mockReturnValueOnce(locator); + + 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", + }); + + const where = sqlText(locator.trace.where); + expect(where).toContain("physical-a"); + expect(where).toContain("session_identity"); + expect(where).toContain("session_id"); + expect(where.match(/physical-a/g)).toHaveLength(2); + }); + + 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 () => { 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", @@ -222,8 +373,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",