Files
OJ2/apps/api/src/routes/problem.ts
T
xuyueandClaude Opus 5 b16e11e85d
Deploy / deploy (push) Canceled after 0s
fix(题目推荐): 已 AC 过滤下推 SQL、难度按语义排序、难度标签渲染
相似题目推荐(GET /problems/:displayId/similar)三个 bug:

- 排除已 AC 的题发生在 limit(5) 之后,候选被筛掉几条就少几条,甚至清零让整块
  不渲染。而这个接口只在刚 AC 或连挂三次时才调用,正是最容易全中的时候。
  (老栈是 .exclude(...) 在切片之前,重写时引入的回归)
- order by difficulty 是 text 字典序,High < Low < Mid,「由易到难」实际是最难的
  先上。改成显式 case 排名,并加上共享标签数作为第一排序键、id 兜稳定序。
- 前端 ref<any[]> 盖住了类型错误:getSimilarProblems 已过 filterResult,难度是
  中文,模板却在比 'Low' / 'High' 再查 DIFFICULTY 表,结果每条推荐都渲染成黄色
  的「中等」。

顺带:比赛题不再发这个请求(接口只在公开题库按 displayId 找,撞号会在比赛中把
题库列给学生);多余的 id in (子查询) 换成 join 中间表。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 09:24:46 +08:00

535 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 type {
ProblemAuthor,
ProblemDetail,
ProblemList,
ProblemListItem,
Tag,
YearlyAc,
} from "@oj2/contract"
import {
and,
asc,
count,
countDistinct,
desc,
eq,
gte,
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 { localTime, shiftMonthsByCalendar, todayStart } from "../time"
import {
asFilterValue,
countFailedSubmissions,
objectValue as toObject,
queryInteger,
sampleUser,
} from "./helpers"
export const problemRoutes = new Hono<AppEnv>()
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
function publicTemplates(value: unknown) {
const templates: Record<string, string> = {}
for (const [language, raw] of Object.entries(objectValue(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 toObject(toObject(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 listItem(
row: {
problem: typeof schema.problem.$inferSelect
user: typeof schema.user.$inferSelect
realName: string | null
},
tags: Map<number, string[]>,
statuses: Record<string, unknown>,
) {
const status = toObject(statuses[String(row.problem.id)]).status
return {
id: row.problem.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(row.problem.id) ?? [],
contestId: row.problem.contestId,
allowFlowchart: row.problem.allowFlowchart,
showFlowchart: row.problem.showFlowchart,
hasAstRules: row.problem.astRules !== null,
myStatus: typeof status === "number" ? status : null,
} satisfies ProblemListItem
}
problemRoutes.get("/problems", optionalAuth, async (c) => {
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()
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 where = and(...filters)
const sort = c.req.query("sort")
const order =
sort === "flowchart"
? [
desc(schema.problem.allowFlowchart),
desc(schema.problem.showFlowchart),
desc(schema.problem.createTime),
]
: sort === "ast"
? [
desc(sql`(${schema.problem.astRules} is not null)`),
desc(schema.problem.createTime),
]
: sort === "-accepted_number"
? [desc(schema.problem.acceptedNumber)]
: sort === "accepted_number"
? [asc(schema.problem.acceptedNumber)]
: sort === "-submission_number"
? [desc(schema.problem.submissionNumber)]
: sort === "submission_number"
? [asc(schema.problem.submissionNumber)]
: sort === "difficulty"
? [asc(schema.problem.difficulty)]
: 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 [tags, statuses] = await Promise.all([
getProblemTags(rows.map((row) => row.problem.id)),
getProblemStatuses(c.get("user")?.id),
])
return success(c, {
results: rows.map((row) => listItem(row, tags, statuses)),
total: totalRow?.value ?? 0,
} satisfies ProblemList)
})
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,
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)
.having(sql`count(${schema.problemTags.problemId}) > 0`)
.orderBy(asc(schema.problemTag.name))
return success(c, rows satisfies Tag[])
})
problemRoutes.get("/problems/random", async (c) => {
const [row] = await db
.select({ displayId: schema.problem.displayId })
.from(schema.problem)
.where(
and(eq(schema.problem.visible, true), isNull(schema.problem.contestId)),
)
.orderBy(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/:id/beat-count", optionalAuth, async (c) => {
const user = c.get("user")
if (!user) return success(c, "0")
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [mine] = await db
.select({ value: count() })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
eq(schema.submission.problemId, id),
inArray(schema.submission.result, [
JudgeStatus.ACCEPTED,
JudgeStatus.AST_CHECK_FAILED,
]),
),
)
if (!mine?.value) return success(c, "0")
// 「近两年」按东八区日历算到当天零点
const since = todayStart(shiftMonthsByCalendar(new Date(), -24))
const [active, accepted] = await Promise.all([
db
.select({ value: count() })
.from(schema.user)
.where(
and(
eq(schema.user.isDisabled, false),
gte(schema.user.lastLogin, since),
),
),
db
.select({ value: countDistinct(schema.submission.userId) })
.from(schema.submission)
.where(
and(
eq(schema.submission.problemId, id),
inArray(schema.submission.result, [0, 10]),
gte(schema.submission.createTime, since),
),
),
])
const total = active[0]?.value ?? 0
const solved = accepted[0]?.value ?? 0
return success(
c,
total > 0 && solved < total
? (((total - solved) / total) * 100).toFixed(2)
: "0",
)
})
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]) => toObject(value).status === JudgeStatus.ACCEPTED)
.map(([key]) => Number(key))
.filter((id) => Number.isInteger(id))
// difficulty 是 textLow / 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 tags = await getProblemTags(rows.map((row) => row.problem.id))
return success(
c,
rows.map((row) => listItem(row, tags, statuses)),
)
})
problemRoutes.get("/problems/:displayId/yearly-ac", async (c) => {
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 year = sql<number>`extract(year from ${localTime(schema.submission.createTime)})::int`
const rows = await db
.select({
year,
total: count(),
accepted: sql<number>`count(*) filter (where ${schema.submission.result} in (0, 10))::int`,
})
.from(schema.submission)
.where(
and(
eq(schema.submission.problemId, problem.id),
isNull(schema.submission.contestId),
notInArray(schema.submission.result, [6, 7]),
),
)
.groupBy(year)
.orderBy(year)
return success(
c,
rows.map(
(row) =>
({
...row,
acRate:
row.total > 0
? Math.round((row.accepted / row.total) * 10_000) / 100
: 0,
}) satisfies YearlyAc,
),
)
})
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 = objectValue(objectValue(profile?.status).problems)
const problemStatus = objectValue(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: objectValue(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
: objectValue(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)
})