新增匿名可读的 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))
|
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) {
|
export async function clearOnline(userId: number) {
|
||||||
await redis.zrem(PRESENCE_KEY, String(userId))
|
await redis.zrem(PRESENCE_KEY, String(userId))
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
|
|
||||||
import { hashPassword } from "../auth/password"
|
import { hashPassword } from "../auth/password"
|
||||||
|
import { onlineUserIds } from "../auth/presence"
|
||||||
import { optionalAuth, requireAuth, type AppEnv } from "../auth/middleware"
|
import { optionalAuth, requireAuth, type AppEnv } from "../auth/middleware"
|
||||||
import { config } from "../config"
|
import { config } from "../config"
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
@@ -40,7 +41,7 @@ import { failure, success } from "../http"
|
|||||||
import { JudgeStatus } from "../judge/status"
|
import { JudgeStatus } from "../judge/status"
|
||||||
import { getBooleanOption } from "../services/options"
|
import { getBooleanOption } from "../services/options"
|
||||||
import { getUserProfileById } from "../services/profile"
|
import { getUserProfileById } from "../services/profile"
|
||||||
import { objectValue, queryInteger, sampleUser } from "./helpers"
|
import { isTeacherOrAbove, objectValue, queryInteger, sampleUser } from "./helpers"
|
||||||
|
|
||||||
export const accountRoutes = new Hono<AppEnv>()
|
export const accountRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
@@ -184,7 +185,8 @@ accountRoutes.get("/rankings/users", optionalAuth, async (c) => {
|
|||||||
// 端点延迟从「四个来回相加」变成「最慢的那个」。越界页一条不剩,直接不发 SQL。
|
// 端点延迟从「四个来回相加」变成「最慢的那个」。越界页一条不剩,直接不发 SQL。
|
||||||
const pageLimit = Math.max(0, Math.min(limit, LEADERBOARD_SIZE - offset))
|
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)
|
db.select({ value: count() }).from(schema.userProfile)
|
||||||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
|
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
|
||||||
.where(leaderboardWhere).then(([row]) => row),
|
.where(leaderboardWhere).then(([row]) => row),
|
||||||
@@ -194,10 +196,11 @@ accountRoutes.get("/rankings/users", optionalAuth, async (c) => {
|
|||||||
.where(leaderboardWhere).orderBy(...leaderboardOrder)
|
.where(leaderboardWhere).orderBy(...leaderboardOrder)
|
||||||
.limit(pageLimit).offset(offset),
|
.limit(pageLimit).offset(offset),
|
||||||
myLeaderboardRank(c.get("user")?.id),
|
myLeaderboardRank(c.get("user")?.id),
|
||||||
|
isTeacherOrAbove(c.get("user")) ? onlineUserIds() : null,
|
||||||
])
|
])
|
||||||
|
|
||||||
return success(c, userRankSchema.parse({
|
return success(c, userRankSchema.parse({
|
||||||
results: rows.map(serializeRankRow),
|
results: rows.map((row) => serializeRankRow(row, online)),
|
||||||
total: Math.min(totalRow?.value ?? 0, LEADERBOARD_SIZE),
|
total: Math.min(totalRow?.value ?? 0, LEADERBOARD_SIZE),
|
||||||
me,
|
me,
|
||||||
}))
|
}))
|
||||||
@@ -206,13 +209,14 @@ accountRoutes.get("/rankings/users", optionalAuth, async (c) => {
|
|||||||
function serializeRankRow({ profile, user }: {
|
function serializeRankRow({ profile, user }: {
|
||||||
profile: typeof schema.userProfile.$inferSelect
|
profile: typeof schema.userProfile.$inferSelect
|
||||||
user: typeof schema.user.$inferSelect
|
user: typeof schema.user.$inferSelect
|
||||||
}) {
|
}, online: Set<number> | null = null) {
|
||||||
return rankProfileSchema.parse({
|
return rankProfileSchema.parse({
|
||||||
id: profile.id,
|
id: profile.id,
|
||||||
user: sampleUser(user, profile.realName),
|
user: sampleUser(user, profile.realName),
|
||||||
acceptedNumber: profile.acceptedNumber,
|
acceptedNumber: profile.acceptedNumber,
|
||||||
submissionNumber: profile.submissionNumber,
|
submissionNumber: profile.submissionNumber,
|
||||||
mood: profile.mood,
|
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 { asc, desc, eq } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { resolve } from "node:path"
|
import { resolve } from "node:path"
|
||||||
|
|
||||||
|
import { onlineCount } from "../auth/presence"
|
||||||
import { config } from "../config"
|
import { config } from "../config"
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
import { failure, success } from "../http"
|
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 就会走这里)
|
// 数据集读不到时的兜底(本机 dev 没挂 data/hitokoto 就会走这里)
|
||||||
const fallbackQuotes = [
|
const fallbackQuotes = [
|
||||||
{ hitokoto: "程序首先是写给人读的,其次才是让机器执行。", from: "Structure and Interpretation of Computer Programs" },
|
{ hitokoto: "程序首先是写给人读的,其次才是让机器执行。", from: "Structure and Interpretation of Computer Programs" },
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import type {
|
|||||||
Submission,
|
Submission,
|
||||||
SubmissionListPayload,
|
SubmissionListPayload,
|
||||||
SubmitCodePayload,
|
SubmitCodePayload,
|
||||||
|
OnlineCount,
|
||||||
WebsiteConfig,
|
WebsiteConfig,
|
||||||
Tutorial,
|
Tutorial,
|
||||||
TutorialProgress,
|
TutorialProgress,
|
||||||
@@ -71,6 +72,11 @@ export function getWebsiteConfig() {
|
|||||||
return api.get<WebsiteConfig>("site")
|
return api.get<WebsiteConfig>("site")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 当前在线人数。只有聚合数字,「谁在线」在榜单接口里、且只对老师下发 */
|
||||||
|
export function getOnlineCount() {
|
||||||
|
return api.get<OnlineCount>("site/online")
|
||||||
|
}
|
||||||
|
|
||||||
export async function getProblemList(
|
export async function getProblemList(
|
||||||
offset = 0,
|
offset = 0,
|
||||||
limit = 10,
|
limit = 10,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { NButton, NFlex } from "naive-ui"
|
|||||||
import {
|
import {
|
||||||
getActivityRank,
|
getActivityRank,
|
||||||
getClassRank,
|
getClassRank,
|
||||||
|
getOnlineCount,
|
||||||
getRank,
|
getRank,
|
||||||
getUserClassRank,
|
getUserClassRank,
|
||||||
getClassPK,
|
getClassPK,
|
||||||
@@ -53,6 +54,8 @@ const query = reactive({
|
|||||||
})
|
})
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const rankChart = ref<Rank[]>([])
|
const rankChart = ref<Rank[]>([])
|
||||||
|
/** 全站在线人数。只是个聚合数字;「谁在线」是每行的 isOnline,服务端只对老师下发 */
|
||||||
|
const onlineCount = ref(0)
|
||||||
const activityChart = ref<Rank[]>([])
|
const activityChart = ref<Rank[]>([])
|
||||||
const duration = ref("months:1")
|
const duration = ref("months:1")
|
||||||
const classData = ref<ClassRank[]>([])
|
const classData = ref<ClassRank[]>([])
|
||||||
@@ -182,6 +185,14 @@ const columns: DataTableColumn<Rank>[] = [
|
|||||||
width: 240,
|
width: 240,
|
||||||
render: (row) =>
|
render: (row) =>
|
||||||
h("div", { style: "display:flex;align-items:center;gap:6px" }, [
|
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(
|
h(
|
||||||
NButton,
|
NButton,
|
||||||
{
|
{
|
||||||
@@ -251,6 +262,11 @@ watch(
|
|||||||
)
|
)
|
||||||
watch(duration, listActivity)
|
watch(duration, listActivity)
|
||||||
|
|
||||||
|
async function listOnline() {
|
||||||
|
const res = await getOnlineCount()
|
||||||
|
onlineCount.value = res.count
|
||||||
|
}
|
||||||
|
|
||||||
async function listActivity() {
|
async function listActivity() {
|
||||||
const current = Date.now()
|
const current = Date.now()
|
||||||
const start = formatISO(sub(current, subOptions.value))
|
const start = formatISO(sub(current, subOptions.value))
|
||||||
@@ -262,6 +278,7 @@ async function listActivity() {
|
|||||||
acceptedNumber: d.count,
|
acceptedNumber: d.count,
|
||||||
submissionNumber: 0,
|
submissionNumber: 0,
|
||||||
mood: null,
|
mood: null,
|
||||||
|
isOnline: null,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,6 +303,7 @@ onMounted(() => {
|
|||||||
// 再单发一次一模一样的 /rankings/users 只会让这张图排在日活后面出来。
|
// 再单发一次一模一样的 /rankings/users 只会让这张图排在日活后面出来。
|
||||||
// 图只在挂载时定一次,翻页/改每页条数不该动它。
|
// 图只在挂载时定一次,翻页/改每页条数不该动它。
|
||||||
init().then((results) => (rankChart.value = results.slice(0, 10)))
|
init().then((results) => (rankChart.value = results.slice(0, 10)))
|
||||||
|
listOnline()
|
||||||
listActivity()
|
listActivity()
|
||||||
listClassRank()
|
listClassRank()
|
||||||
listMyClassRank()
|
listMyClassRank()
|
||||||
@@ -523,6 +541,11 @@ watch(
|
|||||||
</n-grid>
|
</n-grid>
|
||||||
<n-card>
|
<n-card>
|
||||||
<template #header>全服 Top100</template>
|
<template #header>全服 Top100</template>
|
||||||
|
<template #header-extra>
|
||||||
|
<n-tag v-if="onlineCount > 0" round :bordered="false" type="success">
|
||||||
|
当前在线 {{ onlineCount }} 人
|
||||||
|
</n-tag>
|
||||||
|
</template>
|
||||||
<n-data-table
|
<n-data-table
|
||||||
:data="data"
|
:data="data"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
|
|||||||
@@ -396,7 +396,7 @@ export type ContestRank = Omit<
|
|||||||
submissionInfo: { [key: string]: SubmissionInfo }
|
submissionInfo: { [key: string]: SubmissionInfo }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { WebsiteConfig } from "@oj2/contract"
|
export type { WebsiteConfig, OnlineCount } from "@oj2/contract"
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
JudgeServer as Server,
|
JudgeServer as Server,
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ export const rankProfileSchema = z.object({
|
|||||||
acceptedNumber: z.number().int(),
|
acceptedNumber: z.number().int(),
|
||||||
submissionNumber: z.number().int(),
|
submissionNumber: z.number().int(),
|
||||||
mood: z.string().nullable(),
|
mood: z.string().nullable(),
|
||||||
|
/**
|
||||||
|
* 在线与否。**null 表示「这个调用方不该知道」** —— 学生之间互相盯着谁在刷题
|
||||||
|
* 不合适,所以只对老师及以上下发 true/false,其余一律 null。
|
||||||
|
* 三态是有意的:写成 boolean 的话,学生看到的 false 和真的离线分不开。
|
||||||
|
*/
|
||||||
|
isOnline: z.boolean().nullable().default(null),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ export const websiteConfigSchema = z.object({
|
|||||||
enableMaxkb: z.boolean(),
|
enableMaxkb: z.boolean(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 当前在线人数。只有聚合值 —— 「某某在不在线」是个人状态,不往匿名接口放 */
|
||||||
|
export const onlineCountSchema = z.object({
|
||||||
|
count: z.number().int().nonnegative(),
|
||||||
|
})
|
||||||
|
|
||||||
export const quoteSchema = z.union([
|
export const quoteSchema = z.union([
|
||||||
z.string(),
|
z.string(),
|
||||||
z.record(z.string(), z.unknown()),
|
z.record(z.string(), z.unknown()),
|
||||||
@@ -18,3 +23,4 @@ export const quoteSchema = z.union([
|
|||||||
|
|
||||||
export type WebsiteConfig = z.infer<typeof websiteConfigSchema>
|
export type WebsiteConfig = z.infer<typeof websiteConfigSchema>
|
||||||
export type Quote = z.infer<typeof quoteSchema>
|
export type Quote = z.infer<typeof quoteSchema>
|
||||||
|
export type OnlineCount = z.infer<typeof onlineCountSchema>
|
||||||
|
|||||||
Reference in New Issue
Block a user