import { ProjectSession, ChatMessage, StageKey } from '../types/workbench'; /** * 私聊会话 ID 约定:sess-{projectKey}-u{userId} * 同一项目下,不同用户拥有完全独立的 session,互不可见。 */ export function buildPrivateSessionId(projectKey: string, userId: number): string { return `sess-${projectKey}-u${userId}`; } export function filterSessionsForUser( sessions: ProjectSession[], userId: number ): ProjectSession[] { return sessions.filter((s) => s.userId === userId); } /** * 登录 / 切换用户后,解析应激活的私聊会话: * 1) 若当前 active 已属于该用户且该会话确有消息(或无更好候选)则保留 * 2) 同 projectId 下优先选择「有消息」的私聊会话,避免落到 ensure 出来的空壳 sess-p{N}-u{id} * 3) 否则取该用户消息最多的会话 / 第一条会话 */ export function resolveActiveSessionForUser( sessions: ProjectSession[], userId: number, preferredProjectId?: number, currentActiveId?: string, messageCounts?: Record ): string { const mine = filterSessionsForUser(sessions, userId); if (mine.length === 0) return ''; const countOf = (sessionId: string) => messageCounts?.[sessionId] ?? mine.find((s) => s.sessionId === sessionId)?.messageCount ?? 0; const rank = (a: ProjectSession, b: ProjectSession) => { const diff = countOf(b.sessionId) - countOf(a.sessionId); if (diff !== 0) return diff; // 同消息量时,优先非 ensure 生成的空壳 ID(sess-p数字-u) const aShell = /^sess-p\d+-u\d+$/.test(a.sessionId) ? 1 : 0; const bShell = /^sess-p\d+-u\d+$/.test(b.sessionId) ? 1 : 0; return aShell - bShell; }; const pickBest = (candidates: ProjectSession[]) => { if (candidates.length === 0) return ''; return [...candidates].sort(rank)[0].sessionId; }; if (currentActiveId) { const current = mine.find((s) => s.sessionId === currentActiveId); if (current && countOf(currentActiveId) > 0) { return currentActiveId; } } if (preferredProjectId != null) { const sameProject = mine.filter((s) => s.projectId === preferredProjectId); const best = pickBest(sameProject); if (best) return best; } // 当前 active 若为空壳,改选同用户最佳会话 return pickBest(mine); } /** 同一 (projectId, userId) 只保留一条:优先有消息 / 非空壳 ID 的会话 */ export function dedupePrivateSessions( sessions: ProjectSession[], messageCounts?: Record ): ProjectSession[] { const groups = new Map(); for (const s of sessions) { const key = `${s.projectId}:${s.userId}`; const list = groups.get(key) || []; list.push(s); groups.set(key, list); } const result: ProjectSession[] = []; for (const list of groups.values()) { if (list.length === 1) { result.push(list[0]); continue; } const countOf = (id: string) => messageCounts?.[id] ?? list.find((s) => s.sessionId === id)?.messageCount ?? 0; list.sort((a, b) => { const diff = countOf(b.sessionId) - countOf(a.sessionId); if (diff !== 0) return diff; const aShell = /^sess-p\d+-u\d+$/.test(a.sessionId) ? 1 : 0; const bShell = /^sess-p\d+-u\d+$/.test(b.sessionId) ? 1 : 0; return aShell - bShell; }); result.push(list[0]); } return result; } /** 确保用户对每个已知项目都有独立私聊会话(无则克隆项目元数据新建空会话) */ export function ensurePrivateSessionsForUser( sessions: ProjectSession[], userId: number ): ProjectSession[] { const byProject = new Map(); for (const s of sessions) { if (!byProject.has(s.projectId)) { byProject.set(s.projectId, s); } } const result = [...sessions]; for (const [projectId, template] of byProject) { const exists = result.some((s) => s.projectId === projectId && s.userId === userId); if (!exists) { // 优先复用已知项目短名,避免与默认 sess-cusa-u* 并存两套空壳 const projectKey = projectId === 3 ? 'cusa' : projectId === 1 ? 'endo' : projectId === 2 ? 'ultrasound' : projectId === 5201 ? 'neuropulse' : projectId === 6001 ? 'wangjing' : projectId === 6002 ? 'tianzhihang' : projectId === 6003 ? 'xunzun' : projectId === 6004 ? 'xianghejie' : projectId === 6005 ? 'yamui' : projectId === 6006 ? 'ziyanar' : projectId === 6007 ? 'lanting' : projectId === 6008 ? 'dengyizi' : projectId === 6009 ? 'chongjibo' : projectId === 6010 ? 'jigu' : `p${projectId}`; result.push({ sessionId: buildPrivateSessionId(projectKey, userId), userId, projectId, title: template.title, deviceType: template.deviceType, stage: template.stage as StageKey, stageLabel: template.stageLabel, messageCount: 0, updatedAt: '刚刚', }); } } return dedupePrivateSessions(result); } /** 断言:任意消息不得跨用户 session 泄漏 */ export function assertNoCrossUserChatLeak( sessions: ProjectSession[], messages: ChatMessage[] ): { ok: boolean; leaks: string[] } { const sessionOwner = new Map(sessions.map((s) => [s.sessionId, s.userId])); const leaks: string[] = []; for (const m of messages) { const owner = sessionOwner.get(m.sessionId); if (owner == null) continue; if (m.role === 'user' && m.authorName) { const otherOwners = sessions.filter((s) => s.userId !== owner); for (const o of otherOwners) { const otherName = guessUserNameById(o.userId); if (otherName && m.authorName === otherName) { leaks.push( `session=${m.sessionId} ownerUserId=${owner} 含异主用户发言 author=${m.authorName}` ); } } } } return { ok: leaks.length === 0, leaks }; } function guessUserNameById(userId?: number): string | null { if (userId === 2) return '张工'; if (userId === 3) return '李主管'; if (userId === 4) return '王总监'; return null; }