Files
OJ2/apps/api/src/routes/ranking.ts
T
xuyueandClaude Opus 5.5 41d2e46aa9
Deploy / deploy (push) Canceled after 0s
perf(排名): 打开排名页快一些,看「全服 · 全部」从 0.27 秒降到 0.08 秒
做对时刻在库里换成毫秒、按人聚成数组再取回,不再一行一个 timestamptz
逐条解析(全服全部历史六万多行,光解析就占 150ms)。名次与原来完全一致。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 10:32:32 +08:00

314 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
classPkPeriodSchema,
rankHiddenRequestSchema,
rankPeriodSchema,
rankScopeSchema,
STUDENT_ROLES,
type RankBoard,
type RankPeriod,
type RankRow,
type RankScope,
type SampleUser,
type WeeklyChampion,
} from '@oj2/contract'
import { and, eq, inArray, isNotNull } from 'drizzle-orm'
import { Hono } from 'hono'
import { optionalAuth, requireAuth, requireTeacher, type AppEnv } from '../auth/middleware'
import { db, schema } from '../db'
import { failure, parseBody, success } from '../http'
import { dayStart, termStart, weekStart } from '../time'
import {
audienceWhere,
classBattle,
classDetail,
classPk,
defaultClassFor,
lessonClassFor,
loadEntrants,
PK_MAX_CLASSES,
pkPartner,
rankEntrants,
standings,
WEEK_MS,
type Standing,
} from '../services/ranking'
import { isTeacherOrAbove, sampleUser } from './helpers'
/**
* 排名页(设计稿「排名重设计」G1–G3)。口径见契约 `ranking.ts` 的注释:
* 做对多的在前,一样多的先做到的在前;「做对」= 这道题第一次做对落在这段时间里。
*/
export const rankingRoutes = new Hono<AppEnv>()
/** 本年级 / 全服的榜面:前面这么多人 + 我附近一段,中间折起来 */
const BOARD_HEAD = 16
/** 我附近一段:前面 5 个、后面 7 个(多给后面几个:看得到后面的人离自己多近) */
const WINDOW_BEFORE = 5
const WINDOW_AFTER = 7
/** 没有「我」(老师、没上榜)时榜面给多少 */
const BOARD_HEAD_NO_ME = 30
/** 全服名单最多列到第几名,展开也到此为止(一千五百多人全摊开页面会卡死) */
const ALL_CAP = 100
/** 走势图往回看几周 */
const TREND_WEEKS = 4
/** 每周冠军列几周 */
const CHAMPION_WEEKS = 3
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,
mood: entrant.mood,
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
}
/**
* 这次请求看的是哪个班 / 哪个年级。学生只能看自己的班和年级;老师用 `className` 选,
* 不给就取最近布置过作业的班(`lessonClassFor`)。返回 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 lessonClassFor(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([
rankEntrants(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 cap = scope.data === 'all' ? ALL_CAP : null
const listable = cap ? now.slice(0, cap) : now
const full = includeZero || c.req.query('full') === '1'
let picked = listable
if (!full) {
const head = me ? BOARD_HEAD : BOARD_HEAD_NO_ME
picked = listable.filter((_, index) => index < head || (me && index >= meIndex - WINDOW_BEFORE && index <= meIndex + WINDOW_AFTER))
}
// 全服 100 名以外:不报具体名次,前后一名、走势都不给,只给第 100 名算还差几道
const beyond = !!(cap && me && me.rank > cap)
let trend: RankBoard['trend'] = null
if (me && !beyond && 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 === listable.length,
cap,
lastListed: beyond ? rowOf(listable.at(-1)) : null,
me: rowOf(me),
ahead: beyond ? null : rowOf(meIndex > 0 ? now[meIndex - 1] : undefined),
behind: beyond ? null : rowOf(meIndex >= 0 ? now[meIndex + 1] : undefined),
trend,
hidden: !!mine?.rankHiddenAt,
hiddenUsers: hiddenUsers.map((row): SampleUser => sampleUser(row, null)),
} satisfies RankBoard)
})
/**
* 班级对抗:全服每个班这学期人均做对几道,外加这周人均涨了多少(口径见 services/ranking.ts
* 的 classBattle)
*/
rankingRoutes.get('/rankings/classes', async (c) => {
const { items } = await classBattle()
return success(c, items)
})
/** 班级详情:谁都能看(原来的弹框就是),「要多关心的同学」只给老师 */
rankingRoutes.get('/rankings/class-detail', optionalAuth, async (c) => {
const className = c.req.query('className')?.trim()
if (!className) return failure(c, 400, 'class-missing', '没有班级')
return success(c, await classDetail(className, isTeacherOrAbove(c.get('user'))))
})
/**
* 班级 PK(设计稿「班级 PK 重设计」定稿):`classes` 逗号分隔。谁登录了都能看 —— 只有班级
* 层面的数,没有点名到人。只给一个班(或者不给,用自己的班 / 老师默认的班)就配一个对手,
* 见 `pkPartner`。
*/
rankingRoutes.get('/rankings/pk', requireAuth, async (c) => {
const user = c.get('user')!
const period = classPkPeriodSchema.safeParse(c.req.query('period') ?? 'term')
if (!period.success) return failure(c, 400, 'invalid-query', '时间段不对')
const requested = [
...new Set(
(c.req.query('classes') ?? '')
.split(',')
.map((name) => name.trim())
.filter((name) => name && name.length <= 32),
),
]
if (requested.length > PK_MAX_CLASSES) return failure(c, 400, 'too-many-classes', `一次最多比 ${PK_MAX_CLASSES} 个班`)
const isTeacher = isTeacherOrAbove(user)
const mine = isTeacher ? null : user.className || null
const classNames = requested.length ? requested : [isTeacher ? await defaultClassFor(user.id) : mine].filter((name): name is string => !!name)
if (classNames.length === 1) {
const partner = await pkPartner(classNames[0]!)
if (partner) classNames.push(partner)
}
return success(c, await classPk(classNames, period.data, mine))
})
/** 本班每周冠军:最近几个已经结束的周,每周新做对最多的那个人(一样多先做到的) */
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) => ({ ...entrant, times: entrant.times.filter((time) => time >= from && time < from + WEEK_MS) }))
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)
})
/**
* 老师清空一个学生的个性签名。签名在排名上挂出来以后更显眼,拿同学开涮、留电话号码的
* 得有人能拿掉(用户 2026-10-10 定的)。只清这一句,他之后还能重新写。
*/
rankingRoutes.delete('/rankings/mood/:userId', requireTeacher, async (c) => {
const userId = Number(c.req.param('userId'))
if (!Number.isInteger(userId)) return failure(c, 400, 'invalid-user', '用户不对')
const updated = await db
.update(schema.userProfile)
.set({ mood: null })
.where(
and(
eq(schema.userProfile.userId, userId),
inArray(
schema.userProfile.userId,
db
.select({ id: schema.user.id })
.from(schema.user)
.where(inArray(schema.user.adminType, [...STUDENT_ROLES])),
),
),
)
.returning({ id: schema.userProfile.userId })
if (!updated.length) return failure(c, 404, 'user-not-found', '没有这个学生')
return success(c, null)
})