diff --git a/apps/api/src/auth/presence.ts b/apps/api/src/auth/presence.ts new file mode 100644 index 0000000..a08d2b1 --- /dev/null +++ b/apps/api/src/auth/presence.ts @@ -0,0 +1,53 @@ +import type { ChainableCommander } from "ioredis" + +import { redis } from "../redis" + +/** + * 「谁现在在线」。member = userId,score = 最后一次活动的毫秒时间戳。 + * + * 会话本身判定不了在线:`session:` 的 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 不能设 TTL:ZADD 不会重置 + * 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 +} diff --git a/apps/api/src/auth/session.ts b/apps/api/src/auth/session.ts index 574ca2f..428159a 100644 --- a/apps/api/src/auth/session.ts +++ b/apps/api/src/auth/session.ts @@ -14,6 +14,7 @@ import { deleteCookie, getCookie, setCookie } from "hono/cookie" import { config } from "../config" import { db, schema } from "../db" import { publishSessionRevoked, type SessionRevokedReason } from "../events" +import { clearOnline, markOnline } from "./presence" import { redis } from "../redis" const SESSION_PREFIX = "session:" @@ -74,12 +75,13 @@ export async function createSession( } // 三条写进一个 pipeline:一个班四十号人同时登录时,三趟往返和一趟的差别 // 全压在登录这一下上 - await redis + const pipeline = redis .pipeline() .set(sessionKey(token), JSON.stringify(value), "EX", config.sessionTtlSeconds) .sadd(userSessionsKey(userId), token) .expire(userSessionsKey(userId), config.sessionTtlSeconds) - .exec() + markOnline(pipeline, userId) + await pipeline.exec() setCookie(c, config.sessionCookie, token, { httpOnly: true, sameSite: "Lax", @@ -96,7 +98,10 @@ export async function destroySession(c: Context) { // 先读出 userId 再删,否则反向索引里会留下一个永远清不掉的成员 const userId = await sessionUserId(token) await redis.del(sessionKey(token)) - if (userId !== null) await redis.srem(userSessionsKey(userId), token) + if (userId !== null) { + await redis.srem(userSessionsKey(userId), token) + await clearOnline(userId) + } } deleteCookie(c, config.sessionCookie, { path: "/" }) return token ?? null @@ -128,6 +133,7 @@ export async function revokeUserSessions( const tokens = await redis.smembers(userSessionsKey(userId)) if (tokens.length) await redis.del(...tokens.map(sessionKey)) await redis.del(userSessionsKey(userId)) + await clearOnline(userId) await publishSessionRevoked({ userId }, reason) return tokens.length } @@ -196,11 +202,13 @@ async function getUserByToken(token: string | undefined): Promise // 反向索引跟着会话一起续期,否则活跃用户的索引会先于会话到期, // 之后再吊销就找不到这张会话了。两条走一次 pipeline —— 这是全后端最热的 Redis // 路径,每个带鉴权的请求都要走一趟,形状和 touchSession 里那对保持一致 - await redis + const renew = redis .pipeline() .expire(sessionKey(token), config.sessionTtlSeconds) .expire(userSessionsKey(session.userId), config.sessionTtlSeconds) - .exec() + // 在线状态就是搭在这条 pipeline 上记的,见 presence.ts + markOnline(renew, session.userId) + await renew.exec() // 唯一的收窄点。库里是 text 列,认不出来的值降成最低权限,见 toAdminType 的注释。 return { user: { @@ -250,11 +258,13 @@ export function readRequestSessionToken(request: Request) { */ export async function touchSession(token: string, userId: number) { if (!token) return false - const results = await redis + const pipeline = redis .pipeline() .expire(sessionKey(token), config.sessionTtlSeconds) .expire(userSessionsKey(userId), config.sessionTtlSeconds) - .exec() + // 只挂着 WebSocket 不发请求的人,在线状态全靠这里(sweepSessions 每 60 秒一轮) + markOnline(pipeline, userId) + const results = await pipeline.exec() // 索引那条的返回值不看:存量会话(反向索引上线之前签发的)本来就没有索引键, // 续不到很正常,不能因此判定会话已死 return results?.[0]?.[1] === 1 diff --git a/apps/api/src/routes/admin/account.ts b/apps/api/src/routes/admin/account.ts index 742f8d7..ed9c63c 100644 --- a/apps/api/src/routes/admin/account.ts +++ b/apps/api/src/routes/admin/account.ts @@ -18,6 +18,7 @@ import { and, asc, count, desc, eq, ilike, inArray, ne, or, sql } from "drizzle- import { Hono } from "hono" import { hashPassword } from "../../auth/password" +import { isUserOnline, onlineUserIds } from "../../auth/presence" import { revokeUserSessions } from "../../auth/session" import { requireSuperAdmin, type AppEnv } from "../../auth/middleware" import { db, schema } from "../../db" @@ -64,7 +65,7 @@ function normalizePermission(adminType: AdminType, requested: ProblemPermission) function serialize(row: { user: typeof schema.user.$inferSelect realName: string | null -}) { +}, isOnline: boolean) { return adminUserSchema.parse({ id: row.user.id, username: row.user.username, @@ -75,6 +76,7 @@ function serialize(row: { createTime: row.user.createTime, lastLogin: row.user.lastLogin, isDisabled: row.user.isDisabled, + isOnline, rawPassword: row.user.rawPassword, className: row.user.className, }) @@ -153,10 +155,24 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => { )!) } const where = filters.length ? and(...filters) : undefined + // 在线状态每行都要下发(列表里显示),所以不管怎么排都先取一次 + const online = await onlineUserIds() + const orderBy = c.req.query("orderBy") // 「最近登录」排序要把从未登录的排在最后,否则一堆 null 顶在最前面,这个排序就没用了 - const order = c.req.query("orderBy") === "-lastLogin" - ? [sql`${schema.user.lastLogin} desc nulls last`] - : [desc(schema.user.createTime)] + // + // 「在线优先」没有对应的库表列 —— 在线只存在于 Redis,所以把在线的 id 捞出来 + // 在 SQL 里分两档;档内仍按最近登录排,这样一屏离线用户之间还是有意义的顺序。 + // 没人在线时那个 case 恒等于 1,直接省掉(inArray 拿空数组也不合法)。 + const order = orderBy === "-online" + ? [ + ...(online.size + ? [sql`case when ${inArray(schema.user.id, [...online])} then 0 else 1 end`] + : []), + sql`${schema.user.lastLogin} desc nulls last`, + ] + : orderBy === "-lastLogin" + ? [sql`${schema.user.lastLogin} desc nulls last`] + : [desc(schema.user.createTime)] const [totalRows, rows] = await Promise.all([ db.select({ value: count() }).from(schema.user) @@ -166,7 +182,7 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => { .orderBy(...order, asc(schema.user.id)).limit(limit).offset(offset), ]) return success(c, adminUserListSchema.parse({ - results: rows.map(serialize), + results: rows.map((row) => serialize(row, online.has(row.user.id))), total: totalRows[0]?.value ?? 0, })) }) @@ -174,7 +190,7 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => { adminAccountRoutes.get("/users/:id", requireSuperAdmin, async (c) => { const [row] = await selectUser(queryInteger(c.req.param("id"), 0, { min: 1 })) if (!row) return failure(c, 404, "user-not-found", "User does not exist") - return success(c, serialize(row)) + return success(c, serialize(row, await isUserOnline(row.user.id))) }) adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => { @@ -239,7 +255,7 @@ adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => { } const [row] = await selectUser(id) - return success(c, serialize(row!)) + return success(c, serialize(row!, await isUserOnline(id))) }) adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => { diff --git a/apps/web/src/admin/api.ts b/apps/web/src/admin/api.ts index 29c8afb..898c228 100644 --- a/apps/web/src/admin/api.ts +++ b/apps/web/src/admin/api.ts @@ -150,7 +150,8 @@ export function getUserList( orderBy = "", ) { return api.get("admin/users", { - // 旧接口的 order_by 只有 "-last_login" 一个取值 + // "-last_login" 是旧接口传下来的取值(路由 query 里可能还存着),改叫 "-lastLogin"; + // "-online" 是新增的,原样透传 params: { offset, limit, diff --git a/apps/web/src/admin/user/list.vue b/apps/web/src/admin/user/list.vue index 8573588..dd2e820 100644 --- a/apps/web/src/admin/user/list.vue +++ b/apps/web/src/admin/user/list.vue @@ -1,5 +1,5 @@