Files
OJ2/apps/api/src/routes/account.ts
T
xuyueandClaude Opus 5.5 2bda05a978
Deploy / deploy (push) Canceled after 0s
feat(名字): 界面上不再显示 ks + 班号前缀,混着几个班的地方名字后面写班级
- 学生名字一律只显示名字(老号 ks232张三 显示成 张三),顶栏、首页问候、悬停提示、请假名单、比赛成绩表都一样
- 全服 / 年级排名、全部提交、几个班同时举手时,名字后面淡写班级;提交列表的名字列窄,只写班号(陈长 258)
- AI 班级分析点名「要多关心的同学」时不再带 ks 前缀
- 建号查重补漏:同班同名也比真名(加过数字的张三2、编号制老号);名字自己带数字(王芳1)也认得出被占;
  导入时后端再拦一次同班同名,建号页要等查重结果对上当前名单才能点
- 登录名单换成新接口 /classes/:c/members(名字按界面规则剥,换过班的老号不再露出全名),
  旧接口原样留给上线时还开着旧登录页的机房电脑
- 后台手写老格式 ks232张三 新建用户、没选班时按前缀补班级;PUT /me/profile 只带真名时不再 500

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:41:49 +08:00

627 lines
26 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 { randomBytes } from 'node:crypto'
import { resolve } from 'node:path'
import {
registerRequestSchema,
STUDENT_ROLES,
updateProfileRequestSchema,
type KnowledgeLevel,
type KnowledgeMap,
type ProfileOverview,
type ProblemRank,
type WeeklyRank,
type WeeklyRankItem,
} from '@oj2/contract'
import { and, count, countDistinct, desc, eq, gte, inArray, isNull, lt, lte, max, min, notExists, or, sql } from 'drizzle-orm'
import { alias } from 'drizzle-orm/pg-core'
import { Hono } from 'hono'
import { hashPassword } from '../auth/password'
import { optionalAuth, requireAuth, type AppEnv } from '../auth/middleware'
import { config } from '../config'
import { db, schema } from '../db'
import { failure, parseBody, success } from '../http'
import { JudgeStatus } from '../judge/status'
import { getBooleanOption } from '../services/options'
import { sniffImageExtension } from '../services/image'
import { getUserProfileById } from '../services/profile'
import { clientIp, countAttempt, lockoutRemaining, type AttemptRule } from '../services/throttling'
import { audienceWhere, loadEntrants, SOLVED_RESULTS, standings } from '../services/ranking'
import { localTime, termStart, weekStart } from '../time'
import { asRecord, isTeacherOrAbove, sampleUser } from './helpers'
export const accountRoutes = new Hono<AppEnv>()
/**
* 注册限流:按 IP 每小时 100 个号,只数成功的。拦的是脚本批量造号;
* 机房一个班共用一个出口 IP,一节课全班现场注册也够用。
*/
const REGISTER_PER_IP: AttemptRule = { limit: 100, windowSeconds: 60 * 60 }
accountRoutes.post('/users', async (c) => {
const parsed = await parseBody(c, registerRequestSchema, 'Invalid registration payload')
if (!parsed.success) return parsed.response
if (!(await getBooleanOption('allow_register', true))) {
return failure(c, 403, 'registration-disabled', 'Register function has been disabled by admin')
}
const registerKey = `register:ip:${clientIp(c)}`
const wait = await lockoutRemaining(registerKey, REGISTER_PER_IP)
if (wait !== null) {
return failure(c, 429, 'too-many-registrations', `Too many registrations, please wait ${wait} seconds`)
}
const username = parsed.data.username.toLowerCase()
const email = parsed.data.email.toLowerCase()
const [duplicate] = await db
.select({ username: schema.user.username, email: schema.user.email })
.from(schema.user)
.where(or(sql`lower(${schema.user.username}) = ${username}`, sql`lower(${schema.user.email}) = ${email}`))
.limit(1)
if (duplicate?.username.toLowerCase() === username) {
return failure(c, 409, 'username-exists', 'Username already exists')
}
if (duplicate?.email?.toLowerCase() === email) {
return failure(c, 409, 'email-exists', 'Email already exists')
}
const now = new Date().toISOString()
const password = await hashPassword(parsed.data.password)
await db.transaction(async (tx) => {
const [created] = await tx
.insert(schema.user)
.values({
username,
email,
password,
rawPassword: parsed.data.password.slice(0, 20),
lastLogin: null,
createTime: now,
adminType: 'Regular User',
isDisabled: false,
problemPermission: 'None',
className: null,
})
.returning({ id: schema.user.id })
if (!created) throw new Error('User insert did not return an id')
await tx.insert(schema.userProfile).values({
userId: created.id,
acmProblemsStatus: {},
avatar: `${config.avatarUriPrefix}/default.png`,
mood: null,
acceptedNumber: 0,
submissionNumber: 0,
realName: null,
})
})
await countAttempt(registerKey, REGISTER_PER_IP)
return success(c, { ok: true }, 201)
})
accountRoutes.get('/profiles/:username', optionalAuth, async (c) => {
// 对齐旧后端 account/views/oj.py 的 UserProfileAPI.get 首行:
// `if not user.is_authenticated: return self.success()` —— 匿名一律返回空,
// 否则用户名可经排名接口(/rankings/board)公开枚举,进而不登录就批量收集全校学生的邮箱与最后登录时间。
if (!c.get('user')) return success(c, null)
const [target] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(and(sql`lower(${schema.user.username}) = lower(${c.req.param('username')})`, eq(schema.user.isDisabled, false)))
.limit(1)
if (!target) return failure(c, 404, 'user-not-found', 'User does not exist')
const profile = await getUserProfileById(target.id, c.get('user')?.id === target.id)
if (!profile) return failure(c, 404, 'profile-not-found', 'User profile does not exist')
return success(c, profile)
})
/**
* 个人主页那一页的数据(设计稿「我的主页与设置」定稿)。看的人是本人、同学还是老师,
* 由这里裁:交过没做对的题和知识点只给本人和老师;不计入排名只告诉本人和老师。
*/
accountRoutes.get('/profiles/:username/overview', requireAuth, async (c) => {
const viewer = c.get('user')!
const [target] = await db
.select({
id: schema.user.id,
className: schema.user.className,
adminType: schema.user.adminType,
rankHiddenAt: schema.user.rankHiddenAt,
})
.from(schema.user)
.where(and(sql`lower(${schema.user.username}) = lower(${c.req.param('username')})`, eq(schema.user.isDisabled, false)))
.limit(1)
if (!target) return failure(c, 404, 'user-not-found', '没有这个用户')
const privileged = target.id === viewer.id || isTeacherOrAbove(viewer)
const isStudent = (STUDENT_ROLES as readonly string[]).includes(target.adminType)
const firstSolved = min(schema.submission.createTime)
const lastTried = max(schema.submission.createTime)
const [times, solved, unsolved, knowledge, achievements, achievementTotal, classStandings] = await Promise.all([
// 比赛提交也算:首末提交、学习天数一直是连比赛一起算的
db
.select({
first: min(schema.submission.createTime),
latest: max(schema.submission.createTime),
activeDays: countDistinct(sql`date(${localTime(schema.submission.createTime)})`),
})
.from(schema.submission)
.where(eq(schema.submission.userId, target.id)),
db
.select({
problemId: schema.submission.problemId,
displayId: schema.problem.displayId,
title: schema.problem.title,
solvedAt: firstSolved,
})
.from(schema.submission)
.innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId))
.where(and(eq(schema.submission.userId, target.id), isNull(schema.submission.contestId), inArray(schema.submission.result, SOLVED_RESULTS)))
.groupBy(schema.submission.problemId, schema.problem.displayId, schema.problem.title)
.orderBy(desc(firstSolved), schema.problem.displayId),
privileged
? db
.select({
displayId: schema.problem.displayId,
title: schema.problem.title,
attempts: count(),
})
.from(schema.submission)
.innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId))
.where(and(eq(schema.submission.userId, target.id), isNull(schema.submission.contestId)))
.groupBy(schema.submission.problemId, schema.problem.displayId, schema.problem.title)
.having(sql`not bool_or(${inArray(schema.submission.result, SOLVED_RESULTS)})`)
.orderBy(desc(lastTried))
: null,
privileged ? knowledgeMapOf(target.id, target.className) : null,
db
.select({
id: schema.achievement.id,
name: schema.achievement.name,
description: schema.achievement.description,
icon: schema.achievement.icon,
rarity: schema.achievement.rarity,
unlockTime: schema.userAchievement.unlockTime,
})
.from(schema.userAchievement)
.innerJoin(schema.achievement, eq(schema.achievement.id, schema.userAchievement.achievementId))
.where(and(eq(schema.userAchievement.userId, target.id), eq(schema.achievement.visible, true)))
.orderBy(desc(schema.userAchievement.unlockTime), schema.achievement.order),
db.select({ total: count() }).from(schema.achievement).where(eq(schema.achievement.visible, true)),
// 这学期本班第几,和排名页同口径(不计入排名的人不在 rankedStudents 里,自然查不到)
isStudent && target.className && !target.rankHiddenAt
? loadEntrants(audienceWhere('class', target.className), termStart()).then((entrants) => standings(entrants, null, true))
: [],
])
const tagRows = solved.length
? await db
.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name })
.from(schema.problemTags)
.innerJoin(schema.problemTag, eq(schema.problemTag.id, schema.problemTags.problemtagId))
.where(
and(
eq(schema.problemTag.category, 'knowledge'),
inArray(
schema.problemTags.problemId,
solved.map((row) => row.problemId),
),
),
)
.orderBy(schema.problemTag.name)
: []
const tagsOf = new Map<number, string[]>()
for (const row of tagRows) {
const list = tagsOf.get(row.problemId)
if (list) list.push(row.name)
else tagsOf.set(row.problemId, [row.name])
}
const mine = classStandings.find((row) => row.entrant.id === target.id && row.solved > 0)
// 「你和他」:只有同学看同学才给
let versus: ProfileOverview['versus'] = null
if (!privileged) {
const viewerSolved = await db
.selectDistinct({ problemId: schema.submission.problemId })
.from(schema.submission)
.where(and(eq(schema.submission.userId, viewer.id), isNull(schema.submission.contestId), inArray(schema.submission.result, SOLVED_RESULTS)))
const theirs = new Set(solved.map((row) => row.problemId))
const me = classStandings.find((row) => row.entrant.id === viewer.id && row.solved > 0)
versus = {
solved: viewerSolved.length,
both: viewerSolved.filter((row) => theirs.has(row.problemId)).length,
classRank: me ? { rank: me.rank, total: classStandings.length } : null,
}
}
return success(c, {
firstSubmissionAt: times[0]?.first ?? null,
latestSubmissionAt: times[0]?.latest ?? null,
activeDays: times[0]?.activeDays ?? 0,
now: new Date().toISOString(),
classRank: mine ? { rank: mine.rank, total: classStandings.length } : null,
rankHidden: privileged && isStudent && !!target.rankHiddenAt,
solved: solved.flatMap((row) =>
row.solvedAt
? [
{
displayId: row.displayId,
title: row.title,
solvedAt: row.solvedAt,
tags: tagsOf.get(row.problemId) ?? [],
},
]
: [],
),
unsolved,
knowledge,
versus,
achievements: { total: achievementTotal[0]?.total ?? 0, unlocked: achievements },
} satisfies ProfileOverview)
})
accountRoutes.put('/me/profile', requireAuth, async (c) => {
const parsed = await parseBody(c, updateProfileRequestSchema, 'Invalid profile payload')
if (!parsed.success) return parsed.response
const values = Object.fromEntries(Object.entries(parsed.data).map(([key, value]) => [key, value === '' ? null : value]))
// 只带了不收的字段(比如老页面还发 realName):没什么可改的,别让 drizzle 的空 set 报成 500
if (Object.keys(values).length) {
await db
.update(schema.userProfile)
.set(values)
.where(eq(schema.userProfile.userId, c.get('user')!.id))
}
const profile = await getUserProfileById(c.get('user')!.id, true)
if (!profile) return failure(c, 404, 'profile-not-found', 'User profile does not exist')
return success(c, profile)
})
accountRoutes.post('/me/avatar', requireAuth, async (c) => {
const body: Record<string, string | File> = await c.req.parseBody().catch(() => ({}))
const image = body.image
if (!(image instanceof File)) return failure(c, 400, 'invalid-file', 'Invalid file content')
if (image.size > 2 * 1024 * 1024) return failure(c, 400, 'file-too-large', 'Picture is too large')
const extension = await sniffImageExtension(image)
if (!extension) {
return failure(c, 400, 'unsupported-file', 'Unsupported file format')
}
const filename = `${randomBytes(10).toString('hex')}${extension}`
const directory = resolve(config.avatarDirectory)
await Bun.$`mkdir -p ${directory}`.quiet()
await Bun.write(resolve(directory, filename), image)
const avatar = `${config.avatarUriPrefix}/${filename}`
await db
.update(schema.userProfile)
.set({ avatar })
.where(eq(schema.userProfile.userId, c.get('user')!.id))
return success(c, { avatar })
})
/** 不用图片了,退回默认头像(前端画成名字最后一个字,见 RankAvatar.vue) */
accountRoutes.delete('/me/avatar', requireAuth, async (c) => {
const avatar = `${config.avatarUriPrefix}/default.png`
await db
.update(schema.userProfile)
.set({ avatar })
.where(eq(schema.userProfile.userId, c.get('user')!.id))
return success(c, { avatar })
})
/**
* 首页周榜的榜面大小。周榜解决的是「这一周谁在往前走」—— 每周一清零,所以榜面短一点更像「这周的头名」,
* 长了反而又变成一张追不上的总表。榜外的人靠 `me` 单独看到自己的名次。
*/
const WEEKLY_BOARD_SIZE = 10
/** 算「解决」的两个状态:AST_CHECK_FAILED 也是答案对了,与排名页(routes/ranking.ts)同口径 */
const ACCEPTED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
/**
* 本周进步榜:按**本周首次 AC 的题目数**排名,每周一 0:00(东八区)清零。
*
* 「首次 AC」是靠 NOT EXISTS 排掉本周之前已经通过过的 (user, problem) 对,不是简单
* 数本周 AC 的去重题数 —— 后者把老题重交一遍也算成绩,一分钟能刷满一屏。
* 相关子查询的四个条件正好是 `submission_public_metrics_idx`
* (user_id, problem_id, result, create_time,WHERE contest_id IS NULL)的全部列,
* 而且外层已经把行数收在「本周的 AC」这一小撮上,不会退化成按人全表回查。
*/
accountRoutes.get('/rankings/weekly', optionalAuth, async (c) => {
const user = c.get('user')
const scope = c.req.query('scope') === 'class' ? 'class' : 'global'
const className = scope === 'class' ? (user?.className ?? null) : null
if (scope === 'class' && !className) return failure(c, 400, 'class-missing', '用户没有班级信息')
const start = weekStart()
// 入榜人群与排名页一致(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(isNull(schema.submission.contestId), gte(schema.submission.createTime, start), audience)
const earlier = alias(schema.submission, 'earlier')
const [solvedRows, submittedRows] = await Promise.all([
db
.select({
userId: schema.submission.userId,
username: schema.user.username,
value: countDistinct(schema.submission.problemId),
})
.from(schema.submission)
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(
and(
thisWeek,
inArray(schema.submission.result, ACCEPTED_RESULTS),
notExists(
db
.select({ one: sql`1` })
.from(earlier)
.where(
and(
eq(earlier.userId, schema.submission.userId),
eq(earlier.problemId, schema.submission.problemId),
isNull(earlier.contestId),
inArray(earlier.result, ACCEPTED_RESULTS),
lt(earlier.createTime, start),
),
),
),
),
)
.groupBy(schema.submission.userId, schema.user.username),
db
.select({ userId: schema.submission.userId, value: count() })
.from(schema.submission)
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(thisWeek)
.groupBy(schema.submission.userId),
])
const submissions = new Map(submittedRows.map((row) => [row.userId, row.value]))
/**
* 排序键与全服榜同构:解决多的在前 → 同解决数时提交少的在前 → 再同按 id。
* 第三档同样不是凑数,周榜上「都是 1 题」的学生成片存在,没有稳定兜底键时
* postgres 每次返回的顺序可以不同,刷新一下名次就变了。
*/
const ranked = solvedRows
.sort((a, b) => b.value - a.value || (submissions.get(a.userId) ?? 0) - (submissions.get(b.userId) ?? 0) || a.userId - b.userId)
.map(
(row, index) =>
({
user: sampleUser({ id: row.userId, username: row.username }, null),
solvedCount: row.value,
submissionCount: submissions.get(row.userId) ?? 0,
rank: index + 1,
}) satisfies WeeklyRankItem,
)
return success(c, {
start,
scope,
className,
total: ranked.length,
results: ranked.slice(0, WEEKLY_BOARD_SIZE),
me: ranked.find((row) => row.user.id === user?.id) ?? null,
} satisfies WeeklyRank)
})
/** 题数少于这个的知识点不参与升级:3 道题的知识点做完就「精通」,没有意义 */
const KNOWLEDGE_MIN_PROBLEMS = 10
/** 同班这么多比例的人点亮过,才算「班里在学」,才会出现在「还没碰过」里 */
const KNOWLEDGE_CLASS_TOUCHED_RATIO = 0.3
/**
* 四档门槛:入门 1 道、会了 3 道、熟练 = 题数的 40%(最多 10)、精通 = 75%(最多 20)。
* 按题量缩放是因为知识点之间差得很远(循环结构 107 道、数组 15 道)。
*
* 拿 2025 秋回测过:中位数的学生一学期升 5 档,前 25% 升 9 档;加上「入门」这一档之前,
* 26% 的学生一档都没升,其中一半做对过带标签的题、只是没到 3 道。
*/
function levelThresholds(problemCount: number) {
return [1, 3, Math.max(4, Math.min(10, Math.round(problemCount * 0.4))), Math.max(5, Math.min(20, Math.round(problemCount * 0.75)))]
}
function levelOf(solved: number, thresholds: number[]) {
return thresholds.filter((threshold) => solved >= threshold).length
}
/**
* 我的知识点地图。「做对」按题目算(同一题只算一次),不含比赛提交,
* 题目只算公开的题库题 —— 和 problemCount 同一个口径,不然会出现 12/10。
*/
accountRoutes.get('/me/knowledge', requireAuth, async (c) => {
const user = c.get('user')!
return success(c, await knowledgeMapOf(user.id, user.className))
})
/** 一个人的知识点地图:「我的知识点」和老师在个人主页上看学生共用 */
async function knowledgeMapOf(userId: number, className: string | null): Promise<KnowledgeMap> {
const tags = await db
.select({
id: schema.problemTag.id,
name: schema.problemTag.name,
problemCount: count(),
})
.from(schema.problemTag)
.innerJoin(schema.problemTags, eq(schema.problemTags.problemtagId, schema.problemTag.id))
.innerJoin(schema.problem, eq(schema.problem.id, schema.problemTags.problemId))
.where(and(eq(schema.problemTag.category, 'knowledge'), eq(schema.problem.visible, true), isNull(schema.problem.contestId)))
.groupBy(schema.problemTag.id, schema.problemTag.name)
.having(sql`count(*) >= ${KNOWLEDGE_MIN_PROBLEMS}`)
const tagIds = tags.map((tag) => tag.id)
if (!tagIds.length) return { tags: [], classTouched: [] }
const firstAccepted = db
.select({
problemId: schema.submission.problemId,
firstAt: min(schema.submission.createTime).as('first_at'),
})
.from(schema.submission)
.where(and(eq(schema.submission.userId, userId), isNull(schema.submission.contestId), inArray(schema.submission.result, ACCEPTED_RESULTS)))
.groupBy(schema.submission.problemId)
.as('first_accepted')
const start = weekStart()
const [mine, classTouchedRows, classSize] = await Promise.all([
db
.select({
tagId: schema.problemTags.problemtagId,
solved: count(),
solvedBeforeWeek: sql<number>`count(*) filter (where ${firstAccepted.firstAt} < ${start})::int`,
})
.from(firstAccepted)
.innerJoin(schema.problemTags, eq(schema.problemTags.problemId, firstAccepted.problemId))
.innerJoin(schema.problem, eq(schema.problem.id, firstAccepted.problemId))
.where(and(inArray(schema.problemTags.problemtagId, tagIds), eq(schema.problem.visible, true), isNull(schema.problem.contestId)))
.groupBy(schema.problemTags.problemtagId),
className
? db
.select({
tagId: schema.problemTags.problemtagId,
users: countDistinct(schema.submission.userId),
})
.from(schema.submission)
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.innerJoin(schema.problemTags, eq(schema.problemTags.problemId, schema.submission.problemId))
.where(
and(
eq(schema.user.className, className),
eq(schema.user.isDisabled, false),
inArray(schema.user.adminType, [...STUDENT_ROLES]),
isNull(schema.submission.contestId),
inArray(schema.submission.result, ACCEPTED_RESULTS),
inArray(schema.problemTags.problemtagId, tagIds),
),
)
.groupBy(schema.problemTags.problemtagId)
: [],
className
? db
.select({ total: count() })
.from(schema.user)
.where(and(eq(schema.user.className, className), eq(schema.user.isDisabled, false), inArray(schema.user.adminType, [...STUDENT_ROLES])))
: [],
])
const mineByTag = new Map(mine.map((row) => [row.tagId, row]))
const total = classSize[0]?.total ?? 0
const touchedIds = new Set(classTouchedRows.filter((row) => total > 0 && row.users / total >= KNOWLEDGE_CLASS_TOUCHED_RATIO).map((row) => row.tagId))
return {
tags: tags
.sort((a, b) => b.problemCount - a.problemCount)
.map((tag) => {
const thresholds = levelThresholds(tag.problemCount)
const solved = mineByTag.get(tag.id)?.solved ?? 0
const level = levelOf(solved, thresholds)
return {
name: tag.name,
problemCount: tag.problemCount,
solved,
level,
levelAtWeekStart: levelOf(mineByTag.get(tag.id)?.solvedBeforeWeek ?? 0, thresholds),
nextAt: thresholds[level] ?? null,
} satisfies KnowledgeLevel
}),
classTouched: tags.filter((tag) => touchedIds.has(tag.id)).map((tag) => tag.name),
}
}
accountRoutes.get('/problems/:displayId/rank', requireAuth, async (c) => {
const user = c.get('user')!
const [problem] = await db
.select({ id: schema.problem.id })
.from(schema.problem)
.where(
and(sql`lower(${schema.problem.displayId}) = lower(${c.req.param('displayId')})`, isNull(schema.problem.contestId), eq(schema.problem.visible, true)),
)
.limit(1)
if (!problem) return failure(c, 404, 'problem-not-found', 'Problem does not exist')
const accepted = and(eq(schema.submission.problemId, problem.id), inArray(schema.submission.result, [0, 10]))
const [all] = await db
.select({ value: countDistinct(schema.submission.userId) })
.from(schema.submission)
.where(accepted)
const className = user.className ?? ''
const classWhere = className
? and(
accepted,
inArray(
schema.submission.userId,
db
.select({ id: schema.user.id })
.from(schema.user)
.where(and(eq(schema.user.className, className), eq(schema.user.isDisabled, false))),
),
)
: accepted
const [classCount] = className
? await db
.select({ value: countDistinct(schema.submission.userId) })
.from(schema.submission)
.where(classWhere)
: [{ value: 0 }]
const [first] = await db
.select({ value: min(schema.submission.createTime) })
.from(schema.submission)
.where(and(classWhere, eq(schema.submission.userId, user.id)))
let rank = -1
if (first?.value) {
const [rankRow] = await db
.select({ value: count() })
.from(schema.submission)
.where(and(classWhere, lte(schema.submission.createTime, first.value)))
rank = rankRow?.value ?? -1
}
return success(c, {
className,
rank,
classAcCount: classCount?.value ?? 0,
allAcCount: all?.value ?? 0,
} satisfies ProblemRank)
})
/**
* 把 `user_profile.acm_problems_status` 里缓存的题目编号刷成当前值 ——
* 教师改了题目的 `_id`(后台「修改题目编号」)之后,学生个人主页上的那份缓存会变旧。
*
* **目前没有任何前端在调用它**,两代前端都只定义了函数、没有调用点。保留是因为
* 它是唯一能修这份缓存的入口;要接 UI 的话,从这里开始。
*
* 旧后端 `ProfileProblemDisplayIDRefreshAPI` 这段是坏的:它用
* `dict(zip(ids, display_ids))` 把「dict 键顺序」和「查询返回顺序」硬凑成对,
* 题目一旦被隐藏或删除,display_ids 就比 ids 短 —— 轻则把编号张冠李戴写进库,
* 重则 `id_map[k]` KeyError。这里改成按 id 建 Map、查不到就不动。
*/
accountRoutes.post('/me/problem-display-ids/refresh', requireAuth, async (c) => {
const user = c.get('user')!
const [profile] = await db
.select({ value: schema.userProfile.acmProblemsStatus })
.from(schema.userProfile)
.where(eq(schema.userProfile.userId, user.id))
.limit(1)
const status = asRecord(profile?.value)
const problems = asRecord(status.problems)
const ids = Object.keys(problems).map(Number).filter(Number.isInteger)
if (ids.length > 0) {
const rows = await db
.select({ id: schema.problem.id, displayId: schema.problem.displayId })
.from(schema.problem)
.where(and(inArray(schema.problem.id, ids), eq(schema.problem.visible, true)))
const displayIds = new Map(rows.map((row) => [String(row.id), row.displayId]))
for (const [id, value] of Object.entries(problems)) {
const item = asRecord(value)
const displayId = displayIds.get(id)
if (displayId) item._id = displayId
problems[id] = item
}
status.problems = problems
await db.update(schema.userProfile).set({ acmProblemsStatus: status }).where(eq(schema.userProfile.userId, user.id))
}
return success(c, null)
})