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() function objectValue(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {} } function publicTemplates(value: unknown) { const templates: Record = {} 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() 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() 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, statuses: Record, ) { 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`extract(year from ${localTime(schema.submission.createTime)})::int` const rows = await db .select({ year, total: count(), accepted: sql`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) })