Deploy / deploy (push) Canceled after 0s
相似题目推荐(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>
535 lines
17 KiB
TypeScript
535 lines
17 KiB
TypeScript
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 是 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 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)
|
||
})
|