feat(后台用户): 新增「在线优先」排序,列表直接显示在线标记

在线状态库里没有、会话也判定不了 —— session 的 TTL 是 7 天且每次请求续期,
「有会话」只说明这人一周内来过。新开一个 Redis sorted set(auth/presence.ts)
记最后活动时间,5 分钟内有活动算在线。

写入全搭在已有的 pipeline 上,不多一趟往返:登录、每个带鉴权请求的续期、
以及 touchSession —— 只挂着 WebSocket 不发请求的人靠最后这条,sweepSessions
每 60 秒一轮,所以窗口取 5 分钟,明显大于那个间隔。登出、改密码、禁用账号
会立刻把人摘掉;过期成员在后台读列表时顺手清理(整个 key 不能设 TTL,
ZADD 不重置 key 的 TTL,到期会把还在线的人一起抹掉)。

排序 orderBy=-online 先捞在线 id,SQL 里 case when 分两档,档内继续按
最近登录排;没人在线时那个 case 恒等于 1,直接省掉。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xu912Rv5JUUuy6MqMcQW2
This commit is contained in:
2026-09-07 19:30:45 -06:00
parent 5222e012e1
commit 856b7a280e
6 changed files with 111 additions and 20 deletions

View File

@@ -0,0 +1,53 @@
import type { ChainableCommander } from "ioredis"
import { redis } from "../redis"
/**
* 「谁现在在线」。member = userIdscore = 最后一次活动的毫秒时间戳。
*
* 会话本身判定不了在线:`session:<token>` 的 TTL 是 7 天且每次请求都续期,
* 「有会话」只说明这人一周内来过。所以这里单独记一个活动时间戳 ——
* 写入一律搭在已有的 pipeline 上登录、每个带鉴权的请求、WebSocket 巡检),
* 不多一趟往返。
*/
const PRESENCE_KEY = "online-users"
/**
* 多久没动就算离线。挂着页面不操作的人靠 sweepSessions 每 60 秒续一次
* (见 websocket.ts窗口必须明显大于那个间隔否则开着页面的学生会一闪一闪。
*/
const ONLINE_WINDOW_MS = 5 * 60 * 1000
/** 记一笔活动。传 pipeline 而不是自己发命令:调用点都在热路径上 */
export function markOnline(pipeline: ChainableCommander, userId: number) {
pipeline.zadd(PRESENCE_KEY, Date.now(), String(userId))
}
/**
* 当前在线的用户 id。
*
* 顺手把过期成员删掉 —— 这是唯一的清理时机(整个 key 不能设 TTLZADD 不会重置
* key 的 TTL到期会把还在线的人一起抹掉。读这张表的只有后台用户列表
* 不清理最坏也就是攒下全站用户数量级的成员,远谈不上要单开一个定时任务。
*/
export async function onlineUserIds() {
const cutoff = Date.now() - ONLINE_WINDOW_MS
const results = await redis
.pipeline()
.zremrangebyscore(PRESENCE_KEY, "-inf", `(${cutoff}`)
.zrange(PRESENCE_KEY, "0", "-1")
.exec()
const members = (results?.[1]?.[1] ?? []) as string[]
return new Set(members.map(Number).filter(Number.isInteger))
}
/** 登出、被禁用、被踢下线:立刻从在线名单里摘掉,别等窗口自然过期 */
export async function clearOnline(userId: number) {
await redis.zrem(PRESENCE_KEY, String(userId))
}
/** 单个用户在不在线。列表页用上面那个,别在循环里调这个 */
export async function isUserOnline(userId: number) {
const score = await redis.zscore(PRESENCE_KEY, String(userId))
return score !== null && Number(score) >= Date.now() - ONLINE_WINDOW_MS
}