Unify MOCK_USERS with backend auth (admin id=1), merge session identity on login, and migrate demo seeds/tests to id 2/3/4 for 张工/李/王. Co-authored-by: Cursor <cursoragent@cursor.com>
195 lines
6.6 KiB
TypeScript
195 lines
6.6 KiB
TypeScript
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, number>
|
||
): 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<string, number>
|
||
): ProjectSession[] {
|
||
const groups = new Map<string, ProjectSession[]>();
|
||
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<number, ProjectSession>();
|
||
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;
|
||
}
|