diff --git a/apps/api/src/routes/account.ts b/apps/api/src/routes/account.ts index 8f91b44..443f27e 100644 --- a/apps/api/src/routes/account.ts +++ b/apps/api/src/routes/account.ts @@ -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, })) }) diff --git a/apps/api/src/routes/classroom.ts b/apps/api/src/routes/classroom.ts index f40ba7b..642d629 100644 --- a/apps/api/src/routes/classroom.ts +++ b/apps/api/src/routes/classroom.ts @@ -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() for (const user of users) groups.set(user.className, [...(groups.get(user.className) ?? []), user]) const result = [...groups].map(([className, members]) => { diff --git a/apps/web/src/oj/rank/list.vue b/apps/web/src/oj/rank/list.vue index 020ebe5..1981133 100644 --- a/apps/web/src/oj/rank/list.vue +++ b/apps/web/src/oj/rank/list.vue @@ -242,11 +242,13 @@ const columns: DataTableColumn[] = [ ] 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(() => { }) 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() }, )