Compare commits

..
3 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5 6a438872b9 feat(排行榜): 页头显示当前在线人数,在线绿点只给老师
Deploy / deploy (push) Has been cancelled
新增匿名可读的 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
2026-09-07 19:36:12 -06:00
xuyueandClaude Opus 5 856b7a280e 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
2026-09-07 19:30:45 -06:00
xuyueandClaude Opus 5 5222e012e1 fix(比赛): 修四处 —— 倒计时两倍速、排名不自动刷新、题目状态恒空、比赛隐藏后审核页取不到代码
查比赛功能时实跑出来的四个问题,都在这一条里修掉:

**倒计时两倍速**(store/contest.ts)。init() 里 setInterval 之前不清旧表,而
detail.vue 在「未开始 → 进行中」那一刻会再 init 一次(为了捞开赛后才拿得到的题),
于是两个 interval 一起给 now 加 1000。学生赛前挂着页面就会中招:一场 60 分钟的
比赛,真过了 30 分钟页面就显示「已结束」、倒计时归零,而服务端还在正常收提交。
ojnext 里就有,是原样搬过来的。

**排名页「开启自动刷新」开着但不刷新**(contest/pages/rank.vue)。useIntervalFn
传的是 immediate: false,而 watch(autoRefresh) 只在开关变化时才 resume ——
开关初值就是 true、进页面不产生变化,表从没启动过,得手动关一次再开。改成
watchEffect,由「开关 + 比赛进行中」共同驱动,顺带不再在赛后空转轮询。同样来自
ojnext。

**比赛题的 myStatus 恒为 null**(routes/contest.ts)。判题其实把状态记进了
user_profile 的 acm_problems_status.contest_problems,只是这两条路由硬编码下发
空值,于是题目页的「状态」列永远是「未做」,赛后也不恢复。旧后端在赛后/管理员
视角是给的,这是回归。不按赛中赛后分档:这是学生自己的判题结果,不泄露别人任何
信息(旧后端赛中不给,只是因为它整条路换了个 serializer)。

**比赛一隐藏,审核页的「查看代码」必 404**(services/contest.ts)。acm-helper
故意不卡 visible(赛后核查恰恰发生在比赛收起来之后),它调的比赛提交列表却卡着,
两边对不上。findVisibleContest 换成 findAccessibleContest:公开的谁都取得到,
隐藏的只有比赛管理员取得到,学生看隐藏比赛照旧 404。

实跑验证(dev 全栈,判题走临时 worker 绕开本机 token 不一致):

- 浏览器跨过开赛时刻挂着不动 —— 墙钟 20.0 秒,倒计时正好减 20 秒(原来会减 40)。
- 排名页停在「无数据」,另一账号提交一发 AC,5 秒内表格自己长出
  `1 student2 1/3 0:00:42`,没刷新页面。
- 学生 AC 后列表和详情都回 myStatus: 0,没做过的另一个学生仍是 null,匿名照旧 401。
- 比赛隐藏 + 已结束:出题人 detail / problems / rank / submissions / acm-helper
  全 200,学生这 5 条全 404,提交也 404,隐藏比赛不进公开列表。
- 排名记账口径未受影响:10 次提交(8 编译失败 + 2 AC)落库 submission_number=9、
  accepted_number=1、total_time=231=ac_time、is_first_ac=true。

