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:
53
apps/api/src/auth/presence.ts
Normal file
53
apps/api/src/auth/presence.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { ChainableCommander } from "ioredis"
|
||||||
|
|
||||||
|
import { redis } from "../redis"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「谁现在在线」。member = userId,score = 最后一次活动的毫秒时间戳。
|
||||||
|
*
|
||||||
|
* 会话本身判定不了在线:`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 不能设 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
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { deleteCookie, getCookie, setCookie } from "hono/cookie"
|
|||||||
import { config } from "../config"
|
import { config } from "../config"
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
import { publishSessionRevoked, type SessionRevokedReason } from "../events"
|
import { publishSessionRevoked, type SessionRevokedReason } from "../events"
|
||||||
|
import { clearOnline, markOnline } from "./presence"
|
||||||
import { redis } from "../redis"
|
import { redis } from "../redis"
|
||||||
|
|
||||||
const SESSION_PREFIX = "session:"
|
const SESSION_PREFIX = "session:"
|
||||||
@@ -74,12 +75,13 @@ export async function createSession(
|
|||||||
}
|
}
|
||||||
// 三条写进一个 pipeline:一个班四十号人同时登录时,三趟往返和一趟的差别
|
// 三条写进一个 pipeline:一个班四十号人同时登录时,三趟往返和一趟的差别
|
||||||
// 全压在登录这一下上
|
// 全压在登录这一下上
|
||||||
await redis
|
const pipeline = redis
|
||||||
.pipeline()
|
.pipeline()
|
||||||
.set(sessionKey(token), JSON.stringify(value), "EX", config.sessionTtlSeconds)
|
.set(sessionKey(token), JSON.stringify(value), "EX", config.sessionTtlSeconds)
|
||||||
.sadd(userSessionsKey(userId), token)
|
.sadd(userSessionsKey(userId), token)
|
||||||
.expire(userSessionsKey(userId), config.sessionTtlSeconds)
|
.expire(userSessionsKey(userId), config.sessionTtlSeconds)
|
||||||
.exec()
|
markOnline(pipeline, userId)
|
||||||
|
await pipeline.exec()
|
||||||
setCookie(c, config.sessionCookie, token, {
|
setCookie(c, config.sessionCookie, token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "Lax",
|
sameSite: "Lax",
|
||||||
@@ -96,7 +98,10 @@ export async function destroySession(c: Context) {
|
|||||||
// 先读出 userId 再删,否则反向索引里会留下一个永远清不掉的成员
|
// 先读出 userId 再删,否则反向索引里会留下一个永远清不掉的成员
|
||||||
const userId = await sessionUserId(token)
|
const userId = await sessionUserId(token)
|
||||||
await redis.del(sessionKey(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: "/" })
|
deleteCookie(c, config.sessionCookie, { path: "/" })
|
||||||
return token ?? null
|
return token ?? null
|
||||||
@@ -128,6 +133,7 @@ export async function revokeUserSessions(
|
|||||||
const tokens = await redis.smembers(userSessionsKey(userId))
|
const tokens = await redis.smembers(userSessionsKey(userId))
|
||||||
if (tokens.length) await redis.del(...tokens.map(sessionKey))
|
if (tokens.length) await redis.del(...tokens.map(sessionKey))
|
||||||
await redis.del(userSessionsKey(userId))
|
await redis.del(userSessionsKey(userId))
|
||||||
|
await clearOnline(userId)
|
||||||
await publishSessionRevoked({ userId }, reason)
|
await publishSessionRevoked({ userId }, reason)
|
||||||
return tokens.length
|
return tokens.length
|
||||||
}
|
}
|
||||||
@@ -196,11 +202,13 @@ async function getUserByToken(token: string | undefined): Promise<SessionResult>
|
|||||||
// 反向索引跟着会话一起续期,否则活跃用户的索引会先于会话到期,
|
// 反向索引跟着会话一起续期,否则活跃用户的索引会先于会话到期,
|
||||||
// 之后再吊销就找不到这张会话了。两条走一次 pipeline —— 这是全后端最热的 Redis
|
// 之后再吊销就找不到这张会话了。两条走一次 pipeline —— 这是全后端最热的 Redis
|
||||||
// 路径,每个带鉴权的请求都要走一趟,形状和 touchSession 里那对保持一致
|
// 路径,每个带鉴权的请求都要走一趟,形状和 touchSession 里那对保持一致
|
||||||
await redis
|
const renew = redis
|
||||||
.pipeline()
|
.pipeline()
|
||||||
.expire(sessionKey(token), config.sessionTtlSeconds)
|
.expire(sessionKey(token), config.sessionTtlSeconds)
|
||||||
.expire(userSessionsKey(session.userId), config.sessionTtlSeconds)
|
.expire(userSessionsKey(session.userId), config.sessionTtlSeconds)
|
||||||
.exec()
|
// 在线状态就是搭在这条 pipeline 上记的,见 presence.ts
|
||||||
|
markOnline(renew, session.userId)
|
||||||
|
await renew.exec()
|
||||||
// 唯一的收窄点。库里是 text 列,认不出来的值降成最低权限,见 toAdminType 的注释。
|
// 唯一的收窄点。库里是 text 列,认不出来的值降成最低权限,见 toAdminType 的注释。
|
||||||
return {
|
return {
|
||||||
user: {
|
user: {
|
||||||
@@ -250,11 +258,13 @@ export function readRequestSessionToken(request: Request) {
|
|||||||
*/
|
*/
|
||||||
export async function touchSession(token: string, userId: number) {
|
export async function touchSession(token: string, userId: number) {
|
||||||
if (!token) return false
|
if (!token) return false
|
||||||
const results = await redis
|
const pipeline = redis
|
||||||
.pipeline()
|
.pipeline()
|
||||||
.expire(sessionKey(token), config.sessionTtlSeconds)
|
.expire(sessionKey(token), config.sessionTtlSeconds)
|
||||||
.expire(userSessionsKey(userId), config.sessionTtlSeconds)
|
.expire(userSessionsKey(userId), config.sessionTtlSeconds)
|
||||||
.exec()
|
// 只挂着 WebSocket 不发请求的人,在线状态全靠这里(sweepSessions 每 60 秒一轮)
|
||||||
|
markOnline(pipeline, userId)
|
||||||
|
const results = await pipeline.exec()
|
||||||
// 索引那条的返回值不看:存量会话(反向索引上线之前签发的)本来就没有索引键,
|
// 索引那条的返回值不看:存量会话(反向索引上线之前签发的)本来就没有索引键,
|
||||||
// 续不到很正常,不能因此判定会话已死
|
// 续不到很正常,不能因此判定会话已死
|
||||||
return results?.[0]?.[1] === 1
|
return results?.[0]?.[1] === 1
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { and, asc, count, desc, eq, ilike, inArray, ne, or, sql } from "drizzle-
|
|||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
|
|
||||||
import { hashPassword } from "../../auth/password"
|
import { hashPassword } from "../../auth/password"
|
||||||
|
import { isUserOnline, onlineUserIds } from "../../auth/presence"
|
||||||
import { revokeUserSessions } from "../../auth/session"
|
import { revokeUserSessions } from "../../auth/session"
|
||||||
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
|
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
|
||||||
import { db, schema } from "../../db"
|
import { db, schema } from "../../db"
|
||||||
@@ -64,7 +65,7 @@ function normalizePermission(adminType: AdminType, requested: ProblemPermission)
|
|||||||
function serialize(row: {
|
function serialize(row: {
|
||||||
user: typeof schema.user.$inferSelect
|
user: typeof schema.user.$inferSelect
|
||||||
realName: string | null
|
realName: string | null
|
||||||
}) {
|
}, isOnline: boolean) {
|
||||||
return adminUserSchema.parse({
|
return adminUserSchema.parse({
|
||||||
id: row.user.id,
|
id: row.user.id,
|
||||||
username: row.user.username,
|
username: row.user.username,
|
||||||
@@ -75,6 +76,7 @@ function serialize(row: {
|
|||||||
createTime: row.user.createTime,
|
createTime: row.user.createTime,
|
||||||
lastLogin: row.user.lastLogin,
|
lastLogin: row.user.lastLogin,
|
||||||
isDisabled: row.user.isDisabled,
|
isDisabled: row.user.isDisabled,
|
||||||
|
isOnline,
|
||||||
rawPassword: row.user.rawPassword,
|
rawPassword: row.user.rawPassword,
|
||||||
className: row.user.className,
|
className: row.user.className,
|
||||||
})
|
})
|
||||||
@@ -153,8 +155,22 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
|
|||||||
)!)
|
)!)
|
||||||
}
|
}
|
||||||
const where = filters.length ? and(...filters) : undefined
|
const where = filters.length ? and(...filters) : undefined
|
||||||
|
// 在线状态每行都要下发(列表里显示),所以不管怎么排都先取一次
|
||||||
|
const online = await onlineUserIds()
|
||||||
|
const orderBy = c.req.query("orderBy")
|
||||||
// 「最近登录」排序要把从未登录的排在最后,否则一堆 null 顶在最前面,这个排序就没用了
|
// 「最近登录」排序要把从未登录的排在最后,否则一堆 null 顶在最前面,这个排序就没用了
|
||||||
const order = c.req.query("orderBy") === "-lastLogin"
|
//
|
||||||
|
// 「在线优先」没有对应的库表列 —— 在线只存在于 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`]
|
? [sql`${schema.user.lastLogin} desc nulls last`]
|
||||||
: [desc(schema.user.createTime)]
|
: [desc(schema.user.createTime)]
|
||||||
|
|
||||||
@@ -166,7 +182,7 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
|
|||||||
.orderBy(...order, asc(schema.user.id)).limit(limit).offset(offset),
|
.orderBy(...order, asc(schema.user.id)).limit(limit).offset(offset),
|
||||||
])
|
])
|
||||||
return success(c, adminUserListSchema.parse({
|
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,
|
total: totalRows[0]?.value ?? 0,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
@@ -174,7 +190,7 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
|
|||||||
adminAccountRoutes.get("/users/:id", requireSuperAdmin, async (c) => {
|
adminAccountRoutes.get("/users/:id", requireSuperAdmin, async (c) => {
|
||||||
const [row] = await selectUser(queryInteger(c.req.param("id"), 0, { min: 1 }))
|
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")
|
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) => {
|
adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
|
||||||
@@ -239,7 +255,7 @@ adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [row] = await selectUser(id)
|
const [row] = await selectUser(id)
|
||||||
return success(c, serialize(row!))
|
return success(c, serialize(row!, await isUserOnline(id)))
|
||||||
})
|
})
|
||||||
|
|
||||||
adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
|
adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
|
||||||
|
|||||||
@@ -150,7 +150,8 @@ export function getUserList(
|
|||||||
orderBy = "",
|
orderBy = "",
|
||||||
) {
|
) {
|
||||||
return api.get<AdminUserList>("admin/users", {
|
return api.get<AdminUserList>("admin/users", {
|
||||||
// 旧接口的 order_by 只有 "-last_login" 一个取值
|
// "-last_login" 是旧接口传下来的取值(路由 query 里可能还存着),改叫 "-lastLogin";
|
||||||
|
// "-online" 是新增的,原样透传
|
||||||
params: {
|
params: {
|
||||||
offset,
|
offset,
|
||||||
limit,
|
limit,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { DataTableRowKey, SelectOption } from "naive-ui"
|
import { DataTableRowKey, NFlex, NTag, SelectOption } from "naive-ui"
|
||||||
import Pagination from "shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { usePagination } from "shared/composables/pagination"
|
import { usePagination } from "shared/composables/pagination"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
@@ -47,6 +47,7 @@ const adminOptions = [
|
|||||||
const sortOptions = [
|
const sortOptions = [
|
||||||
{ label: "默认排序", value: "" },
|
{ label: "默认排序", value: "" },
|
||||||
{ label: "最近登录", value: "-last_login" },
|
{ label: "最近登录", value: "-last_login" },
|
||||||
|
{ label: "在线优先", value: "-online" },
|
||||||
]
|
]
|
||||||
const [create, toggleCreate] = useToggle(false)
|
const [create, toggleCreate] = useToggle(false)
|
||||||
const password = ref("")
|
const password = ref("")
|
||||||
@@ -87,11 +88,18 @@ const columns: DataTableColumn<User>[] = [
|
|||||||
{
|
{
|
||||||
title: "上次登录",
|
title: "上次登录",
|
||||||
key: "last_login",
|
key: "last_login",
|
||||||
width: 200,
|
width: 240,
|
||||||
|
// 在线是 5 分钟内有过活动(后端 auth/presence.ts),不是「有会话」——
|
||||||
|
// 会话能留 7 天。上次登录时间照旧显示,在线的人前面多一个标记
|
||||||
render: (row) =>
|
render: (row) =>
|
||||||
|
h(NFlex, { align: "center", size: "small" }, () => [
|
||||||
|
row.isOnline
|
||||||
|
? h(NTag, { type: "success", size: "small" }, () => "在线")
|
||||||
|
: null,
|
||||||
row.lastLogin
|
row.lastLogin
|
||||||
? parseTime(row.lastLogin, "YYYY-MM-DD HH:mm:ss")
|
? parseTime(row.lastLogin, "YYYY-MM-DD HH:mm:ss")
|
||||||
: "从未登录",
|
: "从未登录",
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "真名",
|
title: "真名",
|
||||||
@@ -188,6 +196,7 @@ function createNewUser() {
|
|||||||
createTime: null,
|
createTime: null,
|
||||||
lastLogin: null,
|
lastLogin: null,
|
||||||
isDisabled: false,
|
isDisabled: false,
|
||||||
|
isOnline: false,
|
||||||
rawPassword: null,
|
rawPassword: null,
|
||||||
className: null,
|
className: null,
|
||||||
password: "",
|
password: "",
|
||||||
|
|||||||
@@ -209,6 +209,8 @@ export const adminUserSchema = z.object({
|
|||||||
createTime: z.string().nullable(),
|
createTime: z.string().nullable(),
|
||||||
lastLogin: z.string().nullable(),
|
lastLogin: z.string().nullable(),
|
||||||
isDisabled: z.boolean(),
|
isDisabled: z.boolean(),
|
||||||
|
// 在线与否不在库里,是从 Redis 的活动时间戳算出来的(api 的 auth/presence.ts)
|
||||||
|
isOnline: z.boolean(),
|
||||||
// 明文密码。是有意保留的运营需求:老师要能查学生的密码。
|
// 明文密码。是有意保留的运营需求:老师要能查学生的密码。
|
||||||
// 只在超管专属的这一个接口下发,别往任何其它地方复制。
|
// 只在超管专属的这一个接口下发,别往任何其它地方复制。
|
||||||
rawPassword: z.string().nullable(),
|
rawPassword: z.string().nullable(),
|
||||||
|
|||||||
Reference in New Issue
Block a user