From 6a438872b92ee28a922ae08ff4c54c7f7a0a2745 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Mon, 7 Sep 2026 19:36:12 -0600 Subject: [PATCH] =?UTF-8?q?feat(=E6=8E=92=E8=A1=8C=E6=A6=9C):=20=E9=A1=B5?= =?UTF-8?q?=E5=A4=B4=E6=98=BE=E7=A4=BA=E5=BD=93=E5=89=8D=E5=9C=A8=E7=BA=BF?= =?UTF-8?q?=E4=BA=BA=E6=95=B0=EF=BC=8C=E5=9C=A8=E7=BA=BF=E7=BB=BF=E7=82=B9?= =?UTF-8?q?=E5=8F=AA=E7=BB=99=E8=80=81=E5=B8=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增匿名可读的 GET /api/site/online,一条 ZCOUNT 让 Redis 自己数, 不拉成员、也不写(清理过期成员留给后台列表,匿名接口不带写操作)。 榜单页进页面拉一次,为 0 时不显示。 /rankings/users 的 isOnline 是三态:null 表示「这个调用方不该知道」, 只有老师及以上拿到 true/false。写成普通 boolean 的话学生看到的 false 和真的离线分不开,等于默认把每个人的在线状态摊给全校同学看。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017xu912Rv5JUUuy6MqMcQW2 --- apps/api/src/auth/presence.ts | 9 +++++++++ apps/api/src/routes/account.ts | 12 ++++++++---- apps/api/src/routes/site.ts | 11 ++++++++++- apps/web/src/oj/api.ts | 6 ++++++ apps/web/src/oj/rank/list.vue | 23 +++++++++++++++++++++++ apps/web/src/utils/types.ts | 2 +- packages/contract/src/account.ts | 6 ++++++ packages/contract/src/site.ts | 6 ++++++ 8 files changed, 69 insertions(+), 6 deletions(-) diff --git a/apps/api/src/auth/presence.ts b/apps/api/src/auth/presence.ts index a08d2b1..e76ac48 100644 --- a/apps/api/src/auth/presence.ts +++ b/apps/api/src/auth/presence.ts @@ -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)) diff --git a/apps/api/src/routes/account.ts b/apps/api/src/routes/account.ts index e1f7dea..45408f1 100644 --- a/apps/api/src/routes/account.ts +++ b/apps/api/src/routes/account.ts @@ -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() @@ -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 | 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, }) } diff --git a/apps/api/src/routes/site.ts b/apps/api/src/routes/site.ts index 1c0040f..c68b1ad 100644 --- a/apps/api/src/routes/site.ts +++ b/apps/api/src/routes/site.ts @@ -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" }, diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index 15b5957..0712ccd 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -54,6 +54,7 @@ import type { Submission, SubmissionListPayload, SubmitCodePayload, + OnlineCount, WebsiteConfig, Tutorial, TutorialProgress, @@ -71,6 +72,11 @@ export function getWebsiteConfig() { return api.get("site") } +/** 当前在线人数。只有聚合数字,「谁在线」在榜单接口里、且只对老师下发 */ +export function getOnlineCount() { + return api.get("site/online") +} + export async function getProblemList( offset = 0, limit = 10, diff --git a/apps/web/src/oj/rank/list.vue b/apps/web/src/oj/rank/list.vue index 659eecb..4abf977 100644 --- a/apps/web/src/oj/rank/list.vue +++ b/apps/web/src/oj/rank/list.vue @@ -11,6 +11,7 @@ import { NButton, NFlex } from "naive-ui" import { getActivityRank, getClassRank, + getOnlineCount, getRank, getUserClassRank, getClassPK, @@ -53,6 +54,8 @@ const query = reactive({ }) const message = useMessage() const rankChart = ref([]) +/** 全站在线人数。只是个聚合数字;「谁在线」是每行的 isOnline,服务端只对老师下发 */ +const onlineCount = ref(0) const activityChart = ref([]) const duration = ref("months:1") const classData = ref([]) @@ -182,6 +185,14 @@ const columns: DataTableColumn[] = [ width: 240, render: (row) => h("div", { style: "display:flex;align-items:center;gap:6px" }, [ + // isOnline 是三态:null 表示服务端没给(学生视角),只有 true 才点亮 + row.isOnline + ? h("span", { + title: "在线(5 分钟内有活动)", + style: + "width:8px;height:8px;border-radius:50%;background:#18a058;flex:none", + }) + : null, h( NButton, { @@ -251,6 +262,11 @@ watch( ) watch(duration, listActivity) +async function listOnline() { + const res = await getOnlineCount() + onlineCount.value = res.count +} + async function listActivity() { const current = Date.now() const start = formatISO(sub(current, subOptions.value)) @@ -262,6 +278,7 @@ async function listActivity() { acceptedNumber: d.count, submissionNumber: 0, mood: null, + isOnline: null, })) } @@ -286,6 +303,7 @@ onMounted(() => { // 再单发一次一模一样的 /rankings/users 只会让这张图排在日活后面出来。 // 图只在挂载时定一次,翻页/改每页条数不该动它。 init().then((results) => (rankChart.value = results.slice(0, 10))) + listOnline() listActivity() listClassRank() listMyClassRank() @@ -523,6 +541,11 @@ watch( + export type Quote = z.infer +export type OnlineCount = z.infer