Files
OJ2/apps/api/src/routes/problem.ts
T
xuyueandClaude Opus 5.5 ed7752afbb
Deploy / deploy (push) Canceled after 0s
feat(题目列表): 学生自己挑题时只看得到一堆标签和提交数,不知道自己练到哪了——改成左栏知识点进度、顶上先说做到了什么
- 左栏是知识点(可切到主题)和自己的进度:做了一部分写 37/96 加进度条,全做对写「通关」,
  一道没做对过的只写题数,不再是一整列 0/108
- 顶上一行:做对几道 · 这周 +n(或连续几周都有做对)· 离哪个成就最近 ·「有 n 道差一点」
  (点开才列出来);新生引去「输出入门」;一言照旧在右边
- 每题最后一列是做对 / 做过的人数,代替提交数和按提交算的通过率(和题目页统计同一个口径)
- 「画流程图 / 语法要求 / 有参考图」挂在题目名后面,「全部类型」下拉单独筛这三类,
  不再混在排序里;题目页的「代码要求」统一叫「语法要求」
- 「只看没做完」开关(默认关)、「随便来一道」(当前知识点里先挑没做对的简单 / 中等题)
- 画流程图的题,流程图评到 A / S 在状态列也打勾,和题单、课堂条一个口径
- 老师看不到状态列和个人进度;出题者筛选保留

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:31:49 +08:00

478 lines
17 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 {
problemListSortSchema,
problemTypeFilterSchema,
type ProblemAuthor,
type ProblemDetail,
type ProblemList,
type ProblemListItem,
type ProblemProgress,
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, type AppEnv } from "../auth/middleware"
import { db, schema } from "../db"
import { astRequirements } from "../judge/ast"
import { failure, success } from "../http"
import { JudgeStatus } from "../judge/status"
import {
buildProblemProgress,
problemStates,
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
}
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,
// 流程图评到 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
}
/** 题号排序:先按长度再按字面,1001 < 1100 < P026 < SQL09,不会排成 1001、1100、11、2 */
const displayIdOrder = [
asc(sql`length(${schema.problem.displayId})`),
asc(schema.problem.displayId),
]
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)),
),
)
}
if (type === "reference") filters.push(eq(schema.problem.showFlowchart, true))
if (type === "flowchart") filters.push(eq(schema.problem.allowFlowchart, true))
if (type === "ast") filters.push(sql`${schema.problem.astRules} is not null`)
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)
// 默认:选了知识点按题号(同一系列的题挨在一起),全部题目按最新创建。
// 旧的排序值(最多提交、画流程图……)落到默认 —— 类型已经拆成了单独的筛选
const sort = problemListSortSchema.safeParse(c.req.query("sort") ?? "").data ?? ""
const effective = sort || (tag ? "id" : "new")
const allCounts = effective === "popular" ? await problemUserCounts() : null
const popularity = allCounts
? sql`case ${schema.problem.id} ${sql.join(
[...allCounts].map(([id, n]) => sql`when ${id} then ${n.solved}`),
sql` `,
)} else 0 end`
: null
const order =
effective === "id"
? displayIdOrder
: popularity
? [desc(popularity), ...displayIdOrder]
: [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),
),
)
.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[])
})
/**
* 「随便来一道」:范围是当前知识点(不传就是全部),先挑自己还没做对的简单 / 中等题,
* 没有就放宽到没做对的困难题,再没有就随便一道(全做对了也给一道,不让按钮落空)。
*/
problemRoutes.get("/problems/random", optionalAuth, async (c) => {
const user = c.get("user")
const tag = c.req.query("tag")?.trim()
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,
),
)
.orderBy(
solved.length ? asc(inArray(schema.problem.id, solved)) : sql`1`,
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[])
})
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** 或**连挂三次**时才被调用,正是候选最容易全中的时候。
const statuses = await getProblemStatuses(c.get("user")?.id)
const solvedIds = Object.entries(statuses)
.filter(([, value]) => asRecord(value).status === JudgeStatus.ACCEPTED)
.map(([key]) => Number(key))
.filter((id) => Number.isInteger(id))
// 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, null, 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)
})