From 22a7700b89e259883230f3c00a8641c706e338ba Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Mon, 7 Sep 2026 07:40:58 -0600 Subject: [PATCH] =?UTF-8?q?fix(=E4=BC=9A=E8=AF=9D):=20touchSession=20?= =?UTF-8?q?=E7=BB=AD=E6=9C=9F=E6=97=B6=E4=B8=80=E5=B9=B6=E7=BB=AD=E5=8F=8D?= =?UTF-8?q?=E5=90=91=E7=B4=A2=E5=BC=95=EF=BC=8C=E5=90=A6=E5=88=99=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=90=8A=E9=94=80=E4=B8=8D=E6=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebSocket 巡检走的 touchSession 只 EXPIRE `session:`,不碰 `user-sessions:`。而这条路径存在的理由恰恰是「只开着页面挂 WebSocket、一次 HTTP 请求都不发的人」—— 这种连接碰不到 getUserByToken 里那两条并排的 expire。 于是索引先到期、会话却被巡检一直续着。之后改密码 / 禁用账号走 revokeUserSessions 就 SMEMBERS 不到这张 token:WebSocket 那边还有 publishSessionRevoked 按 userId 兜底能断掉,但 HTTP 一侧拿着那张 cookie 照用不误 —— 而改密码要的恰恰是让 HTTP 立刻失效(学生密码是明文存着给老师查的,改密码是密码泄露后唯一的补救手段)。 签名加一个 userId,两条 EXPIRE 走一次 pipeline,仍然只有一趟往返,原来「比 GET + EXPIRE 少一趟」的理由保住了。三个调用点都有现成的 ws.data.userId。 返回值只看会话那条:反向索引是 498fc1c 才加的,在那之前签发的会话本来就没有索引 键,续不到是正常的,不能因此判定会话已死。 实跑验过四种情况:正常会话两边都续到 7 天;无索引键的存量会话仍判活;会话已删返回 false;空 token 返回 false。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XvmqDsZNyUo9P3sFQtoWVB --- apps/api/src/auth/session.ts | 23 +++++++++++++++++++---- apps/api/src/collab/handler.ts | 2 +- apps/api/src/websocket.ts | 4 ++-- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/apps/api/src/auth/session.ts b/apps/api/src/auth/session.ts index 182cbd3..5612c7d 100644 --- a/apps/api/src/auth/session.ts +++ b/apps/api/src/auth/session.ts @@ -232,13 +232,28 @@ export function readRequestSessionToken(request: Request) { /** * 会话还在就续期并返回 true,已登出或已过期返回 false。 * - * 用 EXPIRE 一条命令同时完成「判断存在」和「续期」,比 GET + EXPIRE 少一趟往返。 - * 续期这件事本身也是要的:HTTP 请求会走 getUserByToken 里的 redis.expire 续期, + * 用 EXPIRE 同时完成「判断存在」和「续期」,比 GET + EXPIRE 少一趟往返;两条 EXPIRE + * 走一次 pipeline,仍然只有一趟。 + * + * 续期这件事本身是要的:HTTP 请求会走 getUserByToken 里的 redis.expire 续期, * 而只开着页面挂 WebSocket 的人一次请求都不发,不该因此被算成不活跃踢下线。 + * + * **反向索引必须跟着一起续。** 走到这里的正是那种一次 HTTP 请求都不发的连接, + * 它碰不到 getUserByToken 里那两条并排的 expire。只续会话不续索引的话,索引先到期、 + * 会话却被巡检一直续着,之后改密码 / 禁用账号走 revokeUserSessions 就 SMEMBERS + * 不到这张 token —— WebSocket 那边还有 publishSessionRevoked 按 userId 兜底能断掉, + * 但 HTTP 一侧拿着那张 cookie 照用不误,而改密码要的恰恰是让 HTTP 立刻失效。 */ -export async function touchSession(token: string) { +export async function touchSession(token: string, userId: number) { if (!token) return false - return (await redis.expire(sessionKey(token), config.sessionTtlSeconds)) === 1 + const results = await redis + .pipeline() + .expire(sessionKey(token), config.sessionTtlSeconds) + .expire(userSessionsKey(userId), config.sessionTtlSeconds) + .exec() + // 索引那条的返回值不看:存量会话(反向索引上线之前签发的)本来就没有索引键, + // 续不到很正常,不能因此判定会话已死 + return results?.[0]?.[1] === 1 } async function getStoredSession(c: Context) { diff --git a/apps/api/src/collab/handler.ts b/apps/api/src/collab/handler.ts index b0b29cc..861e947 100644 --- a/apps/api/src/collab/handler.ts +++ b/apps/api/src/collab/handler.ts @@ -202,7 +202,7 @@ export async function handleCollabMessage(ws: CollabSocket, raw: string) { } // 握手时校验过一次不算数 —— 这条连接能挂几个小时 - if (!(await touchSession(ws.data.token))) { + if (!(await touchSession(ws.data.token, ws.data.userId))) { ws.close(1008, "Session expired") return } diff --git a/apps/api/src/websocket.ts b/apps/api/src/websocket.ts index b889809..6015a60 100644 --- a/apps/api/src/websocket.ts +++ b/apps/api/src/websocket.ts @@ -178,7 +178,7 @@ export async function sweepSessions() { let alive = checked.get(token) if (alive === undefined) { try { - alive = await touchSession(token) + alive = await touchSession(token, ws.data.userId) } catch (error) { // Redis 抖一下不该把全班踢下线:这一轮直接放弃,下一轮再说 console.error("Failed to verify websocket sessions", error) @@ -299,7 +299,7 @@ async function handleMessage( // 会话可能在连接期间就失效了:用户在别的标签页登出,或者会话自己到期。 // 握手时校验过一次不算数 —— 这条连接能挂几个小时。 - if (!(await touchSession(ws.data.token))) { + if (!(await touchSession(ws.data.token, ws.data.userId))) { ws.close(1008, "Session expired") return }