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