Files
OJ2/apps/api/src/routes/problem.ts
T
xuyueandClaude Opus 5.5 29c31640b5 refactor(课堂任务): 「课堂看板」改名「课堂任务」,地址换成 /task;班级 PK 换成 /pk
- 界面上的「课堂看板」「课堂作业」统一叫「课堂任务」,顶栏、老师首页、后台菜单一起改
- 前端 oj/classroom → oj/task、oj/class → oj/pk,两个名字不再只差几个字母却毫不相干;旧地址不留跳转
- 后端 routes/classroom.ts 拆开:课堂任务的接口进 routes/task.ts(/task/board|lesson|today),
  首页的「上次来」「班里在做」进 routes/home.ts;共用的取题口径挪进 services/lesson.ts
- 契约 classroom.ts 同样拆成 task.ts / home.ts,ClassBoard* 改叫 TaskBoard*

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:53:07 +08:00

467 lines
20 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 {
AST_SUPPORTED_LANGUAGES,
isSqlProblem,
lessonLanguageSchema,
parseLanguage,
problemListSortSchema,
problemTypeFilterSchema,
type ChallengeList,
type ProblemAuthor,
type ProblemDetail,
type ProblemList,
type ProblemListItem,
type ProblemProgress,
type ProblemTypeFilter,
type Tag,
} from '@oj2/contract'
import { and, asc, count, countDistinct, desc, eq, ilike, inArray, isNull, ne, notInArray, or, sql } from 'drizzle-orm'
import { Hono } from 'hono'
import { optionalAuth, requireAuth, type AppEnv } from '../auth/middleware'
import { db, schema } from '../db'
import { astRequirements } from '../judge/ast'
import { challengeList } from '../services/challenge'
import { failure, success } from '../http'
import { JudgeStatus } from '../judge/status'
import { buildProblemProgress, problemStates, allProblemUserCounts, problemUserCounts } from '../services/problem-progress'
import { asFilterValue, countFailedSubmissions, asRecord, queryInteger, sampleUser } from './helpers'
export const problemRoutes = new Hono<AppEnv>()
function publicTemplates(value: unknown) {
const templates: Record<string, string> = {}
for (const [language, raw] of Object.entries(asRecord(value))) {
if (typeof raw !== 'string') continue
const match = raw.match(/\/\/TEMPLATE BEGIN\n([\s\S]+?)\/\/TEMPLATE END/)
templates[language] = match?.[1] ?? ''
}
return templates
}
async function getProblemStatuses(userId: number | undefined) {
if (!userId) return {}
const [profile] = await db
.select({ value: schema.userProfile.acmProblemsStatus })
.from(schema.userProfile)
.where(eq(schema.userProfile.userId, userId))
.limit(1)
return asRecord(asRecord(profile?.value).problems)
}
async function getProblemTags(problemIds: number[]) {
if (problemIds.length === 0) return new Map<number, string[]>()
const rows = await db
.select({
problemId: schema.problemTags.problemId,
name: schema.problemTag.name,
})
.from(schema.problemTags)
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
.where(inArray(schema.problemTags.problemId, problemIds))
const result = new Map<number, string[]>()
for (const row of rows) result.set(row.problemId, [...(result.get(row.problemId) ?? []), row.name])
return result
}
/** 「全部类型」下拉的筛选条件,列表和「随便来一道」共用 */
function typeFilter(type: ProblemTypeFilter | undefined) {
if (type === 'reference') return eq(schema.problem.showFlowchart, true)
if (type === 'flowchart') return eq(schema.problem.allowFlowchart, true)
if (type === 'ast') return sql`${schema.problem.astRules} is not null`
if (type === 'sql') return sql`${schema.problem.languages} @> '["SQL"]'::jsonb`
return undefined
}
/**
* 课堂任务「选题」弹框只列能用这次作业的语言交的题(`?language=C`),左栏知识点的题数也按它数。
* 学生的题目列表不带这个参数
*/
function lessonLanguageFilter(raw: string | undefined) {
const language = lessonLanguageSchema.safeParse(raw).data
return language ? sql`${schema.problem.languages} @> ${JSON.stringify([language])}::jsonb` : undefined
}
/** 要画流程图的课(`?flowchart=1`):只列打开了「让学生自己画」的题,没打开的学生那边没有画布 */
function lessonFlowchartFilter(raw: string | undefined) {
return raw === '1' ? eq(schema.problem.allowFlowchart, true) : undefined
}
type ProblemStates = Awaited<ReturnType<typeof problemStates>> | null
type UserCounts = Awaited<ReturnType<typeof problemUserCounts>>
function listItem(
row: {
problem: typeof schema.problem.$inferSelect
user: typeof schema.user.$inferSelect
realName: string | null
},
tags: Map<number, string[]>,
statuses: Record<string, unknown>,
states: ProblemStates,
counts: UserCounts,
) {
const status = asRecord(statuses[String(row.problem.id)]).status
const id = row.problem.id
return {
id,
_id: row.problem.displayId,
title: row.problem.title,
submissionNumber: row.problem.submissionNumber,
acceptedNumber: row.problem.acceptedNumber,
difficulty: row.problem.difficulty,
createdBy: sampleUser(row.user, row.realName),
tags: tags.get(id) ?? [],
contestId: row.problem.contestId,
allowFlowchart: row.problem.allowFlowchart,
showFlowchart: row.problem.showFlowchart,
hasAstRules: row.problem.astRules !== null,
isSql: isSqlProblem(row.problem),
// 流程图评到 A / S 也打勾:problemStates 已经把它算进 solved
myStatus: states?.solved.has(id) ? JudgeStatus.ACCEPTED : typeof status === 'number' ? status : null,
solvedUsers: counts.get(id)?.solved ?? 0,
triedUsers: counts.get(id)?.tried ?? 0,
} satisfies ProblemListItem
}
problemRoutes.get('/problems', optionalAuth, async (c) => {
const user = c.get('user')
const limit = queryInteger(c.req.query('limit'), 20, { min: 1, max: 250 })
const offset = queryInteger(c.req.query('offset'), 0, { min: 0 })
const filters = [eq(schema.problem.visible, true), isNull(schema.problem.contestId)]
const author = c.req.query('author')?.trim()
const keyword = c.req.query('keyword')?.trim()
const difficulty = c.req.query('difficulty')?.trim()
const tag = c.req.query('tag')?.trim()
const type = problemTypeFilterSchema.safeParse(c.req.query('type')).data
// 没登录时「只看没做完」在前端是灰的;直接拼地址来的就当没开
const undone = c.req.query('undone') === '1' && !!user
if (author) filters.push(eq(schema.user.username, author))
if (keyword) filters.push(or(ilike(schema.problem.title, `%${keyword}%`), ilike(schema.problem.displayId, `%${keyword}%`))!)
if (difficulty) filters.push(eq(schema.problem.difficulty, asFilterValue(difficulty)))
if (tag) {
filters.push(
inArray(
schema.problem.id,
db
.select({ id: schema.problemTags.problemId })
.from(schema.problemTags)
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
.where(eq(schema.problemTag.name, tag)),
),
)
}
const byType = typeFilter(type)
if (byType) filters.push(byType)
const byLanguage = lessonLanguageFilter(c.req.query('language'))
if (byLanguage) filters.push(byLanguage)
const byFlowchart = lessonFlowchartFilter(c.req.query('flowchart'))
if (byFlowchart) filters.push(byFlowchart)
const states = user ? await problemStates(user.id) : null
if (undone && states && states.solved.size > 0) filters.push(notInArray(schema.problem.id, [...states.solved]))
const where = and(...filters)
// 「最多 / 最少」按**人数**排,和列表最后一列「做对 / 做过」对得上:
// submission_number 是做过的人,accepted_number 是做对的人。认不出的排序值落到默认(最新创建)
const sort = problemListSortSchema.safeParse(c.req.query('sort') ?? '').data ?? ''
const byPeople = sort.endsWith('submission_number') || sort.endsWith('accepted_number')
const allCounts = byPeople ? await allProblemUserCounts() : null
const people = allCounts
? sql`case ${schema.problem.id} ${sql.join(
[...allCounts].map(([id, n]) => sql`when ${id} then ${sort.endsWith('accepted_number') ? n.solved : n.tried}`),
sql` `,
)} else 0 end`
: null
const order = people
? [sort.startsWith('-') ? desc(people) : asc(people), desc(schema.problem.createTime)]
: sort === 'create_time'
? [asc(schema.problem.createTime)]
: [desc(schema.problem.createTime)]
const [totalRow] = await db
.select({ value: countDistinct(schema.problem.id) })
.from(schema.problem)
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.where(where)
const rows = await db
.select({
problem: schema.problem,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.problem)
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(where)
.orderBy(...order)
.limit(limit)
.offset(offset)
const ids = rows.map((row) => row.problem.id)
const [tags, statuses, counts] = await Promise.all([getProblemTags(ids), getProblemStatuses(user?.id), allCounts ?? problemUserCounts(ids)])
return success(c, {
results: rows.map((row) => listItem(row, tags, statuses, states, counts)),
total: totalRow?.value ?? 0,
} satisfies ProblemList)
})
/**
* 题目列表顶上那行和左栏的进度。没登录给 null(前端那行换成「登录以后……」)。
* 注册在 /problems/:displayId 前面,不然被它吃掉。
*/
problemRoutes.get('/problems/progress', optionalAuth, async (c) => {
const user = c.get('user')
if (!user) return success(c, null)
return success(c, (await buildProblemProgress(user.id)) satisfies ProblemProgress)
})
problemRoutes.get('/problem-tags', async (c) => {
const keyword = c.req.query('keyword')?.trim()
// 只数公开题库里可见的题:隐藏的题和比赛题都不算,否则标签会出现在
// 首页列表里,点进去却一道题都筛不出来(对齐 /problems 的过滤条件)
const rows = await db
.select({
id: schema.problemTag.id,
name: schema.problemTag.name,
category: schema.problemTag.category,
problemCount: countDistinct(schema.problemTags.problemId),
})
.from(schema.problemTag)
.innerJoin(schema.problemTags, eq(schema.problemTags.problemtagId, schema.problemTag.id))
.innerJoin(
schema.problem,
and(
eq(schema.problem.id, schema.problemTags.problemId),
eq(schema.problem.visible, true),
isNull(schema.problem.contestId),
lessonLanguageFilter(c.req.query('language')),
lessonFlowchartFilter(c.req.query('flowchart')),
),
)
.where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined)
.groupBy(schema.problemTag.id, schema.problemTag.name, schema.problemTag.category)
.having(sql`count(${schema.problemTags.problemId}) > 0`)
.orderBy(asc(schema.problemTag.name))
return success(c, rows satisfies Tag[])
})
/**
* 「随便来一道」:范围是当前知识点和类型(不传就是全部),先挑能用自己的语言做的、
* 还没做对的简单 / 中等题,没有就一档档放宽:没做对的困难题 → 做对过的
* (全做对了也给一道,不让按钮落空)→ 别的语言的题。
*
* 语言是前端记着的「上次用的语言」,没有就是 Python。不按语言挑的话,C 学生大约每
* 9 次就抽到 1 道没有 C 的题(Python 专用题和 SQL 题)。语言排在最前、而不是筛掉:
* 选了「SQL」知识点时没有一道能用 Python 做,照样得抽一道 SQL 题出来。
*/
problemRoutes.get('/problems/random', optionalAuth, async (c) => {
const user = c.get('user')
const tag = c.req.query('tag')?.trim()
const type = problemTypeFilterSchema.safeParse(c.req.query('type')).data
// 上次用的是 SQL(刚做完一道 SQL 题)不算偏好:SQL 题一共 9 道,照它挑就成了「随便来一道
// SQL」。要 SQL 题的人会选「SQL」知识点或类型,那时按上面说的照样抽得到
const saved = parseLanguage(c.req.query('language'))
const language = saved && saved !== 'SQL' && saved !== 'Flowchart' ? saved : 'Python'
const states = user ? await problemStates(user.id) : null
const solved = states ? [...states.solved] : []
const [row] = await db
.select({ displayId: schema.problem.displayId })
.from(schema.problem)
.where(
and(
eq(schema.problem.visible, true),
isNull(schema.problem.contestId),
tag
? inArray(
schema.problem.id,
db
.select({ id: schema.problemTags.problemId })
.from(schema.problemTags)
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
.where(eq(schema.problemTag.name, tag)),
)
: undefined,
typeFilter(type),
),
)
// 没做对过题(没登录、新生)就不排这一项。别拿 sql`1` 占位:ORDER BY 里的整数常量是
// 「按第 1 列排」,会先按题号排、random() 形同虚设,每次都是同一道
.orderBy(
desc(sql`${schema.problem.languages} @> ${JSON.stringify([language])}::jsonb`),
...(solved.length ? [asc(inArray(schema.problem.id, solved))] : []),
asc(sql`${schema.problem.difficulty} = 'High'`),
sql`random()`,
)
.limit(1)
if (!row) return failure(c, 404, 'no-problems', 'No problem to pick')
return success(c, row.displayId)
})
problemRoutes.get('/problem-authors', async (c) => {
const showAll = c.req.query('all') === '1'
const rows = await db
.select({
username: schema.user.username,
problemCount: count(schema.problem.id),
})
.from(schema.problem)
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.where(and(isNull(schema.problem.contestId), eq(schema.user.isDisabled, false), showAll ? undefined : eq(schema.problem.visible, true)))
.groupBy(schema.user.username)
.orderBy(desc(count(schema.problem.id)))
return success(c, rows satisfies ProblemAuthor[])
})
/**
* 几道题的挑战:`?problems=1080,2020,4077&language=Python`。课堂条「这节课都做完了」一次要
* 一节课的几道,题目页只要当前这道。挑战按语言分,语言是学生编辑器里选的那种
*/
problemRoutes.get('/challenges', requireAuth, async (c) => {
const language = c.req.query('language') ?? ''
if (!AST_SUPPORTED_LANGUAGES.includes(language)) return success(c, [] satisfies ChallengeList)
const ids = (c.req.query('problems') ?? '')
.split(',')
.map((id) => id.trim())
.filter(Boolean)
.slice(0, 20)
return success(c, (await challengeList(c.get('user')!, ids, language)) satisfies ChallengeList)
})
problemRoutes.get('/problems/:displayId/similar', optionalAuth, async (c) => {
const [target] = 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)))
.limit(1)
if (!target) return failure(c, 404, 'problem-not-found', 'Problem not found')
const targetTags = await db.select({ id: schema.problemTags.problemtagId }).from(schema.problemTags).where(eq(schema.problemTags.problemId, target.id))
if (targetTags.length === 0) return success(c, [])
// 「已 AC 的不再推荐」必须下推到 SQL。早先是先 limit(5) 再在内存里筛,刷题多的
// 学生 5 条候选能被筛到只剩一两条、甚至清零(前端 v-if 一空整块就不渲染)——
// 而这个接口恰好只在**刚 AC** 或**连挂三次**时才被调用,正是候选最容易全中的时候。
// 做对了的和题目列表同一个口径:画流程图的题,流程图评到 A / S 也算(problemStates)
const user = c.get('user')
const [statuses, states] = await Promise.all([getProblemStatuses(user?.id), user ? problemStates(user.id) : null])
const solvedIds = states ? [...states.solved] : []
// difficulty 是 text(Low / Mid / High),直接 order by 走的是字典序 ——
// High 排在 Low 前面,「由易到难」会变成「最难的先上」。按语义显式排。
const difficultyRank = sql`case ${schema.problem.difficulty} when 'Low' then 0 when 'Mid' then 1 else 2 end`
const sharedTags = count(schema.problemTags.problemtagId)
const rows = await db
.select({
problem: schema.problem,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.problem)
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
// 直接 join 中间表(而不是 id in (子查询))是为了数出重合了几个标签,
// 拿来当第一排序键。(problem_id, problemtag_id) 上有唯一约束,不会重复计数。
.innerJoin(
schema.problemTags,
and(
eq(schema.problemTags.problemId, schema.problem.id),
inArray(
schema.problemTags.problemtagId,
targetTags.map((tag) => tag.id),
),
),
)
.where(
and(
eq(schema.problem.visible, true),
isNull(schema.problem.contestId),
ne(schema.problem.id, target.id),
solvedIds.length ? notInArray(schema.problem.id, solvedIds) : undefined,
),
)
.groupBy(schema.problem.id, schema.user.id, schema.userProfile.realName)
// 末尾的 id 是稳定排序用的:并列时没有它,同一道题两次请求能返回不同的 5 条
.orderBy(desc(sharedTags), asc(difficultyRank), desc(schema.problem.acceptedNumber), asc(schema.problem.id))
.limit(5)
const ids = rows.map((row) => row.problem.id)
const [tags, counts] = await Promise.all([getProblemTags(ids), problemUserCounts(ids)])
return success(
c,
rows.map((row) => listItem(row, tags, statuses, states, counts)),
)
})
problemRoutes.get('/problems/:displayId', optionalAuth, async (c) => {
const [row] = await db
.select({
problem: schema.problem,
creatorId: schema.user.id,
creatorUsername: schema.user.username,
})
.from(schema.problem)
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.where(and(eq(schema.problem.displayId, c.req.param('displayId')), eq(schema.problem.visible, true), isNull(schema.problem.contestId)))
.limit(1)
if (!row) return failure(c, 404, 'problem-not-found', 'Problem does not exist')
const tagRows = await db
.select({ name: schema.problemTag.name })
.from(schema.problemTags)
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
.where(eq(schema.problemTags.problemId, row.problem.id))
const user = c.get('user')
let myStatus: number | null = null
let myFailedCount = 0
if (user) {
const [profile] = await db
.select({ status: schema.userProfile.acmProblemsStatus })
.from(schema.userProfile)
.where(eq(schema.userProfile.userId, user.id))
.limit(1)
const statuses = asRecord(asRecord(profile?.status).problems)
const problemStatus = asRecord(statuses[String(row.problem.id)]).status
if (typeof problemStatus === 'number') myStatus = problemStatus
// 前端拿这个数决定「让 AI 分析我的代码」露不露面,口径必须和 POST /ai/hint
// 的服务端闸门一致,所以两边共用 countFailedSubmissions
myFailedCount = await countFailedSubmissions(user.id, row.problem.id)
}
const samples = Array.isArray(row.problem.samples) ? row.problem.samples : []
const data = {
id: row.problem.id,
_id: row.problem.displayId,
title: row.problem.title,
description: row.problem.description,
inputDescription: row.problem.inputDescription,
outputDescription: row.problem.outputDescription,
samples,
hint: row.problem.hint,
languages: row.problem.languages,
template: publicTemplates(row.problem.template),
createTime: row.problem.createTime,
lastUpdateTime: row.problem.lastUpdateTime,
timeLimit: row.problem.timeLimit,
memoryLimit: row.problem.memoryLimit,
difficulty: row.problem.difficulty,
source: row.problem.source,
prompt: row.problem.prompt,
submissionNumber: row.problem.submissionNumber,
acceptedNumber: row.problem.acceptedNumber,
statisticInfo: asRecord(row.problem.statisticInfo),
contestId: row.problem.contestId,
tags: tagRows.map((tag) => tag.name),
createdBy: sampleUser({ id: row.creatorId, username: row.creatorUsername }, null),
myStatus,
myFailedCount,
allowFlowchart: row.problem.allowFlowchart,
showFlowchart: row.problem.showFlowchart,
mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode,
flowchartData: row.problem.allowFlowchart ? null : asRecord(row.problem.flowchartData),
flowchartHint: row.problem.flowchartHint,
sqlConfig: row.problem.sqlConfig,
sqlDisplay: row.problem.sqlDisplay,
// 代码要求:只给渲染好的文案,规则原文不下发给学生
astRequirements: astRequirements(row.problem.astRules),
} satisfies ProblemDetail
return success(c, data)
})