Deploy / deploy (push) Canceled after 0s
- 左栏是知识点(可切到主题)和自己的进度:做了一部分写 37/96 加进度条,全做对写「通关」, 一道没做对过的只写题数,不再是一整列 0/108 - 顶上一行:做对几道 · 这周 +n(或连续几周都有做对)· 离哪个成就最近 ·「有 n 道差一点」 (点开才列出来);新生引去「输出入门」;一言照旧在右边 - 每题最后一列是做对 / 做过的人数,代替提交数和按提交算的通过率(和题目页统计同一个口径) - 「画流程图 / 语法要求 / 有参考图」挂在题目名后面,「全部类型」下拉单独筛这三类, 不再混在排序里;题目页的「代码要求」统一叫「语法要求」 - 「只看没做完」开关(默认关)、「随便来一道」(当前知识点里先挑没做对的简单 / 中等题) - 画流程图的题,流程图评到 A / S 在状态列也打勾,和题单、课堂条一个口径 - 老师看不到状态列和个人进度;出题者筛选保留 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
478 lines
17 KiB
TypeScript
478 lines
17 KiB
TypeScript
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)
|
||
})
|