tsc / vue-tsc / check:routes(175 条无遮蔽)均干净,测试数据已清库。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xu912Rv5JUUuy6MqMcQW2
2026-09-07 19:19:29 -06:00
18 changed files with 249 additions and 41 deletions
+62
View File
@@ -0,0 +1,62 @@
import type { ChainableCommander } from "ioredis"
import { redis } from "../redis"
/**
* 「谁现在在线」。member = userIdscore = 最后一次活动的毫秒时间戳。
*
* 会话本身判定不了在线:`session:<token>` 的 TTL 是 7 天且每次请求都续期,
* 「有会话」只说明这人一周内来过。所以这里单独记一个活动时间戳 ——
* 写入一律搭在已有的 pipeline 上(登录、每个带鉴权的请求、WebSocket 巡检),
* 不多一趟往返。
*/
const PRESENCE_KEY = "online-users"
/**
* 多久没动就算离线。挂着页面不操作的人靠 sweepSessions 每 60 秒续一次
* (见 websocket.ts),窗口必须明显大于那个间隔,否则开着页面的学生会一闪一闪。
*/
const ONLINE_WINDOW_MS = 5 * 60 * 1000
/** 记一笔活动。传 pipeline 而不是自己发命令:调用点都在热路径上 */
export function markOnline(pipeline: ChainableCommander, userId: number) {
pipeline.zadd(PRESENCE_KEY, Date.now(), String(userId))
}
/**
* 当前在线的用户 id。
*
* 顺手把过期成员删掉 —— 这是唯一的清理时机(整个 key 不能设 TTL:ZADD 不会重置
* key 的 TTL,到期会把还在线的人一起抹掉)。读这张表的只有后台用户列表,
* 不清理最坏也就是攒下全站用户数量级的成员,远谈不上要单开一个定时任务。
*/
export async function onlineUserIds() {
const cutoff = Date.now() - ONLINE_WINDOW_MS
const results = await redis
.pipeline()
.zremrangebyscore(PRESENCE_KEY, "-inf", `(${cutoff}`)
.zrange(PRESENCE_KEY, "0", "-1")
.exec()
const members = (results?.[1]?.[1] ?? []) as string[]
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))
}
/** 单个用户在不在线。列表页用上面那个,别在循环里调这个 */
export async function isUserOnline(userId: number) {
const score = await redis.zscore(PRESENCE_KEY, String(userId))
return score !== null && Number(score) >= Date.now() - ONLINE_WINDOW_MS
}
+17 -7
View File
@@ -14,6 +14,7 @@ import { deleteCookie, getCookie, setCookie } from "hono/cookie"
import { config } from "../config" import { config } from "../config"
import { db, schema } from "../db" import { db, schema } from "../db"
import { publishSessionRevoked, type SessionRevokedReason } from "../events" import { publishSessionRevoked, type SessionRevokedReason } from "../events"
import { clearOnline, markOnline } from "./presence"
import { redis } from "../redis" import { redis } from "../redis"
const SESSION_PREFIX = "session:" const SESSION_PREFIX = "session:"
@@ -74,12 +75,13 @@ export async function createSession(
} }
// 三条写进一个 pipeline:一个班四十号人同时登录时,三趟往返和一趟的差别 // 三条写进一个 pipeline:一个班四十号人同时登录时,三趟往返和一趟的差别
// 全压在登录这一下上 // 全压在登录这一下上
await redis const pipeline = redis
.pipeline() .pipeline()
.set(sessionKey(token), JSON.stringify(value), "EX", config.sessionTtlSeconds) .set(sessionKey(token), JSON.stringify(value), "EX", config.sessionTtlSeconds)
.sadd(userSessionsKey(userId), token) .sadd(userSessionsKey(userId), token)
.expire(userSessionsKey(userId), config.sessionTtlSeconds) .expire(userSessionsKey(userId), config.sessionTtlSeconds)
.exec() markOnline(pipeline, userId)
await pipeline.exec()
setCookie(c, config.sessionCookie, token, { setCookie(c, config.sessionCookie, token, {
httpOnly: true, httpOnly: true,
sameSite: "Lax", sameSite: "Lax",
@@ -96,7 +98,10 @@ export async function destroySession(c: Context) {
// 先读出 userId 再删,否则反向索引里会留下一个永远清不掉的成员 // 先读出 userId 再删,否则反向索引里会留下一个永远清不掉的成员
const userId = await sessionUserId(token) const userId = await sessionUserId(token)
await redis.del(sessionKey(token)) await redis.del(sessionKey(token))
if (userId !== null) await redis.srem(userSessionsKey(userId), token) if (userId !== null) {
await redis.srem(userSessionsKey(userId), token)
await clearOnline(userId)
}
} }
deleteCookie(c, config.sessionCookie, { path: "/" }) deleteCookie(c, config.sessionCookie, { path: "/" })
return token ?? null return token ?? null
@@ -128,6 +133,7 @@ export async function revokeUserSessions(
const tokens = await redis.smembers(userSessionsKey(userId)) const tokens = await redis.smembers(userSessionsKey(userId))
if (tokens.length) await redis.del(...tokens.map(sessionKey)) if (tokens.length) await redis.del(...tokens.map(sessionKey))
await redis.del(userSessionsKey(userId)) await redis.del(userSessionsKey(userId))
await clearOnline(userId)
await publishSessionRevoked({ userId }, reason) await publishSessionRevoked({ userId }, reason)
return tokens.length return tokens.length
} }
@@ -196,11 +202,13 @@ async function getUserByToken(token: string | undefined): Promise<SessionResult>
// 反向索引跟着会话一起续期,否则活跃用户的索引会先于会话到期, // 反向索引跟着会话一起续期,否则活跃用户的索引会先于会话到期,
// 之后再吊销就找不到这张会话了。两条走一次 pipeline —— 这是全后端最热的 Redis // 之后再吊销就找不到这张会话了。两条走一次 pipeline —— 这是全后端最热的 Redis
// 路径,每个带鉴权的请求都要走一趟,形状和 touchSession 里那对保持一致 // 路径,每个带鉴权的请求都要走一趟,形状和 touchSession 里那对保持一致
await redis const renew = redis
.pipeline() .pipeline()
.expire(sessionKey(token), config.sessionTtlSeconds) .expire(sessionKey(token), config.sessionTtlSeconds)
.expire(userSessionsKey(session.userId), config.sessionTtlSeconds) .expire(userSessionsKey(session.userId), config.sessionTtlSeconds)
.exec() // 在线状态就是搭在这条 pipeline 上记的,见 presence.ts
markOnline(renew, session.userId)
await renew.exec()
// 唯一的收窄点。库里是 text 列,认不出来的值降成最低权限,见 toAdminType 的注释。 // 唯一的收窄点。库里是 text 列,认不出来的值降成最低权限,见 toAdminType 的注释。
return { return {
user: { user: {
@@ -250,11 +258,13 @@ export function readRequestSessionToken(request: Request) {
*/ */
export async function touchSession(token: string, userId: number) { export async function touchSession(token: string, userId: number) {
if (!token) return false if (!token) return false
const results = await redis const pipeline = redis
.pipeline() .pipeline()
.expire(sessionKey(token), config.sessionTtlSeconds) .expire(sessionKey(token), config.sessionTtlSeconds)
.expire(userSessionsKey(userId), config.sessionTtlSeconds) .expire(userSessionsKey(userId), config.sessionTtlSeconds)
.exec() // 只挂着 WebSocket 不发请求的人,在线状态全靠这里(sweepSessions 每 60 秒一轮)
markOnline(pipeline, userId)
const results = await pipeline.exec()
// 索引那条的返回值不看:存量会话(反向索引上线之前签发的)本来就没有索引键, // 索引那条的返回值不看:存量会话(反向索引上线之前签发的)本来就没有索引键,
// 续不到很正常,不能因此判定会话已死 // 续不到很正常,不能因此判定会话已死
return results?.[0]?.[1] === 1 return results?.[0]?.[1] === 1
+8 -4
View File
@@ -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,
}) })
} }
+21 -5
View File
@@ -18,6 +18,7 @@ import { and, asc, count, desc, eq, ilike, inArray, ne, or, sql } from "drizzle-
import { Hono } from "hono" import { Hono } from "hono"
import { hashPassword } from "../../auth/password" import { hashPassword } from "../../auth/password"
import { isUserOnline, onlineUserIds } from "../../auth/presence"
import { revokeUserSessions } from "../../auth/session" import { revokeUserSessions } from "../../auth/session"
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware" import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
import { db, schema } from "../../db" import { db, schema } from "../../db"
@@ -64,7 +65,7 @@ function normalizePermission(adminType: AdminType, requested: ProblemPermission)
function serialize(row: { function serialize(row: {
user: typeof schema.user.$inferSelect user: typeof schema.user.$inferSelect
realName: string | null realName: string | null
}) { }, isOnline: boolean) {
return adminUserSchema.parse({ return adminUserSchema.parse({
id: row.user.id, id: row.user.id,
username: row.user.username, username: row.user.username,
@@ -75,6 +76,7 @@ function serialize(row: {
createTime: row.user.createTime, createTime: row.user.createTime,
lastLogin: row.user.lastLogin, lastLogin: row.user.lastLogin,
isDisabled: row.user.isDisabled, isDisabled: row.user.isDisabled,
isOnline,
rawPassword: row.user.rawPassword, rawPassword: row.user.rawPassword,
className: row.user.className, className: row.user.className,
}) })
@@ -153,8 +155,22 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
)!) )!)
} }
const where = filters.length ? and(...filters) : undefined const where = filters.length ? and(...filters) : undefined
// 在线状态每行都要下发(列表里显示),所以不管怎么排都先取一次
const online = await onlineUserIds()
const orderBy = c.req.query("orderBy")
// 「最近登录」排序要把从未登录的排在最后,否则一堆 null 顶在最前面,这个排序就没用了 // 「最近登录」排序要把从未登录的排在最后,否则一堆 null 顶在最前面,这个排序就没用了
const order = c.req.query("orderBy") === "-lastLogin" //
// 「在线优先」没有对应的库表列 —— 在线只存在于 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`] ? [sql`${schema.user.lastLogin} desc nulls last`]
: [desc(schema.user.createTime)] : [desc(schema.user.createTime)]
@@ -166,7 +182,7 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
.orderBy(...order, asc(schema.user.id)).limit(limit).offset(offset), .orderBy(...order, asc(schema.user.id)).limit(limit).offset(offset),
]) ])
return success(c, adminUserListSchema.parse({ 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, total: totalRows[0]?.value ?? 0,
})) }))
}) })
@@ -174,7 +190,7 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
adminAccountRoutes.get("/users/:id", requireSuperAdmin, async (c) => { adminAccountRoutes.get("/users/:id", requireSuperAdmin, async (c) => {
const [row] = await selectUser(queryInteger(c.req.param("id"), 0, { min: 1 })) 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") 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) => { adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
@@ -239,7 +255,7 @@ adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
} }
const [row] = await selectUser(id) const [row] = await selectUser(id)
return success(c, serialize(row!)) return success(c, serialize(row!, await isUserOnline(id)))
}) })
adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => { adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
+35 -7
View File
@@ -22,7 +22,7 @@ import {
checkContestPassword, checkContestPassword,
contestDetailsAllowed, contestDetailsAllowed,
contestStatus, contestStatus,
findVisibleContest, findAccessibleContest,
isContestAdmin, isContestAdmin,
requireContestAccess, requireContestAccess,
type ContestEnv, type ContestEnv,
@@ -91,8 +91,10 @@ contestRoutes.get("/contests", async (c) => {
})) }))
}) })
contestRoutes.get("/contests/:id", async (c) => { // optionalAuth 是为了下面那句 findAccessibleContest 认得出「这是出题人自己」——
const contest = await findVisibleContest(queryInteger(c.req.param("id"), 0, { min: 1 })) // 隐藏的比赛只有他看得到详情,匿名访问照旧当作不存在
contestRoutes.get("/contests/:id", optionalAuth, async (c) => {
const contest = await findAccessibleContest(c.get("user"), queryInteger(c.req.param("id"), 0, { min: 1 }))
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist") if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
const byId = await creators([contest.createdById]) const byId = await creators([contest.createdById])
return success(c, serializeContest( return success(c, serializeContest(
@@ -103,7 +105,7 @@ contestRoutes.get("/contests/:id", async (c) => {
}) })
contestRoutes.post("/contests/:id/access", requireAuth, async (c) => { contestRoutes.post("/contests/:id/access", requireAuth, async (c) => {
const contest = await findVisibleContest(queryInteger(c.req.param("id"), 0, { min: 1 })) const contest = await findAccessibleContest(c.get("user"), queryInteger(c.req.param("id"), 0, { min: 1 }))
if (!contest || !contest.password) return failure(c, 404, "contest-not-found", "Contest does not exist") if (!contest || !contest.password) return failure(c, 404, "contest-not-found", "Contest does not exist")
const parsed = contestPasswordRequestSchema.safeParse(await c.req.json().catch(() => null)) const parsed = contestPasswordRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "Password is required") if (!parsed.success) return failure(c, 400, "invalid-request", "Password is required")
@@ -115,12 +117,35 @@ contestRoutes.post("/contests/:id/access", requireAuth, async (c) => {
}) })
contestRoutes.get("/contests/:id/access", requireAuth, async (c) => { contestRoutes.get("/contests/:id/access", requireAuth, async (c) => {
const contest = await findVisibleContest(queryInteger(c.req.param("id"), 0, { min: 1 })) const contest = await findAccessibleContest(c.get("user"), queryInteger(c.req.param("id"), 0, { min: 1 }))
if (!contest || !contest.password) return failure(c, 404, "contest-not-found", "Contest does not exist") if (!contest || !contest.password) return failure(c, 404, "contest-not-found", "Contest does not exist")
const access = await canAccessContest(c, contest, "details") const access = await canAccessContest(c, contest, "details")
return success(c, contestAccessSchema.parse({ access: access.ok })) return success(c, contestAccessSchema.parse({ access: access.ok }))
}) })
/**
* 当前用户在**比赛题**上的做题状态。判题回写记在 user_profile 的
* `acm_problems_status.contest_problems`judge/run.ts),公开题库那份记在 `problems`
* 下,两边互不干扰。
*
* 原来这两条路由一律下发空状态,于是比赛题目页的「状态」列永远是「未做」,赛后也不
* 恢复 —— 而库里其实一直记着。
*
* 不按「比赛结没结束」分档:这是学生自己的判题结果,赛中赛后都不泄露别人的任何信息
* (旧后端赛中不下发,纯粹是因为它整条路换了个 serializer,不是什么保密考虑)。
*/
async function contestProblemStatuses(userId: number | undefined) {
if (!userId) return {}
const [profile] = await db.select({ status: schema.userProfile.acmProblemsStatus })
.from(schema.userProfile).where(eq(schema.userProfile.userId, userId)).limit(1)
return objectValue(objectValue(profile?.status).contest_problems)
}
function myStatusOf(statuses: Record<string, unknown>, problemId: number) {
const status = objectValue(statuses[String(problemId)]).status
return typeof status === "number" ? status : null
}
async function contestProblemTags(problemIds: number[]) { async function contestProblemTags(problemIds: number[]) {
if (problemIds.length === 0) return new Map<number, string[]>() if (problemIds.length === 0) return new Map<number, string[]>()
const rows = await db.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name }) const rows = await db.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name })
@@ -139,6 +164,7 @@ contestRoutes.get("/contests/:id/problems", optionalAuth, requireContestAccess("
.where(and(eq(schema.problem.contestId, contest.id), eq(schema.problem.visible, true))).orderBy(asc(schema.problem.displayId)) .where(and(eq(schema.problem.contestId, contest.id), eq(schema.problem.visible, true))).orderBy(asc(schema.problem.displayId))
const tags = await contestProblemTags(rows.map((row) => row.problem.id)) const tags = await contestProblemTags(rows.map((row) => row.problem.id))
const allowed = contestDetailsAllowed(c.get("user"), contest) const allowed = contestDetailsAllowed(c.get("user"), contest)
const statuses = await contestProblemStatuses(c.get("user")?.id)
return success(c, rows.map(({ problem, user, realName }) => problemListItemSchema.parse({ return success(c, rows.map(({ problem, user, realName }) => problemListItemSchema.parse({
id: problem.id, id: problem.id,
_id: problem.displayId, _id: problem.displayId,
@@ -152,7 +178,7 @@ contestRoutes.get("/contests/:id/problems", optionalAuth, requireContestAccess("
allowFlowchart: problem.allowFlowchart, allowFlowchart: problem.allowFlowchart,
showFlowchart: problem.showFlowchart, showFlowchart: problem.showFlowchart,
hasAstRules: problem.astRules !== null, hasAstRules: problem.astRules !== null,
myStatus: null, myStatus: myStatusOf(statuses, problem.id),
}))) })))
}) })
@@ -165,6 +191,7 @@ contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireCont
if (!row) return failure(c, 404, "problem-not-found", "Problem does not exist") if (!row) return failure(c, 404, "problem-not-found", "Problem does not exist")
const tags = await contestProblemTags([row.problem.id]) const tags = await contestProblemTags([row.problem.id])
const allowed = contestDetailsAllowed(c.get("user"), contest) const allowed = contestDetailsAllowed(c.get("user"), contest)
const statuses = await contestProblemStatuses(c.get("user")?.id)
return success(c, problemDetailSchema.parse({ return success(c, problemDetailSchema.parse({
id: row.problem.id, id: row.problem.id,
_id: row.problem.displayId, _id: row.problem.displayId,
@@ -189,7 +216,8 @@ contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireCont
contestId: contest.id, contestId: contest.id,
tags: tags.get(row.problem.id) ?? [], tags: tags.get(row.problem.id) ?? [],
createdBy: sampleUser(row.user, row.realName), createdBy: sampleUser(row.user, row.realName),
myStatus: null, myStatus: myStatusOf(statuses, row.problem.id),
// 比赛里不给 AI 提示(POST /ai/hint 见到比赛提交直接 403),这个数只喂那个按钮,恒 0
myFailedCount: 0, myFailedCount: 0,
allowFlowchart: row.problem.allowFlowchart, allowFlowchart: row.problem.allowFlowchart,
showFlowchart: row.problem.showFlowchart, showFlowchart: row.problem.showFlowchart,
+10 -1
View File
@@ -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" },
+2 -2
View File
@@ -27,7 +27,7 @@ import { judgeQueue } from "../queue"
import { import {
canAccessContest, canAccessContest,
contestStatus, contestStatus,
findVisibleContest, findAccessibleContest,
isContestAdmin, isContestAdmin,
requireContestAccess, requireContestAccess,
type ContestEnv, type ContestEnv,
@@ -68,7 +68,7 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => {
if (parsed.data.contestId) { if (parsed.data.contestId) {
// 这里用不了 requireContestAccess 中间件:比赛 id 来自请求体, // 这里用不了 requireContestAccess 中间件:比赛 id 来自请求体,
// 中间件跑的时候 body 还没解析。全仓只有这一处仍是手工调用,改动时留意别漏掉鉴权。 // 中间件跑的时候 body 还没解析。全仓只有这一处仍是手工调用,改动时留意别漏掉鉴权。
const contest = await findVisibleContest(parsed.data.contestId) const contest = await findAccessibleContest(c.get("user"), parsed.data.contestId)
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist") if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
const access = await canAccessContest(c, contest, "problems") const access = await canAccessContest(c, contest, "problems")
if (!access.ok) return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message) if (!access.ok) return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message)
+16 -5
View File
@@ -1,6 +1,6 @@
import { createHash } from "node:crypto" import { createHash } from "node:crypto"
import { and, eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import type { Context, MiddlewareHandler } from "hono" import type { Context, MiddlewareHandler } from "hono"
import type { AppEnv } from "../auth/middleware" import type { AppEnv } from "../auth/middleware"
@@ -48,10 +48,21 @@ export function checkContestPassword(candidate: string | null | undefined, expec
return signature === expectedSignature && Date.now() < Number(expiresAt) * 1000 return signature === expectedSignature && Date.now() < Number(expiresAt) * 1000
} }
export async function findVisibleContest(id: number) { /**
* 取一场「这个人看得见」的比赛:公开(visible)的谁都取得到,隐藏的只有比赛管理员
* (出题人本人 / 超管)取得到,对其余人一律当作不存在。
*
* 原来这里一律卡 visible,于是老师赛后把比赛收起来之后,核查页的「查看代码」必然 404:
* 那个页面自己**故意不卡** visible(赛后核查恰恰发生在比赛收起来之后,见
* admin/contest.ts 的说明),它调的比赛提交列表却卡着,两边对不上。
*
* 放宽的只有出题人自己的视角,学生看隐藏比赛照旧是 404。
*/
export async function findAccessibleContest(user: AuthUser | null | undefined, id: number) {
const [contest] = await db.select().from(schema.contest) const [contest] = await db.select().from(schema.contest)
.where(and(eq(schema.contest.id, id), eq(schema.contest.visible, true))).limit(1) .where(eq(schema.contest.id, id)).limit(1)
return contest ?? null if (!contest) return null
return contest.visible || isContestAdmin(user, contest) ? contest : null
} }
// 泛型而不是写死 Context<AppEnv>requireContestAccess 传进来的是 Context<ContestEnv> // 泛型而不是写死 Context<AppEnv>requireContestAccess 传进来的是 Context<ContestEnv>
@@ -93,7 +104,7 @@ export function requireContestAccess(
): MiddlewareHandler<ContestEnv> { ): MiddlewareHandler<ContestEnv> {
return async (c, next) => { return async (c, next) => {
const id = Number(c.req.param(paramName)) const id = Number(c.req.param(paramName))
const contest = Number.isInteger(id) && id > 0 ? await findVisibleContest(id) : null const contest = Number.isInteger(id) && id > 0 ? await findAccessibleContest(c.get("user"), id) : null
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist") if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
const access = await canAccessContest(c, contest, checkType) const access = await canAccessContest(c, contest, checkType)
if (!access.ok) { if (!access.ok) {
+2 -1
View File
@@ -150,7 +150,8 @@ export function getUserList(
orderBy = "", orderBy = "",
) { ) {
return api.get<AdminUserList>("admin/users", { return api.get<AdminUserList>("admin/users", {
// 旧接口的 order_by 只有 "-last_login" 一个取值 // "-last_login" 是旧接口传下来的取值(路由 query 里可能还存着),改叫 "-lastLogin"
// "-online" 是新增的,原样透传
params: { params: {
offset, offset,
limit, limit,
+11 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { DataTableRowKey, SelectOption } from "naive-ui" import { DataTableRowKey, NFlex, NTag, SelectOption } from "naive-ui"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination" import { usePagination } from "shared/composables/pagination"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
@@ -47,6 +47,7 @@ const adminOptions = [
const sortOptions = [ const sortOptions = [
{ label: "默认排序", value: "" }, { label: "默认排序", value: "" },
{ label: "最近登录", value: "-last_login" }, { label: "最近登录", value: "-last_login" },
{ label: "在线优先", value: "-online" },
] ]
const [create, toggleCreate] = useToggle(false) const [create, toggleCreate] = useToggle(false)
const password = ref("") const password = ref("")
@@ -87,11 +88,18 @@ const columns: DataTableColumn<User>[] = [
{ {
title: "上次登录", title: "上次登录",
key: "last_login", key: "last_login",
width: 200, width: 240,
// 在线是 5 分钟内有过活动(后端 auth/presence.ts),不是「有会话」——
// 会话能留 7 天。上次登录时间照旧显示,在线的人前面多一个标记
render: (row) => render: (row) =>
h(NFlex, { align: "center", size: "small" }, () => [
row.isOnline
? h(NTag, { type: "success", size: "small" }, () => "在线")
: null,
row.lastLogin row.lastLogin
? parseTime(row.lastLogin, "YYYY-MM-DD HH:mm:ss") ? parseTime(row.lastLogin, "YYYY-MM-DD HH:mm:ss")
: "从未登录", : "从未登录",
]),
}, },
{ {
title: "真名", title: "真名",
@@ -188,6 +196,7 @@ function createNewUser() {
createTime: null, createTime: null,
lastLogin: null, lastLogin: null,
isDisabled: false, isDisabled: false,
isOnline: false,
rawPassword: null, rawPassword: null,
className: null, className: null,
password: "", password: "",
+6
View File
@@ -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,
+10 -1
View File
@@ -272,7 +272,16 @@ async function downloadExcel() {
// //
watch([() => query.page, () => query.limit], listRanks) watch([() => query.page, () => query.limit], listRanks)
watch(autoRefresh, (checked) => (checked ? resume() : pause()))
// +
// watch(autoRefresh) true
// useIntervalFn immediate: false
//
watchEffect(() => {
const running = contestStore.contestStatus === ContestStatus.underway
if (autoRefresh.value && running) resume()
else pause()
})
onMounted(() => { onMounted(() => {
listRanks() listRanks()
+23
View File
@@ -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"
+6
View File
@@ -61,6 +61,11 @@ export const useContestStore = defineStore("contest", () => {
contest.value = res contest.value = res
// now 是学生侧比赛专有的服务器时间,用来对齐倒计时 // now 是学生侧比赛专有的服务器时间,用来对齐倒计时
now.value = getTime(parseISO(res.now ?? res.createTime)) now.value = getTime(parseISO(res.now ?? res.createTime))
// 先停掉上一轮的表。init() 会被调第二次:detail.vue 在「未开始 → 进行中」那一刻
// 重新 init 一次(为了把开赛后才拿得到的题目捞回来),不清的话两个 setInterval
// 一起给 now 加 1000,倒计时变两倍速 —— 学生赛前挂着页面就会中招,一场 60 分钟的
// 比赛过了 30 分钟页面就显示「已结束」,而服务端其实还在正常收提交。
if (timer) clearInterval(timer)
if (contestStatus.value !== ContestStatus.finished) { if (contestStatus.value !== ContestStatus.finished) {
timer = setInterval(() => { timer = setInterval(() => {
now.value = now.value + 1000 now.value = now.value + 1000
@@ -79,6 +84,7 @@ export const useContestStore = defineStore("contest", () => {
toggleAccess(false) toggleAccess(false)
now.value = 0 now.value = 0
if (timer) clearInterval(timer) if (timer) clearInterval(timer)
timer = 0
} }
async function checkPassword(contestID: string, password: string) { async function checkPassword(contestID: string, password: string) {
+1 -1
View File
@@ -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,
+6
View File
@@ -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),
}) })
/** /**
+2
View File
@@ -209,6 +209,8 @@ export const adminUserSchema = z.object({
createTime: z.string().nullable(), createTime: z.string().nullable(),
lastLogin: z.string().nullable(), lastLogin: z.string().nullable(),
isDisabled: z.boolean(), isDisabled: z.boolean(),
// 在线与否不在库里,是从 Redis 的活动时间戳算出来的(api 的 auth/presence.ts
isOnline: z.boolean(),
// 明文密码。是有意保留的运营需求:老师要能查学生的密码。 // 明文密码。是有意保留的运营需求:老师要能查学生的密码。
// 只在超管专属的这一个接口下发,别往任何其它地方复制。 // 只在超管专属的这一个接口下发,别往任何其它地方复制。
rawPassword: z.string().nullable(), rawPassword: z.string().nullable(),
+6
View File
@@ -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>