新增匿名可读的 GET /api/site/online,一条 ZCOUNT 让 Redis 自己数, 不拉成员、也不写(清理过期成员留给后台列表,匿名接口不带写操作)。 榜单页进页面拉一次,为 0 时不显示。 /rankings/users 的 isOnline 是三态:null 表示「这个调用方不该知道」, 只有老师及以上拿到 true/false。写成普通 boolean 的话学生看到的 false 和真的离线分不开,等于默认把每个人的在线状态摊给全校同学看。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xu912Rv5JUUuy6MqMcQW2
This commit is contained in:
@@ -41,6 +41,15 @@ export async function onlineUserIds() {
|
||||
return new Set(members.map(Number).filter(Number.isInteger))
|
||||
}
|
||||
|
||||
/**
|
||||
* 在线人数。前台榜单页要的就是这一个数 —— 不必像 onlineUserIds 那样把成员全拉回来,
|
||||
* ZCOUNT 让 Redis 自己数(O(log N))。这里不顺手清过期成员:清理是写操作,
|
||||
* 而这个端点是匿名可访问的。
|
||||
*/
|
||||
export async function onlineCount() {
|
||||
return redis.zcount(PRESENCE_KEY, Date.now() - ONLINE_WINDOW_MS, "+inf")
|
||||
}
|
||||
|
||||
/** 登出、被禁用、被踢下线:立刻从在线名单里摘掉,别等窗口自然过期 */
|
||||
export async function clearOnline(userId: number) {
|
||||
await redis.zrem(PRESENCE_KEY, String(userId))
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { hashPassword } from "../auth/password"
|
||||
import { onlineUserIds } from "../auth/presence"
|
||||
import { optionalAuth, requireAuth, type AppEnv } from "../auth/middleware"
|
||||
import { config } from "../config"
|
||||
import { db, schema } from "../db"
|
||||
@@ -40,7 +41,7 @@ import { failure, success } from "../http"
|
||||
import { JudgeStatus } from "../judge/status"
|
||||
import { getBooleanOption } from "../services/options"
|
||||
import { getUserProfileById } from "../services/profile"
|
||||
import { objectValue, queryInteger, sampleUser } from "./helpers"
|
||||
import { isTeacherOrAbove, objectValue, queryInteger, sampleUser } from "./helpers"
|
||||
|
||||
export const accountRoutes = new Hono<AppEnv>()
|
||||
|
||||
@@ -184,7 +185,8 @@ accountRoutes.get("/rankings/users", optionalAuth, async (c) => {
|
||||
// 端点延迟从「四个来回相加」变成「最慢的那个」。越界页一条不剩,直接不发 SQL。
|
||||
const pageLimit = Math.max(0, Math.min(limit, LEADERBOARD_SIZE - offset))
|
||||
|
||||
const [totalRow, rows, me] = await Promise.all([
|
||||
// 谁在线只给老师看,学生那边整列都是 null(见 rankProfileSchema.isOnline)
|
||||
const [totalRow, rows, me, online] = await Promise.all([
|
||||
db.select({ value: count() }).from(schema.userProfile)
|
||||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(leaderboardWhere).then(([row]) => row),
|
||||
@@ -194,10 +196,11 @@ accountRoutes.get("/rankings/users", optionalAuth, async (c) => {
|
||||
.where(leaderboardWhere).orderBy(...leaderboardOrder)
|
||||
.limit(pageLimit).offset(offset),
|
||||
myLeaderboardRank(c.get("user")?.id),
|
||||
isTeacherOrAbove(c.get("user")) ? onlineUserIds() : null,
|
||||
])
|
||||
|
||||
return success(c, userRankSchema.parse({
|
||||
results: rows.map(serializeRankRow),
|
||||
results: rows.map((row) => serializeRankRow(row, online)),
|
||||
total: Math.min(totalRow?.value ?? 0, LEADERBOARD_SIZE),
|
||||
me,
|
||||
}))
|
||||
@@ -206,13 +209,14 @@ accountRoutes.get("/rankings/users", optionalAuth, async (c) => {
|
||||
function serializeRankRow({ profile, user }: {
|
||||
profile: typeof schema.userProfile.$inferSelect
|
||||
user: typeof schema.user.$inferSelect
|
||||
}) {
|
||||
}, online: Set<number> | null = null) {
|
||||
return rankProfileSchema.parse({
|
||||
id: profile.id,
|
||||
user: sampleUser(user, profile.realName),
|
||||
acceptedNumber: profile.acceptedNumber,
|
||||
submissionNumber: profile.submissionNumber,
|
||||
mood: profile.mood,
|
||||
isOnline: online ? online.has(user.id) : null,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { quoteSchema, websiteConfigSchema } from "@oj2/contract"
|
||||
import { onlineCountSchema, quoteSchema, websiteConfigSchema } from "@oj2/contract"
|
||||
import { asc, desc, eq } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
import { resolve } from "node:path"
|
||||
|
||||
import { onlineCount } from "../auth/presence"
|
||||
import { config } from "../config"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, success } from "../http"
|
||||
@@ -25,6 +26,14 @@ siteRoutes.get("/site", async (c) => {
|
||||
}))
|
||||
})
|
||||
|
||||
/**
|
||||
* 当前在线人数。匿名可读 —— 一个聚合数字不暴露任何人的身份,
|
||||
* 而榜单页本身就允许匿名看。谁在线是另一回事,只在 /rankings/users 里对老师下发。
|
||||
*/
|
||||
siteRoutes.get("/site/online", async (c) => {
|
||||
return success(c, onlineCountSchema.parse({ count: await onlineCount() }))
|
||||
})
|
||||
|
||||
// 数据集读不到时的兜底(本机 dev 没挂 data/hitokoto 就会走这里)
|
||||
const fallbackQuotes = [
|
||||
{ hitokoto: "程序首先是写给人读的,其次才是让机器执行。", from: "Structure and Interpretation of Computer Programs" },
|
||||
|
||||
Reference in New Issue
Block a user