- 一张榜两个开关:本班 / 本年级 / 全服 × 这周 / 这学期 / 全部,默认本班 · 这学期。 原来最大那张全服 Top100 里,这学期在用的 25 级学生一个都进不去 - 做对一样多的不再按「提交少的在前」排,改成先做到的在前;名次旁边写比周一升降了几名 - 「你」那张卡:第几名、再做对几道上领奖台,要追的人早你几秒、追你的人晚你几秒 - 本年级 / 全服只列前面一段和你附近一段,中间折起来,点开再看 - 班级对抗按人均做对比,另有我和对手的名次走势、这周本班、每周冠军 - 老师不上榜,选班看;每行「⋯」能把怀疑抄代码的学生设成不计入排名(新增 user.rank_hidden_at, 首页周榜同样不算他),随时能恢复 - 去掉全服 Top10、日活两张图、在线人数和班级详情弹框里的统计词,以及它们背后的旧接口 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0cee4ef779
commit
1c6e2b9d82
31 files changed
+6633
-1392
No files matched your search
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 人以上(老师点的题),
|
||||
|
||||
@@ -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,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 = [
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user