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

@@ -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) => {