update
Some checks failed
Deploy / deploy (push) Has been cancelled

This commit is contained in:
2026-08-31 07:41:44 -06:00
parent e62f41f6c7
commit 2031e6a434
3 changed files with 37 additions and 30 deletions

View File

@@ -181,23 +181,27 @@ 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 })
const [totalRow] = await db.select({ value: count() }).from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
.where(leaderboardWhere)
const total = Math.min(totalRow?.value ?? 0, LEADERBOARD_SIZE)
// 榜单封顶 100 名,所以这一页最多还能取几条只取决于 offset**不取决于总人数** ——
// 真人不够时数据库自己会少返回。不拿 total 当上限,三段查询就能并发发出去,
// 端点延迟从「四个来回相加」变成「最慢的那个」。越界页一条不剩,直接不发 SQL。
const pageLimit = Math.max(0, Math.min(limit, LEADERBOARD_SIZE - offset))
// 末页可能只剩不足 limit 条,越界页一条不剩 —— 后者直接不发 SQL
const pageLimit = Math.max(0, Math.min(limit, total - offset))
const rows = pageLimit === 0 ? [] : await 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)
const [totalRow, rows, me] = 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),
])
return success(c, userRankSchema.parse({
results: rows.map(serializeRankRow),
total,
me: await myLeaderboardRank(c.get("user")?.id),
total: Math.min(totalRow?.value ?? 0, LEADERBOARD_SIZE),
me,
}))
})

View File

@@ -5,7 +5,7 @@ import {
classRankItemSchema,
classUserRankSchema,
} from "@oj2/contract"
import { and, asc, eq, gte, inArray, lte, sql } from "drizzle-orm"
import { and, eq, gte, inArray, like, lte, sql } from "drizzle-orm"
import { Hono } from "hono"
import { requireAuth, type AppEnv } from "../auth/middleware"
@@ -24,13 +24,19 @@ interface ClassUser {
submissionNumber: number
}
async function loadClassUsers(classNames?: string[]) {
/**
* 入班学生的 AC/提交数。`gradePrefix` 是年级(班号形如 `241` = 24 级 1 班),
* 走 SQL 的 like 而不是拉全表再在内存里 startsWith —— 班级榜每换一次年级就要跑一遍,
* 没必要每次都把全校一千多号人搬进进程。年级在调用处已校验为纯数字,不含 like 通配符。
*/
async function loadClassUsers(classNames?: string[], gradePrefix?: string) {
const filters = [
eq(schema.user.isDisabled, false),
inArray(schema.user.adminType, ["Regular User", "Student Admin"]),
sql`${schema.user.className} is not null`,
]
if (classNames) filters.push(inArray(schema.user.className, classNames))
if (gradePrefix) filters.push(like(schema.user.className, `${gradePrefix}%`))
const rows = await db.select({
userId: schema.user.id,
username: schema.user.username,
@@ -72,7 +78,7 @@ 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()).filter((user) => user.className.startsWith(grade))
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]) => {

View File

@@ -242,11 +242,13 @@ const columns: DataTableColumn<Rank>[] = [
]
watch(() => query.page, init)
// 改每页条数时,若当前不在第一页,把重新取数交给 page 的 watcher ——
// 这里再自己取一次,就是两个一模一样的请求
watch(
() => query.limit,
() => {
query.page = 1
init()
if (query.page === 1) init()
else query.page = 1
},
)
watch(duration, listActivity)
@@ -265,12 +267,6 @@ async function listActivity() {
}))
}
// 「全服 Top10」就是同一个榜的第一页 —— 上限由服务端定,这里只要前 10 条
async function listRank() {
const res = await getRank(0, 10)
rankChart.value = res.results
}
const options: SelectOption[] = [
{ label: "一周内", value: "weeks:1" },
{ label: "一个月内", value: "months:1" },
@@ -288,8 +284,10 @@ const subOptions = computed<Duration>(() => {
})
onMounted(() => {
init()
listRank()
// 「全服 Top10」就是榜单第一页的前 10 条:挂载时 init() 取的正是 offset=0&limit=10
// 再单发一次一模一样的 /rankings/users 只会让这张图排在日活后面出来。
// 图只在挂载时定一次,翻页/改每页条数不该动它。
init().then((results) => (rankChart.value = results.slice(0, 10)))
listActivity()
listClassRank()
listMyClassRank()
@@ -480,13 +478,12 @@ watch(
},
)
// 同上page 改了自会触发下面那个 watcher别重复取
watch(
() => myClassQuery.limit,
() => {
myClassQuery.page = 1
if (myClassScope.value === "all") {
listMyClassRank()
}
if (myClassQuery.page !== 1) myClassQuery.page = 1
else if (myClassScope.value === "all") listMyClassRank()
},
)
</script>