diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 51f5b12..d2f9b97 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -15,6 +15,7 @@ import { achievementRoutes } from "./routes/achievement" import { aiRoutes } from "./routes/ai" import { flowchartRoutes } from "./routes/flowchart" import { problemRoutes } from "./routes/problem" +import { problemStatsRoutes } from "./routes/problem-stats" import { submissionRoutes } from "./routes/submission" import { trialRunRoutes } from "./routes/trial-run" import { siteRoutes } from "./routes/site" @@ -40,6 +41,7 @@ app.route("/api", achievementRoutes) app.route("/api", aiRoutes) app.route("/api", flowchartRoutes) app.route("/api", problemRoutes) +app.route("/api", problemStatsRoutes) app.route("/api", submissionRoutes) app.route("/api", trialRunRoutes) app.route("/api", judgeServerRoutes) diff --git a/apps/api/src/routes/problem-stats.ts b/apps/api/src/routes/problem-stats.ts new file mode 100644 index 0000000..74b2777 --- /dev/null +++ b/apps/api/src/routes/problem-stats.ts @@ -0,0 +1,259 @@ +import { type ProblemClassDetail, type ProblemStats } from "@oj2/contract" +import { and, eq, isNotNull, sql } from "drizzle-orm" +import { Hono } from "hono" + +import { optionalAuth, type AppEnv } from "../auth/middleware" +import { db, schema } from "../db" +import { failure, success } from "../http" +import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from "../judge/status" +import { canAccessContest, contestDetailsAllowed, findAccessibleContest } from "../services/contest" +import { accepted } from "../services/learning-stats" +import { localTime } from "../time" +import { isAdminRole, isTeacherOrAbove, queryInteger, stripClassPrefix } from "./helpers" + +export const problemStatsRoutes = new Hono() + +/** + * 同一天班里至少这么多人交过,才算「这个班一起做过这题」。只看总人数不行: + * 2079 有个班 2 月里零星补做了 6 个人,比 12 月全班 26 人做的那次还「近」,默认就选成了它 + */ +const CLASS_MIN_TRIED = 5 + +const EMPTY: ProblemStats = { + locked: false, + tried: 0, + solved: 0, + tries: { one: 0, few: 0, many: 0 }, + failures: [], + wrongAnswerFirstCase: 0, + me: null, + myClass: null, + classes: null, + classDetail: null, +} + +// type 而不是 interface:db.execute 的泛型约束是 Record,interface 不带索引签名 +type UserRow = { + user_id: number + username: string | null + class_name: string | null + attempts: number + /** 做对之前(含做对那次)交了几次;没做对就是全部次数 */ + tries: number + solved: boolean + last_result: JudgeStatusValue + last_time: string +} + +/** + * 题目页「统计」页签。id 是内部题号(problem.id):比赛题的展示题号是 1、2、3, + * 拿 displayId 查会撞上同号的公开题(原来的「历年 AC 率」就是这么查错的)。 + * `className` 只有老师用:选中哪个班看明细,不填就是最近做过这题的那个班。 + */ +problemStatsRoutes.get("/problems/:id/stats", optionalAuth, async (c) => { + const user = c.get("user") + const id = queryInteger(c.req.param("id"), 0, { min: 1 }) + const [problem] = await db + .select({ + id: schema.problem.id, + contestId: schema.problem.contestId, + visible: schema.problem.visible, + }) + .from(schema.problem) + .where(eq(schema.problem.id, id)) + .limit(1) + if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist") + + const inContest = problem.contestId !== null + if (inContest) { + const contest = await findAccessibleContest(user, problem.contestId!) + if (!contest) return failure(c, 404, "problem-not-found", "Problem does not exist") + const access = await canAccessContest(c, contest, "problems") + if (!access.ok) return failure(c, 403, access.code, access.message) + // 比赛没结束:别人几次做对、错在哪一律不给,和题目详情里计数归零同一个口径 + if (!contestDetailsAllowed(user, contest)) { + return success(c, { ...EMPTY, locked: true } satisfies ProblemStats) + } + } else if (!problem.visible && !isAdminRole(user)) { + return failure(c, 404, "problem-not-found", "Problem does not exist") + } + + const acceptedList = sql.join( + accepted.map((value) => sql`${value}`), + sql`, `, + ) + const unjudgedList = sql.join( + UNJUDGED_RESULTS.map((value) => sql`${value}`), + sql`, `, + ) + // 每人一行:窗口函数先给每条提交标上这个人第一次做对的时刻,再按人数到那一刻为止交了几次 + const day = sql`to_char(${localTime(schema.submission.createTime)}, 'YYYY-MM-DD')` + const [users, failureRows, firstCase, classDays] = await Promise.all([ + db.execute(sql` + select s.user_id, u.username, u.class_name, + count(*)::int as attempts, + count(*) filter (where s.first_ac is null or s.create_time <= s.first_ac)::int as tries, + (min(s.first_ac) is not null) as solved, + (array_agg(s.result order by s.create_time desc))[1] as last_result, + max(s.create_time) as last_time + from ( + select user_id, result, create_time, + min(create_time) filter (where result in (${acceptedList})) + over (partition by user_id) as first_ac + from ${schema.submission} + where problem_id = ${problem.id} and result not in (${unjudgedList}) + ) s + left join ${schema.user} u on u.id = s.user_id + group by s.user_id, u.username, u.class_name + `), + db.execute<{ class_name: string | null; result: JudgeStatusValue; n: number }>(sql` + select u.class_name, s.result, count(*)::int as n + from ${schema.submission} s + left join ${schema.user} u on u.id = s.user_id + where s.problem_id = ${problem.id} + and s.result not in (${acceptedList}) + and s.result not in (${unjudgedList}) + group by u.class_name, s.result + `), + // 判题机按测试点回一个数组,test_case 是 "1"、"2"…… + db.execute<{ n: number }>(sql` + select count(*)::int as n from ${schema.submission} + where problem_id = ${problem.id} and result = ${JudgeStatus.WRONG_ANSWER} + and jsonb_typeof(info->'data') = 'array' + and exists ( + select 1 from jsonb_array_elements(info->'data') e + where e->>'test_case' = '1' and (e->>'result')::int <> 0 + ) + `), + // 每个班每天几个人交过,取人最多的那天当「这个班做这题的那天」 + db + .select({ + className: schema.user.className, + day, + users: sql`count(distinct ${schema.submission.userId})::int`, + }) + .from(schema.submission) + .innerJoin(schema.user, eq(schema.user.id, schema.submission.userId)) + .where(and(eq(schema.submission.problemId, problem.id), isNotNull(schema.user.className))) + .groupBy(schema.user.className, day), + ]) + + const solvedUsers = users.filter((row) => row.solved) + const tries = { one: 0, few: 0, many: 0 } + for (const row of solvedUsers) { + if (row.tries <= 1) tries.one++ + else if (row.tries <= 3) tries.few++ + else tries.many++ + } + const sortFailures = (map: Map) => + [...map].map(([result, count]) => ({ result, count })).sort((a, b) => b.count - a.count) + const siteFailures = new Map() + for (const row of failureRows) { + siteFailures.set(row.result, (siteFailures.get(row.result) ?? 0) + row.n) + } + + const mine = user ? users.find((row) => row.user_id === user.id) : undefined + const myClassName = !inContest && user?.className ? user.className : null + const classmates = myClassName ? users.filter((row) => row.class_name === myClassName) : [] + + let classes: ProblemStats["classes"] = null + let classDetail: ProblemClassDetail | null = null + if (!inContest && isTeacherOrAbove(user)) { + const byClass = new Map() + for (const row of users) { + if (!row.class_name) continue + byClass.set(row.class_name, [...(byClass.get(row.class_name) ?? []), row]) + } + const peak = new Map() + for (const row of classDays) { + const current = peak.get(row.className!) + const later = current && row.users === current.users && row.day > current.day + if (!current || row.users > current.users || later) { + peak.set(row.className!, { day: row.day, users: row.users }) + } + } + const lessonClasses = [...byClass] + .filter(([className]) => (peak.get(className)?.users ?? 0) >= CLASS_MIN_TRIED) + .map(([className, rows]) => ({ + className, + tried: rows.length, + solved: rows.filter((row) => row.solved).length, + day: peak.get(className)!.day, + })) + .sort((a, b) => b.day.localeCompare(a.day)) + classes = lessonClasses + // 地址里指定的班不在列表里(零星几个人做过)也照样给明细 + const wanted = c.req.query("className")?.trim() + const selected = wanted && byClass.has(wanted) ? wanted : lessonClasses[0]?.className + if (selected) { + classDetail = await buildClassDetail( + selected, + peak.get(selected)!.day, + byClass.get(selected)!, + failureRows, + ) + } + } + + return success(c, { + locked: false, + tried: users.length, + solved: solvedUsers.length, + tries, + failures: sortFailures(siteFailures), + wrongAnswerFirstCase: firstCase[0]?.n ?? 0, + me: user ? { attempts: mine?.attempts ?? 0, solved: mine?.solved ?? false } : null, + myClass: myClassName + ? { + className: myClassName, + tried: classmates.length, + solved: classmates.filter((row) => row.solved).length, + } + : null, + classes, + classDetail, + } satisfies ProblemStats) +}) + +async function buildClassDetail( + className: string, + day: string, + rows: UserRow[], + failureRows: { class_name: string | null; result: JudgeStatusValue; n: number }[], +): Promise { + // 花名册只数学生:老师、助教也可能挂着班级 + const roster = await db + .select({ id: schema.user.id, username: schema.user.username }) + .from(schema.user) + .where( + and( + eq(schema.user.className, className), + eq(schema.user.adminType, "Regular User"), + eq(schema.user.isDisabled, false), + ), + ) + const triedIds = new Set(rows.map((row) => row.user_id)) + const name = (username: string | null) => stripClassPrefix(username ?? "", className) + return { + className, + roster: roster.length, + day, + failures: failureRows + .filter((row) => row.class_name === className) + .map((row) => ({ result: row.result, count: row.n })) + .sort((a, b) => b.count - a.count), + unsolved: rows + .filter((row) => !row.solved) + .sort((a, b) => b.attempts - a.attempts) + .map((row) => ({ + realName: name(row.username), + attempts: row.attempts, + lastResult: row.last_result, + })), + solved: rows + .filter((row) => row.solved) + .sort((a, b) => a.last_time.localeCompare(b.last_time)) + .map((row) => name(row.username)), + untouched: roster.filter((row) => !triedIds.has(row.id)).map((row) => name(row.username)), + } +} diff --git a/apps/api/src/routes/problem.ts b/apps/api/src/routes/problem.ts index ee6bff0..de0cf70 100644 --- a/apps/api/src/routes/problem.ts +++ b/apps/api/src/routes/problem.ts @@ -1,11 +1,4 @@ -import type { - ProblemAuthor, - ProblemDetail, - ProblemList, - ProblemListItem, - Tag, - YearlyAc, -} from "@oj2/contract" +import type { ProblemAuthor, ProblemDetail, ProblemList, ProblemListItem, Tag } from "@oj2/contract" import { and, asc, @@ -13,7 +6,6 @@ import { countDistinct, desc, eq, - gte, ilike, inArray, isNull, @@ -29,7 +21,6 @@ import { db, schema } from "../db" import { astRequirements } from "../judge/ast" import { failure, success } from "../http" import { JudgeStatus } from "../judge/status" -import { dayStart, localTime, shiftMonthsByCalendar } from "../time" import { asFilterValue, countFailedSubmissions, @@ -245,47 +236,6 @@ problemRoutes.get("/problem-authors", async (c) => { 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 = dayStart(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 }) @@ -360,48 +310,6 @@ problemRoutes.get("/problems/:displayId/similar", optionalAuth, async (c) => { ) }) -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({ diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index ca85995..50f0f1c 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -22,7 +22,7 @@ import { type CreateSubmissionResponse, type ProblemAuthor, type ProblemListItem, - type YearlyAc, + type ProblemStats, type ProblemList, type CreateFlowchartResponse, type FlowchartScores, @@ -115,9 +115,14 @@ export async function getProblem(problemID: string, contestID: string) { return problem } -// 未登录返回 "0",登录后返回百分比字符串 -export function getProblemBeatRate(problemID: number) { - return api.get(`problems/${problemID}/beat-count`) +/** + * 题目页「统计」页签。problemID 是内部题号(problem.id),比赛题也能查。 + * className 只有老师用:看哪个班的明细,不填就是最近做过这题的班 + */ +export function getProblemStats(problemID: number, className?: string) { + return api.get(`problems/${problemID}/stats`, { + params: className ? { className } : {}, + }) } export async function getSubmission(id: string): Promise { @@ -405,12 +410,6 @@ export function getSimilarProblems(problemId: string) { .then((response) => response.map(toProblemRow)) } -export type { YearlyAc as YearlyACData } from "@oj2/contract" - -export function getProblemYearlyAC(problemId: string) { - return api.get(`problems/${encodeURIComponent(problemId)}/yearly-ac`) -} - // ==================== 流程图相关API ==================== export function submitFlowchart(data: { diff --git a/apps/web/src/oj/problem/components/ProblemDrawer.vue b/apps/web/src/oj/problem/components/ProblemDrawer.vue index d1620af..3f73bba 100644 --- a/apps/web/src/oj/problem/components/ProblemDrawer.vue +++ b/apps/web/src/oj/problem/components/ProblemDrawer.vue @@ -142,7 +142,7 @@ function goAllSubmissions() { - +