feat(排名): 排名页重做成领奖台加全班赛道,一眼看出自己第几、该追谁、谁在追自己
Deploy / deploy (push) Canceled after 0s

- 一张榜两个开关:本班 / 本年级 / 全服 × 这周 / 这学期 / 全部,默认本班 · 这学期。
  原来最大那张全服 Top100 里,这学期在用的 25 级学生一个都进不去
- 做对一样多的不再按「提交少的在前」排,改成先做到的在前;名次旁边写比周一升降了几名
- 「你」那张卡:第几名、再做对几道上领奖台,要追的人早你几秒、追你的人晚你几秒
- 本年级 / 全服只列前面一段和你附近一段,中间折起来,点开再看
- 班级对抗按人均做对比,另有我和对手的名次走势、这周本班、每周冠军
- 老师不上榜,选班看;每行「⋯」能把怀疑抄代码的学生设成不计入排名(新增 user.rank_hidden_at,
  首页周榜同样不算他),随时能恢复
- 去掉全服 Top10、日活两张图、在线人数和班级详情弹框里的统计词,以及它们背后的旧接口

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-10 07:26:15 +08:00
1 parent 0cee4ef779
commit 1c6e2b9d82
31 files changed
+6633 -1392

No files matched your search

-9
View File
@@ -41,15 +41,6 @@ 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))
@@ -0,0 +1 @@
ALTER TABLE "user" ADD COLUMN "rank_hidden_at" timestamp with time zone;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -211,6 +211,13 @@
"when": 1791555848163,
"tag": "0029_regrant_problemset_badges",
"breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1791587559546,
"tag": "0030_add_user_rank_hidden",
"breakpoints": true
}
]
}
+5
View File
@@ -1259,6 +1259,11 @@ export const user = pgTable(
problemPermission: text("problem_permission").notNull().$type<ProblemPermission>(),
rawPassword: varchar("raw_password", { length: 20 }),
className: text("class_name"),
/**
* 老师把这个学生「不计入排名」的时刻,null = 正常上榜。怀疑抄代码时用,随时能恢复
* (见 routes/ranking.ts)。只管排名页和首页周榜,比赛排名不受它影响
*/
rankHiddenAt: timestamp("rank_hidden_at", { withTimezone: true, mode: "string" }),
},
(table) => [
unique("user_username_key").on(table.username),
+2
View File
@@ -10,6 +10,7 @@ import { judgeServerRoutes } from "./routes/judge-server"
import { contestRoutes } from "./routes/contest"
import { contentRoutes } from "./routes/content"
import { classroomRoutes } from "./routes/classroom"
import { rankingRoutes } from "./routes/ranking"
import { problemsetRoutes } from "./routes/problemset"
import { achievementRoutes } from "./routes/achievement"
import { aiRoutes } from "./routes/ai"
@@ -36,6 +37,7 @@ app.route("/api", siteRoutes)
app.route("/api", contestRoutes)
app.route("/api", contentRoutes)
app.route("/api", classroomRoutes)
app.route("/api", rankingRoutes)
app.route("/api", problemsetRoutes)
app.route("/api", achievementRoutes)
app.route("/api", aiRoutes)
+7 -197
View File
@@ -5,25 +5,18 @@ import {
registerRequestSchema,
STUDENT_ROLES,
updateProfileRequestSchema,
type ActivityRankItem,
type KnowledgeLevel,
type KnowledgeMap,
type Metrics,
type MyRank,
type ProblemRank,
type RankProfile,
type UserRank,
type WeeklyRank,
type WeeklyRankItem,
} from "@oj2/contract"
import {
and,
asc,
count,
countDistinct,
desc,
eq,
gt,
gte,
inArray,
isNull,
@@ -31,7 +24,6 @@ import {
lte,
max,
min,
ne,
notExists,
or,
sql,
@@ -40,7 +32,6 @@ import { alias } from "drizzle-orm/pg-core"
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"
@@ -51,7 +42,7 @@ import { sniffImageExtension } from "../services/image"
import { getUserProfileById } from "../services/profile"
import { clientIp, countAttempt, lockoutRemaining, type AttemptRule } from "../services/throttling"
import { localTime, weekStart } from "../time"
import { isTeacherOrAbove, asRecord, queryInteger, sampleUser } from "./helpers"
import { asRecord, queryInteger, sampleUser } from "./helpers"
export const accountRoutes = new Hono<AppEnv>()
@@ -134,7 +125,7 @@ accountRoutes.post("/users", async (c) => {
accountRoutes.get("/profiles/:username", optionalAuth, async (c) => {
// 对齐旧后端 account/views/oj.py 的 UserProfileAPI.get 首行:
// `if not user.is_authenticated: return self.success()` —— 匿名一律返回空,
// 否则用户名可经 /rankings/users 公开枚举,进而无 cookie 批量收集全校学生的邮箱与最后登录时间。
// 否则用户名可经排名接口(/rankings/board)公开枚举,进而无 cookie 批量收集全校学生的邮箱与最后登录时间。
if (!c.get("user")) return success(c, null)
const [target] = await db
.select({ id: schema.user.id })
@@ -209,200 +200,17 @@ accountRoutes.get("/users/:id/metrics", async (c) => {
})
/**
* 全服榜单的大小。**写死在服务端,不接受调用方传** —— 上限是这个端点的属性,
* 不是调用方的选择。
*
* 之前它是个 `top` 查询参数,三个调用方各传各的(100 / 10 / 0),
* 而 top 又会覆盖 limit 与 offset,total 却按全量人数算 —— 于是分页器算出几十页、
* 页页内容相同(36e4ac2)。「全服 Top10」不需要另一个上限,取 limit=10&offset=0 即可;
* 后台那个「不限量」的用法搬去了 /admin/rankings/users。
*/
const LEADERBOARD_SIZE = 100
/** 入榜人群:正常状态的学生与学生管理员。教师和超管不参与排名。 */
const leaderboardWhere = and(
inArray(schema.user.adminType, [...STUDENT_ROLES]),
eq(schema.user.isDisabled, false),
)
/**
* 榜单排序:AC 多的在前 → 同 AC 时提交少的在前 → 再同就按 id。
*
* 第三档不是凑数:前两个键完全相同的学生在真实数据里成片存在(都是 0/0),
* 没有稳定的兜底键时 postgres 每次返回的顺序可以不同,翻页会看到重复或漏掉的人。
*/
const leaderboardOrder = [
desc(schema.userProfile.acceptedNumber),
asc(schema.userProfile.submissionNumber),
asc(schema.user.id),
]
accountRoutes.get("/rankings/users", optionalAuth, async (c) => {
const limit = queryInteger(c.req.query("limit"), 10, {
min: 1,
max: LEADERBOARD_SIZE,
})
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
// 榜单封顶 100 名,所以这一页最多还能取几条只取决于 offset,**不取决于总人数** ——
// 真人不够时数据库自己会少返回。不拿 total 当上限,三段查询就能并发发出去,
// 端点延迟从「四个来回相加」变成「最慢的那个」。越界页一条不剩,直接不发 SQL。
const pageLimit = Math.max(0, Math.min(limit, LEADERBOARD_SIZE - offset))
// 谁在线只给老师看,学生那边整列都是 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),
pageLimit === 0
? []
: db
.select({ profile: schema.userProfile, user: schema.user })
.from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
.where(leaderboardWhere)
.orderBy(...leaderboardOrder)
.limit(pageLimit)
.offset(offset),
myLeaderboardRank(c.get("user")?.id),
isTeacherOrAbove(c.get("user")) ? onlineUserIds() : null,
])
return success(c, {
results: rows.map((row) => serializeRankRow(row, online)),
total: Math.min(totalRow?.value ?? 0, LEADERBOARD_SIZE),
me,
} satisfies UserRank)
})
function serializeRankRow(
{
profile,
user,
}: {
profile: typeof schema.userProfile.$inferSelect
user: typeof schema.user.$inferSelect
},
online: Set<number> | null = null,
) {
return {
id: profile.id,
user: sampleUser(user, profile.realName),
acceptedNumber: profile.acceptedNumber,
submissionNumber: profile.submissionNumber,
mood: profile.mood,
isOnline: online ? online.has(user.id) : null,
} satisfies RankProfile
}
/**
* 「我」的全服名次,登录且身份入榜时才有。
*
* 名次 = 排在我前面的人数 + 1,三个排序键**逐级**比较,与列表的 orderBy 逐字对应 ——
* 少比一级就会出现「显示第 7 名、实际排在表格第 9 行」这种对不上的情况。
* 三个键全等才算并列,此时名次相同。
*/
async function myLeaderboardRank(userId: number | undefined) {
if (!userId) return null
const [mine] = await db
.select({ profile: schema.userProfile, user: schema.user })
.from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
.where(and(leaderboardWhere, eq(schema.user.id, userId)))
.limit(1)
if (!mine) return null
const { acceptedNumber, submissionNumber } = mine.profile
const [ahead] = await db
.select({ value: count() })
.from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
.where(
and(
leaderboardWhere,
or(
gt(schema.userProfile.acceptedNumber, acceptedNumber),
and(
eq(schema.userProfile.acceptedNumber, acceptedNumber),
lt(schema.userProfile.submissionNumber, submissionNumber),
),
and(
eq(schema.userProfile.acceptedNumber, acceptedNumber),
eq(schema.userProfile.submissionNumber, submissionNumber),
lt(schema.user.id, userId),
),
),
),
)
return {
...serializeRankRow(mine),
rank: (ahead?.value ?? 0) + 1,
} satisfies MyRank
}
accountRoutes.get("/rankings/activity", async (c) => {
const start = c.req.query("start")
if (!start || Number.isNaN(Date.parse(start)))
return failure(c, 400, "invalid-start", "start time is required")
/**
* 按 **user_id** 聚合,名字从 user 表取。按 `submission.username` 分组的话,
* 改过名的学生会裂成新旧两条各算各的 AC 题数 —— 排名被拆低,运气不好还会以
* 两个名字同时挂在榜上。同 `/submissions/statistics` 那批。
*
* innerJoin user 顺带把已删号学生的孤儿提交挡在外面,不用再兜底名字。
*/
const rows = await db
.select({
username: schema.user.username,
value: countDistinct(schema.submission.problemId),
})
.from(schema.submission)
.innerJoin(schema.user, eq(schema.submission.userId, schema.user.id))
.where(
and(
isNull(schema.submission.contestId),
gte(schema.submission.createTime, start),
inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]),
eq(schema.user.isDisabled, false),
ne(schema.user.adminType, "Super Admin"),
),
)
.groupBy(schema.submission.userId, schema.user.username)
.orderBy(desc(countDistinct(schema.submission.problemId)))
.limit(10)
return success(
c,
rows.map(
(row) =>
({
username: row.username,
count: row.value,
}) satisfies ActivityRankItem,
),
)
})
/**
* 周榜的榜面大小。**存量榜(`/rankings/users`)解决的是「谁最强」,周榜解决的是
* 「这一周谁在往前走」** —— 后者每周一清零,所以榜面短一点更像「这周的头名」,
* 首页周榜的榜面大小。周榜解决的是「这一周谁在往前走」—— 每周一清零,所以榜面短一点更像「这周的头名」,
* 长了反而又变成一张追不上的总表。榜外的人靠 `me` 单独看到自己的名次。
*/
const WEEKLY_BOARD_SIZE = 10
/** 算「解决」的两个状态:AST_CHECK_FAILED 也是答案对了,与 /rankings/activity 同口径 */
/** 算「解决」的两个状态:AST_CHECK_FAILED 也是答案对了,与排名页(routes/ranking.ts)同口径 */
const ACCEPTED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
/**
* 本周进步榜:按**本周首次 AC 的题目数**排名,每周一 0:00(东八区)清零。
*
* 和 `/rankings/users` 的区别不只是加了时间窗:那张榜排的是 `user_profile` 的存量
* AC 总数,名次几乎不动,中位学生看一眼就知道追不上,等于负反馈。这张榜的分母是
* 「这一周」,谁都可能进前十。
*
* 「首次 AC」是靠 NOT EXISTS 排掉本周之前已经通过过的 (user, problem) 对,不是简单
* 数本周 AC 的去重题数 —— 后者把老题重交一遍也算成绩,一分钟能刷满一屏。
* 相关子查询的四个条件正好是 `submission_public_metrics_idx`
@@ -417,10 +225,12 @@ accountRoutes.get("/rankings/weekly", optionalAuth, async (c) => {
const start = weekStart()
// 入榜人群与全服榜一致(leaderboardWhere):正常状态的学生与学生管理员
// 入榜人群与排名页一致(routes/ranking.ts 的 rankedStudents):正常状态的学生与学生管理员,
// 老师设成不计入排名的不算
const audience = and(
inArray(schema.user.adminType, [...STUDENT_ROLES]),
eq(schema.user.isDisabled, false),
isNull(schema.user.rankHiddenAt),
className ? eq(schema.user.className, className) : undefined,
)
const thisWeek = and(
+1 -62
View File
@@ -10,8 +10,6 @@ import {
type ClassActivityProblem,
type ClassComparison,
type ClassComparisonResponse,
type ClassRankItem,
type ClassUserRank,
type LastVisit,
type LessonLanguage,
FLOWCHART_PASS_GRADES,
@@ -39,7 +37,7 @@ import { failure, parseBody, success } from "../http"
import { JudgeStatus } from "../judge/status"
import { accepted } from "../services/learning-stats"
import { calendarDay, dayStart, localTime } from "../time"
import { queryInteger, rounded } from "./helpers"
import { rounded } from "./helpers"
export const classroomRoutes = new Hono<AppEnv>()
@@ -108,65 +106,6 @@ function sampleStdDev(values: number[]) {
)
}
classroomRoutes.get("/rankings/classes", async (c) => {
const grade = c.req.query("grade")?.trim()
if (!grade || !/^\d+$/.test(grade)) return failure(c, 400, "invalid-grade", "grade is required")
const users = await loadClassUsers(undefined, grade)
const groups = new Map<string, ClassUser[]>()
for (const user of users)
groups.set(user.className, [...(groups.get(user.className) ?? []), user])
const result = [...groups]
.map(([className, members]) => {
const totalAc = members.reduce((sum, member) => sum + member.acceptedNumber, 0)
const totalSubmission = members.reduce((sum, member) => sum + member.submissionNumber, 0)
return {
className,
userCount: members.length,
totalAc,
totalSubmission,
avgAc: rounded(totalAc / members.length),
acRate: totalSubmission > 0 ? rounded((totalAc / totalSubmission) * 100) : 0,
}
})
.sort((a, b) => b.totalAc - a.totalAc || a.totalSubmission - b.totalSubmission)
return success(
c,
result.map((item, index) => ({ ...item, rank: index + 1 }) satisfies ClassRankItem),
)
})
classroomRoutes.get("/me/class-rank", requireAuth, async (c) => {
const user = c.get("user")!
if (!user.className) return failure(c, 400, "class-missing", "用户没有班级信息")
const members = (await loadClassUsers([user.className])).sort(
(a, b) => b.acceptedNumber - a.acceptedNumber || a.submissionNumber - b.submissionNumber,
)
const ranks = members.map((member, index) => ({
userId: member.userId,
username: member.username,
acceptedNumber: member.acceptedNumber,
submissionNumber: member.submissionNumber,
rank: index + 1,
}))
const myRank = ranks.find((rank) => rank.userId === user.id)?.rank ?? -1
const showAll = c.req.query("scope") === "all"
let selected = ranks
if (showAll) {
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
selected = ranks.slice(offset, offset + limit)
} else if (myRank > 0 && ranks.length > 10) {
const start = Math.min(Math.max(0, myRank - 6), ranks.length - 10)
selected = ranks.slice(start, start + 10)
}
return success(c, {
className: user.className,
myRank,
total: ranks.length,
ranks: selected,
} satisfies ClassUserRank)
})
/**
* 同班同一天有这么多人做过,就算「班里在做」。2025 秋的实测分布是两极的:
* (班级, 日, 题) 要么只有 1 个人(自己在刷),要么 13 人以上(老师点的题),
+461
View File
@@ -0,0 +1,461 @@
import {
rankHiddenRequestSchema,
rankPeriodSchema,
rankScopeSchema,
STUDENT_ROLES,
type ClassBattleItem,
type RankBoard,
type RankPeriod,
type RankRow,
type RankScope,
type SampleUser,
type WeeklyChampion,
} from "@oj2/contract"
import {
and,
countDistinct,
desc,
eq,
gte,
inArray,
isNotNull,
isNull,
like,
min,
ne,
type SQL,
} from "drizzle-orm"
import { Hono } from "hono"
import { optionalAuth, requireTeacher, type AppEnv } from "../auth/middleware"
import { db, schema } from "../db"
import { failure, parseBody, success } from "../http"
import { JudgeStatus } from "../judge/status"
import { dayStart, termStart, weekStart } from "../time"
import { isTeacherOrAbove, rounded, sampleUser } from "./helpers"
/**
* 排名页(设计稿「排名重设计」G1–G3)。口径见契约 `ranking.ts` 的注释:
* 做对多的在前,一样多的先做到的在前;「做对」= 这道题第一次做对落在这段时间里。
*/
export const rankingRoutes = new Hono<AppEnv>()
/** AST_CHECK_FAILED 也是答案对了,与周榜、课堂条同口径 */
const SOLVED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
/** 本年级 / 全服的榜面:前面这么多人 + 我附近一段,中间折起来 */
const BOARD_HEAD = 16
/** 我附近一段:前面 5 个、后面 7 个(前面的人是要追的,多给后面几个看「谁在追我」) */
const WINDOW_BEFORE = 5
const WINDOW_AFTER = 7
/** 没有「我」(老师、没上榜)时榜面给多少 */
const BOARD_HEAD_NO_ME = 30
/** 走势图往回看几周 */
const TREND_WEEKS = 4
/** 每周冠军列几周 */
const CHAMPION_WEEKS = 3
const WEEK_MS = 7 * 86_400_000
/** 入榜人群:正常状态的学生与学生管理员,老师设成不计入排名的不算 */
const rankedStudents = and(
inArray(schema.user.adminType, [...STUDENT_ROLES]),
eq(schema.user.isDisabled, false),
isNull(schema.user.rankHiddenAt),
)!
interface Entrant {
id: number
username: string
className: string | null
avatar: string | null
/** 每道题第一次做对的时刻,升序;毫秒数用来比较,原文留着给出参 */
times: number[]
stamps: string[]
}
interface Standing {
entrant: Entrant
rank: number
solved: number
reachedAt: string | null
}
function audienceWhere(scope: RankScope, className: string | null) {
if (scope === "class") return and(rankedStudents, eq(schema.user.className, className!))!
if (scope === "grade") return and(rankedStudents, like(schema.user.className, `${className}%`))!
return rankedStudents
}
function avatarOf(avatar: string | null) {
return !avatar || avatar.endsWith("/default.png") ? null : avatar
}
/**
* 这群人、从 `since` 起每道题第一次做对的时刻。
*
* 先按 (人, 题) 取全部历史里的最早一次,再用 having 卡 `>= since` —— 这样「这学期」数的是
* 这学期**第一次**做对的题,以前做对过的老题这学期重交一遍不算。比赛里的提交不算。
* 走 `submission_public_metrics_idx`(user_id, problem_id, result, create_time,WHERE
* contest_id IS NULL),全服全部历史实测 24ms。
*/
async function loadEntrants(where: SQL, since: string | null): Promise<Entrant[]> {
const firstSolved = min(schema.submission.createTime)
const [people, solves] = await Promise.all([
db
.select({
id: schema.user.id,
username: schema.user.username,
className: schema.user.className,
avatar: schema.userProfile.avatar,
})
.from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(where),
db
.select({ userId: schema.submission.userId, at: firstSolved })
.from(schema.submission)
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(
and(
where,
isNull(schema.submission.contestId),
inArray(schema.submission.result, SOLVED_RESULTS),
),
)
.groupBy(schema.submission.userId, schema.submission.problemId)
.having(since ? gte(firstSolved, since) : undefined),
])
const byUser = new Map<number, string[]>()
for (const row of solves) {
if (!row.at) continue
const list = byUser.get(row.userId)
if (list) list.push(row.at)
else byUser.set(row.userId, [row.at])
}
return people.map((person) => {
const pairs = (byUser.get(person.id) ?? [])
.map((stamp) => [Date.parse(stamp), stamp] as const)
.sort((a, b) => a[0] - b[0])
return {
id: person.id,
username: person.username,
className: person.className,
avatar: avatarOf(person.avatar),
times: pairs.map(([time]) => time),
stamps: pairs.map(([, stamp]) => stamp),
}
})
}
/**
* `cutoff` 那一刻的名次(不给就是现在)。一样多的先做到的在前,再一样按 id ——
* 第三档不是凑数:同一秒做到的确实有(一节课的最后一道),没有稳定的兜底键名次会跳。
* `includeZero`:本班把一道没做对的也排上(全班名单),本年级 / 全服不排。
*/
function standings(entrants: Entrant[], cutoff: number | null, includeZero: boolean) {
const list = entrants
.map((entrant) => {
let solved = entrant.times.length
if (cutoff !== null) {
solved = 0
while (solved < entrant.times.length && entrant.times[solved]! < cutoff) solved++
}
return {
entrant,
solved,
reached: solved ? entrant.times[solved - 1]! : Number.POSITIVE_INFINITY,
reachedAt: solved ? entrant.stamps[solved - 1]! : null,
}
})
.filter((row) => includeZero || row.solved > 0)
.sort((a, b) => b.solved - a.solved || a.reached - b.reached || a.entrant.id - b.entrant.id)
return list.map((row, index): Standing => ({
entrant: row.entrant,
rank: index + 1,
solved: row.solved,
reachedAt: row.reachedAt,
}))
}
function toRow(standing: Standing, change: number | null): RankRow {
const { entrant } = standing
return {
rank: standing.rank,
user: sampleUser(entrant, null),
avatar: entrant.avatar,
className: entrant.className,
solved: standing.solved,
reachedAt: standing.reachedAt,
change,
} satisfies RankRow
}
function periodStart(period: RankPeriod) {
if (period === "week") return weekStart()
if (period === "term") return termStart()
return null
}
/**
* 老师打开排名页默认看哪个班:自己最近布置过作业的班 → 最近一周交题人数最多的班。
* 不记老师上次选的:统计面板吃过那个亏(上一节课的班悄悄留在框里)。
*/
async function defaultClassFor(teacherId: number) {
const [lesson] = await db
.select({ className: schema.classLesson.className })
.from(schema.classLesson)
.where(eq(schema.classLesson.createdBy, teacherId))
.orderBy(desc(schema.classLesson.day), desc(schema.classLesson.updatedAt))
.limit(1)
if (lesson) return lesson.className
const users = countDistinct(schema.submission.userId)
const [busiest] = await db
.select({ className: schema.user.className, users })
.from(schema.submission)
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(
and(
rankedStudents,
isNotNull(schema.user.className),
ne(schema.user.className, ""),
gte(schema.submission.createTime, new Date(Date.now() - WEEK_MS).toISOString()),
),
)
.groupBy(schema.user.className)
.orderBy(desc(users))
.limit(1)
if (busiest?.className) return busiest.className
// 放假回来第一次打开:一周里没人交过题,就看最后一个交题的学生是哪个班
const [latest] = await db
.select({ className: schema.user.className })
.from(schema.submission)
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(and(rankedStudents, isNotNull(schema.user.className), ne(schema.user.className, "")))
.orderBy(desc(schema.submission.createTime))
.limit(1)
return latest?.className ?? null
}
/**
* 这次请求看的是哪个班 / 哪个年级。学生只能看自己的班和年级;老师用 `className` 选,
* 不给就猜(`defaultClassFor`)。返回 undefined 表示该报「没有班级」。
*/
async function resolveGroup(
user: AppEnv["Variables"]["user"],
scope: RankScope,
requested: string | undefined,
): Promise<string | null | undefined> {
if (scope === "all") return null
let className = user?.className || null
if (isTeacherOrAbove(user)) className = requested || (await defaultClassFor(user!.id))
if (!className) return undefined
return scope === "class" ? className : className.slice(0, 2)
}
rankingRoutes.get("/rankings/board", optionalAuth, async (c) => {
const user = c.get("user")
const scope = rankScopeSchema.safeParse(c.req.query("scope"))
const period = rankPeriodSchema.safeParse(c.req.query("period"))
if (!scope.success || !period.success)
return failure(c, 400, "invalid-query", "排名范围或时间段不对")
const group = await resolveGroup(user, scope.data, c.req.query("className")?.trim())
if (group === undefined) return failure(c, 400, "class-missing", "没有班级,看不了本班和本年级")
if (scope.data === "grade" && !/^\d{2}$/.test(group!))
return failure(c, 400, "invalid-grade", "年级不对")
const start = periodStart(period.data)
const changeSince = period.data === "week" ? dayStart() : weekStart()
const includeZero = scope.data === "class"
const isTeacher = isTeacherOrAbove(user)
const [entrants, mine, hiddenUsers] = await Promise.all([
loadEntrants(audienceWhere(scope.data, group), start),
user && !isTeacher
? db
.select({ rankHiddenAt: schema.user.rankHiddenAt })
.from(schema.user)
.where(eq(schema.user.id, user.id))
.then(([row]) => row)
: undefined,
isTeacher && scope.data === "class"
? db
.select({ id: schema.user.id, username: schema.user.username })
.from(schema.user)
.where(
and(
eq(schema.user.className, group!),
inArray(schema.user.adminType, [...STUDENT_ROLES]),
eq(schema.user.isDisabled, false),
isNotNull(schema.user.rankHiddenAt),
),
)
.orderBy(schema.user.username)
: [],
])
const now = standings(entrants, null, includeZero)
const before = new Map(
standings(entrants, Date.parse(changeSince), includeZero)
.filter((row) => row.solved > 0)
.map((row) => [row.entrant.id, row.rank]),
)
const rowOf = (standing: Standing | undefined) =>
standing
? toRow(
standing,
before.has(standing.entrant.id) ? before.get(standing.entrant.id)! - standing.rank : null,
)
: null
// 一道没做对就不算上榜(本班的全班名单里也列着他,但没有名次可说,也没有对手)
const meIndex = user ? now.findIndex((row) => row.entrant.id === user.id && row.solved > 0) : -1
const me = meIndex >= 0 ? now[meIndex] : undefined
const full = includeZero || c.req.query("full") === "1"
let picked = now
if (!full) {
const head = me ? BOARD_HEAD : BOARD_HEAD_NO_ME
picked = now.filter(
(_, index) =>
index < head || (me && index >= meIndex - WINDOW_BEFORE && index <= meIndex + WINDOW_AFTER),
)
}
let trend: RankBoard["trend"] = null
if (me && period.data !== "week") {
const thisWeek = Date.parse(weekStart())
const points = Array.from(
{ length: TREND_WEEKS },
(_, k) => thisWeek - (TREND_WEEKS - 1 - k) * WEEK_MS,
).filter((point) => !start || point > Date.parse(start))
const tracked = [now[meIndex - 1], me, now[meIndex + 1]].filter((row): row is Standing => !!row)
const snapshots = points.map((point) => {
const ranks = new Map(
standings(entrants, point, includeZero)
.filter((row) => row.solved > 0)
.map((row) => [row.entrant.id, row.rank]),
)
return ranks
})
trend = {
points: [...points.map((point) => new Date(point).toISOString()), new Date().toISOString()],
series: tracked.map((row) => ({
userId: row.entrant.id,
ranks: [...snapshots.map((ranks) => ranks.get(row.entrant.id) ?? null), row.rank],
})),
}
}
return success(c, {
scope: scope.data,
period: period.data,
className: group,
start,
changeSince,
total: now.length,
rows: picked.map((row) => rowOf(row)!),
complete: picked.length === now.length,
me: rowOf(me),
ahead: rowOf(meIndex > 0 ? now[meIndex - 1] : undefined),
behind: rowOf(meIndex >= 0 ? now[meIndex + 1] : undefined),
trend,
hidden: !!mine?.rankHiddenAt,
hiddenUsers: hiddenUsers.map((row): SampleUser => sampleUser(row, null)),
} satisfies RankBoard)
})
/**
* 班级对抗:全服每个班这学期人均做对几道,外加这周人均涨了多少。人均而不是总数 ——
* 班级人数从 11 到 58 都有,比总数等于比人多。这学期一道没做对的班不列(还没开始用)。
* 不计入排名的人分子分母都不算:抄来的题不该替全班加分。
*/
rankingRoutes.get("/rankings/classes", async (c) => {
const where = and(
rankedStudents,
isNotNull(schema.user.className),
ne(schema.user.className, ""),
)!
const term = termStart()
const week = Date.parse(weekStart())
const entrants = await loadEntrants(where, term)
const classes = new Map<string, { members: number; term: number; week: number }>()
for (const entrant of entrants) {
const entry = classes.get(entrant.className!) ?? { members: 0, term: 0, week: 0 }
entry.members++
entry.term += entrant.times.length
entry.week += entrant.times.filter((time) => time >= week).length
classes.set(entrant.className!, entry)
}
const result = [...classes]
.filter(([, entry]) => entry.term > 0)
.map(([className, entry]) => ({
className,
members: entry.members,
perCapita: rounded(entry.term / entry.members, 1),
weekGain: rounded(entry.week / entry.members, 1),
}))
.sort((a, b) => b.perCapita - a.perCapita || b.weekGain - a.weekGain)
return success(
c,
result.map((item, index) => ({ ...item, rank: index + 1 }) satisfies ClassBattleItem),
)
})
/** 本班每周冠军:最近几个已经结束的周,每周新做对最多的那个人(一样多先做到的) */
rankingRoutes.get("/rankings/champions", optionalAuth, async (c) => {
const className = await resolveGroup(c.get("user"), "class", c.req.query("className")?.trim())
if (!className) return failure(c, 400, "class-missing", "没有班级")
const thisWeek = Date.parse(weekStart())
const since = new Date(thisWeek - CHAMPION_WEEKS * WEEK_MS).toISOString()
const entrants = await loadEntrants(audienceWhere("class", className), since)
const champions: WeeklyChampion[] = []
for (let k = 1; k <= CHAMPION_WEEKS; k++) {
const from = thisWeek - k * WEEK_MS
const inWeek = entrants.map((entrant) => {
const keep = entrant.times
.map((time, index) => [time, entrant.stamps[index]!] as const)
.filter(([time]) => time >= from && time < from + WEEK_MS)
return {
...entrant,
times: keep.map(([time]) => time),
stamps: keep.map(([, stamp]) => stamp),
}
})
const [top] = standings(inWeek, null, false)
if (!top) continue
champions.push({
weekStart: new Date(from).toISOString(),
user: sampleUser(top.entrant, null),
avatar: top.entrant.avatar,
solved: top.solved,
} satisfies WeeklyChampion)
}
return success(c, champions)
})
/**
* 老师把一个学生设成不计入排名 / 恢复。怀疑抄代码时用(用户 2026-10-10 定的)。
* 所有排名页的榜和首页周榜都不出现他;他自己看得到「不计入排名」;比赛排名不受影响。
*/
rankingRoutes.put("/rankings/hidden/:userId", requireTeacher, async (c) => {
const userId = Number(c.req.param("userId"))
if (!Number.isInteger(userId)) return failure(c, 400, "invalid-user", "用户不对")
const body = await parseBody(c, rankHiddenRequestSchema)
if (!body.success) return body.response
const updated = await db
.update(schema.user)
.set({ rankHiddenAt: body.data.hidden ? new Date().toISOString() : null })
.where(and(eq(schema.user.id, userId), inArray(schema.user.adminType, [...STUDENT_ROLES])))
.returning({ id: schema.user.id })
if (!updated.length) return failure(c, 404, "user-not-found", "没有这个学生")
return success(c, null)
})
+1 -10
View File
@@ -1,9 +1,8 @@
import type { OnlineCount, Quote, WebsiteConfig } from "@oj2/contract"
import type { Quote, WebsiteConfig } 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"
@@ -26,14 +25,6 @@ siteRoutes.get("/site", async (c) => {
} satisfies WebsiteConfig)
})
/**
* 当前在线人数。匿名可读 —— 一个聚合数字不暴露任何人的身份,
* 而榜单页本身就允许匿名看。谁在线是另一回事,只在 /rankings/users 里对老师下发。
*/
siteRoutes.get("/site/online", async (c) => {
return success(c, { count: await onlineCount() } satisfies OnlineCount)
})
// 数据集读不到时的兜底(本机旁边没有 sentences-bundle 就会走这里)
const fallbackQuotes = [
{
+12
View File
@@ -83,6 +83,18 @@ export function weekStart(now: Date | number | string = new Date()): string {
return new Date((today - (weekday - 1)) * DAY_MS - OFFSET_MS).toISOString()
}
/**
* 「这学期」从哪天零点(东八区)算起:秋季学期 9 月 1 日起(到次年 1 月都算),春季学期
* 2 月 1 日起(7、8 月的暑假也归春季,开学前没人做题,不影响)。返回 ISO 字符串。
*/
export function termStart(now: Date | number | string = new Date()): string {
const wall = toWallClock(now)
const year = wall.getUTCFullYear()
const month = wall.getUTCMonth() + 1
const [termYear, termMonth] = month >= 9 ? [year, 9] : month >= 2 ? [year, 2] : [year - 1, 9]
return fromWallClock(new Date(Date.UTC(termYear, termMonth - 1, 1))).toISOString()
}
/** 按北京时间的日历做月份平移,日号超出目标月长度时截到月末,时分秒毫秒原样保留 */
export function shiftMonthsByCalendar(instant: Date, months: number): Date {
const wall = toWallClock(instant)