学生原来只能拿两张表逐格对,看不出要改的是哪几行。现在改过的格子、新插的行标黄, 删掉的行在表下面写「删掉了 2 行(id 是 2、3)」。按第一列配对,对不上就不标。 数据表和期望结果抽成一个组件,后台出题页的预览也用它。 题型判断统一走契约的 isSqlProblem()。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
503 lines
19 KiB
TypeScript
503 lines
19 KiB
TypeScript
import {
|
||
isSqlProblem,
|
||
normalizeLanguage,
|
||
problemListSortSchema,
|
||
problemTypeFilterSchema,
|
||
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, 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
|
||
}
|
||
|
||
/** 「全部类型」下拉的筛选条件,列表和「随便来一道」共用 */
|
||
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
|
||
}
|
||
|
||
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 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 problemUserCounts() : 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),
|
||
),
|
||
)
|
||
.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 = normalizeLanguage(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[])
|
||
})
|
||
|
||
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)
|
||
})
|