diff --git a/apps/api/src/judge/job.ts b/apps/api/src/judge/job.ts index 8885ccc..d9958f0 100644 --- a/apps/api/src/judge/job.ts +++ b/apps/api/src/judge/job.ts @@ -3,4 +3,10 @@ export const judgeQueueName = "judge-submission" export interface JudgeJobData { submissionId: string problemId: number + /** + * 重判时带上**重判之前**的结果。落库时靠它只做差量(见 run.ts 的 persistResult): + * 原来这条已经记过一次账,重判再当成新提交记一遍,题目和个人的提交数就多一。 + * 新提交不带。 + */ + rejudgedFrom?: number } diff --git a/apps/api/src/judge/run.ts b/apps/api/src/judge/run.ts index 1e97f6e..17732ab 100644 --- a/apps/api/src/judge/run.ts +++ b/apps/api/src/judge/run.ts @@ -134,6 +134,8 @@ async function persistResult( statisticInfo: Record, contestId: number | null, submissionCreateTime: string, + /** 重判之前**已经记过账**的结果;新提交、或原来没记过账(系统错误)时为 null */ + counted: JudgeStatusValue | null, ) { return db.transaction(async (tx) => { const [currentSubmission] = await tx @@ -174,17 +176,26 @@ async function persistResult( .set({ result, info, statisticInfo }) .where(eq(schema.submission.id, submissionId)) + /** + * 重判只做差量:提交数不动,旧结果那一格减一、新结果那一格加一,通过数按新旧结果 + * 调整。原来重判一律当新提交记账,老师上课对一条连点三次重判,这道题的提交数和 + * 通过数就各多三(旧栈的 update_problem_status_rejudge 也是只做差量) + */ const problemStatistics = asRecord(problem.statisticInfo) - const resultKey = String(result) - const previousResultCount = problemStatistics[resultKey] - problemStatistics[resultKey] = - (typeof previousResultCount === "number" ? previousResultCount : 0) + 1 + const bump = (key: string, delta: number) => { + const value = problemStatistics[key] + problemStatistics[key] = Math.max(0, (typeof value === "number" ? value : 0) + delta) + } + bump(String(result), 1) + if (counted !== null) bump(String(counted), -1) + const acceptedDelta = + (isAccepted(result) ? 1 : 0) - (counted !== null && isAccepted(counted) ? 1 : 0) await tx .update(schema.problem) .set({ - submissionNumber: problem.submissionNumber + 1, - acceptedNumber: problem.acceptedNumber + (isAccepted(result) ? 1 : 0), + submissionNumber: problem.submissionNumber + (counted === null ? 1 : 0), + acceptedNumber: Math.max(0, problem.acceptedNumber + acceptedDelta), statisticInfo: problemStatistics, }) .where(eq(schema.problem.id, problemId)) @@ -214,7 +225,10 @@ async function persistResult( await tx .update(schema.userProfile) .set({ - submissionNumber: profile.submissionNumber + (contestId === null ? 1 : 0), + // 重判不是又交了一次。个人的通过数只在「第一次做对」时加,重判改错了也不收回 + // (他可能别的提交也对了),和 acm_problems_status 同一个口径 + submissionNumber: + profile.submissionNumber + (contestId === null && counted === null ? 1 : 0), acceptedNumber: profile.acceptedNumber + (contestId === null && acceptedNow && !wasAccepted ? 1 : 0), acmProblemsStatus: acmStatus, @@ -452,6 +466,24 @@ async function checkSamples( } } +/** + * 重判之前的结果**有没有记过账**。判完的结果都走过 persistResult、记过一次; + * 还在判的(卡在队列里被重判)和系统错误(markSystemError 只改结果、不动计数)没有, + * 重判时要当新提交记一次。判题机自己回的系统错误其实记过账,这里分不出来,按没记过算 —— + * 多记一次好过把别人的计数减掉 + */ +function countedBefore(rejudgedFrom: number | undefined): JudgeStatusValue | null { + if (rejudgedFrom === undefined) return null + if ( + rejudgedFrom === JudgeStatus.PENDING || + rejudgedFrom === JudgeStatus.JUDGING || + rejudgedFrom === JudgeStatus.SYSTEM_ERROR + ) { + return null + } + return rejudgedFrom as JudgeStatusValue +} + export async function judgeSubmission(job: JudgeJobData) { const [row] = await db .select({ @@ -583,6 +615,7 @@ export async function judgeSubmission(job: JudgeJobData) { statisticInfo, row.submission.contestId, row.submission.createTime, + countedBefore(job.rejudgedFrom), ) if (!saved) return diff --git a/apps/api/src/routes/classroom.ts b/apps/api/src/routes/classroom.ts index 27239b5..3f59ae2 100644 --- a/apps/api/src/routes/classroom.ts +++ b/apps/api/src/routes/classroom.ts @@ -452,7 +452,7 @@ const ACTIVE_CLASS_WINDOW_MS = 2 * 60 * 60 * 1000 /** * 从实际提交猜正在上课的班,而**不是记住老师上次选的**:统计面板吃过那个亏(上一节课 - * 的班悄悄留在框里,老师看的整个是别人的班,见 StatisticsPanel.vue 的注释)。 + * 的班悄悄留在框里,老师看的整个是别人的班)。 */ async function suggestActiveClass() { // 流程图也算:一节课全班都在画流程图时,只数代码提交就猜成了别的班 @@ -668,9 +668,8 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => { }) /** - * 老师给这个班布置今天的题。按展示题号给、不分大小写,存 problem.id 并保留输入顺序; - * 有对不上的题号就整个拒掉并点名是哪几个,免得存下半张单子老师还不知道。空数组 = 清掉, - * 学生那边退回推断。 + * 提交列表「这节课」按钮:这个班这节课是哪几道题,取法同课堂看板(老师布置的优先, + * 否则按今天同班提交推断)。不传班级就猜最近两小时在交题的班 */ classroomRoutes.get("/classroom/lesson", requireTeacher, async (c) => { const className = c.req.query("className")?.trim() || (await suggestActiveClass()) @@ -688,6 +687,11 @@ classroomRoutes.get("/classroom/lesson", requireTeacher, async (c) => { } satisfies ClassLesson) }) +/** + * 老师给这个班布置今天的题。按展示题号给、不分大小写,存 problem.id 并保留输入顺序; + * 有对不上的题号就整个拒掉并点名是哪几个,免得存下半张单子老师还不知道。空数组 = 清掉, + * 学生那边退回推断。 + */ classroomRoutes.put("/classroom/lesson", requireTeacher, async (c) => { const parsed = await parseBody(c, classLessonRequestSchema, "题号格式不对") if (!parsed.success) return parsed.response diff --git a/apps/api/src/routes/flowchart.ts b/apps/api/src/routes/flowchart.ts index 6d2a18f..b8ae5b5 100644 --- a/apps/api/src/routes/flowchart.ts +++ b/apps/api/src/routes/flowchart.ts @@ -31,6 +31,7 @@ import { asRecord, parseDisplayIds, queryInteger, + scopedUsers, rounded, stripClassPrefix, } from "./helpers" @@ -215,8 +216,20 @@ async function flowchartClassFilter(className: string) { : sql`false` } -async function flowchartUserFilter(username: string) { - const ids = (await matchedUsers(username)).map((row) => row.id) +/** + * 用户名筛选。默认是「包含」;`exact` 时整名匹配(提交列表的「只看他」、协作条带进来的), + * 学号互相包含是常态,ks24a1 会混进 ks24a10–ks24a19 + */ +async function flowchartUserFilter(username: string, exact = false) { + const ids = exact + ? ( + await db + .select({ id: schema.user.id }) + .from(schema.user) + .where(eq(schema.user.username, username)) + .limit(1) + ).map((row) => row.id) + : (await matchedUsers(username)).map((row) => row.id) return ids.length ? inArray(schema.flowchartSubmission.userId, ids) : sql`false` } @@ -275,7 +288,9 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => { filters.push( ...(await Promise.all([ displayId ? flowchartProblemFilter(displayId) : undefined, - !onlyMyself && username ? flowchartUserFilter(username) : undefined, + !onlyMyself && username + ? flowchartUserFilter(username, c.req.query("exactUsername") === "1") + : undefined, !onlyMyself && className ? flowchartClassFilter(className) : undefined, ])), ) @@ -335,8 +350,8 @@ const FLOWCHART_COMPLETED = 2 * 看到的完成率和均分就是错的,而且从界面上看不出来。 * * 会随数据量线性变重的只剩词云:每条 feedback / suggestions / comment 都要走一遍 - * jieba,而前端的「全部时段」是不带 start 的(FlowchartStatisticsPanel.vue 那个 - * `duration === "all"`),攒一学年就得把所有评语重新 cut 一遍。词云是辅助性的, + * jieba,而前端的「全部时段」是不带 start 的(统计页 oj/statistics/ 的 + * 「全部」时段),攒一学年就得把所有评语重新 cut 一遍。词云是辅助性的, * 看的是高频问题,取最近这些条足够。 * * 这里**同时**卡了两道:SQL 侧 `order by create_time desc limit N` 只取最近 N 条提交, @@ -347,6 +362,9 @@ const FLOWCHART_COMPLETED = 2 */ const WORDCLOUD_TEXT_LIMIT = 3000 +/** 「每人最好的一次」最多给多少人。快照里有流程图的学生一共 203 人 */ +const PEOPLE_LIMIT = 500 + flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => { const end = c.req.query("end")?.trim() if (!end) return failure(c, 400, "invalid-request", "end is required") @@ -361,6 +379,8 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => { // 可以一次查几道(统计页的题号框和代码统计同一个),有一道找不到就整体报错, // 不退化成「全部题目」—— 否则打错一个字看到的是全站数据 const displayIds = parseDisplayIds(c.req.query("problemDisplayId") ?? "") + // 和代码统计同一个上限(submission-statistics.ts 的 STATISTICS_MAX_PROBLEMS) + if (displayIds.length > 20) return failure(c, 400, "invalid-request", "At most 20 problems") if (displayIds.length) { const lowered = displayIds.map((id) => id.toLowerCase()) const problems = await db @@ -384,11 +404,15 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => { ) } - const username = c.req.query("username")?.trim() - // 用户名先解析成账号,再拿 user_id 去筛 —— 理由同代码提交的统计接口 - // (submission.ts 的 GET /submissions/statistics),顺带让下面这几条一个 join 都不用挂 - const matched = username ? await matchedUsers(username) : [] - if (username) { + // 用户名 / 班级先解析成账号,再拿 user_id 去筛 —— 理由同代码提交的统计接口 + // (submission-statistics.ts 的 GET /submissions/statistics),顺带让下面这几条一个 join 都不用挂。 + // 班级按 class_name 精确匹配(scopedUsers 的注释说了为什么不能拿 ks231 模糊匹配) + const scoped = await scopedUsers( + c.req.query("username")?.trim() || undefined, + c.req.query("className")?.trim() || undefined, + ) + const matched = scoped ?? [] + if (scoped) { const ids = matched.map((row) => row.id) // 一个账号都没匹配上时得留个恒假条件,否则「查无此班」变成「全站统计」 filters.push(ids.length ? inArray(schema.flowchartSubmission.userId, ids) : sql`false`) @@ -396,7 +420,7 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => { const where = and(...filters) // 花名册:只有指定了用户名才谈得上「班级人数」,不指定时分母无意义。 // 未禁用的普通用户才进分母,教师和管理员不算 - const roster = username + const roster = scoped ? matched.filter((row) => !row.isDisabled && row.adminType === "Regular User") : [] @@ -492,6 +516,9 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => { bestGrade: sql`(array_agg(${schema.flowchartSubmission.aiGrade} order by ${schema.flowchartSubmission.aiScore} desc nulls last, ${schema.flowchartSubmission.createTime} desc))[1]`, count: count(), + // 拿到 A / S 的题数。查几道题时要每道都拿到才算完成,和代码统计「几道都做完」同口径 + goodProblems: sql`count(distinct ${schema.flowchartSubmission.problemId}) + filter (where ${schema.flowchartSubmission.aiGrade} in ('S', 'A'))`.mapWith(Number), }) .from(schema.flowchartSubmission) .innerJoin(schema.user, eq(schema.user.id, schema.flowchartSubmission.userId)) @@ -503,12 +530,14 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => { .limit(500), ]) - const people = peopleRows.map((row) => ({ + const peopleTruncated = peopleRows.length > PEOPLE_LIMIT + const people = peopleRows.slice(0, PEOPLE_LIMIT).map((row) => ({ username: row.username, realName: stripClassPrefix(row.username, row.className), bestScore: row.bestScore === null ? null : Number(row.bestScore), bestGrade: row.bestGrade, count: row.count, + goodProblems: row.goodProblems, })) if (!totals || totals.total === 0) { @@ -527,6 +556,8 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => { realName: stripClassPrefix(row.username, row.className), })), people: [], + peopleTruncated: false, + problemCount: displayIds.length, } satisfies FlowchartStatistics) } @@ -591,6 +622,8 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => { realName: stripClassPrefix(row.username, row.className), })), people, + peopleTruncated, + problemCount: displayIds.length, } satisfies FlowchartStatistics) }) diff --git a/apps/api/src/routes/helpers.ts b/apps/api/src/routes/helpers.ts index c845d2f..0e48cd0 100644 --- a/apps/api/src/routes/helpers.ts +++ b/apps/api/src/routes/helpers.ts @@ -159,6 +159,32 @@ export async function countFailedSubmissions(userId: number, problemId: number) * 代码提交和流程图两条统计都走这里。流程图那张表连冻结用户名都没有(只有 * `user_id`),更是只能从这儿拿 id。 */ +/** + * 统计接口「圈哪些人」:班级按 `user.class_name` **精确**匹配,学生名按包含匹配,两个都给 + * 就取交集,都没给返回 null(不圈人)。 + * + * 原来统计只收一个 username、前端拼成 `ks231` 去做包含匹配 —— 231 班会连带 2311、2312 班 + * (快照里 37 人变成 138 人),而提交列表的班级筛选是精确的,两边的人对不上 + */ +export async function scopedUsers(username: string | undefined, className: string | undefined) { + if (!username && !className) return null + return db + .select({ + id: schema.user.id, + username: schema.user.username, + className: schema.user.className, + isDisabled: schema.user.isDisabled, + adminType: schema.user.adminType, + }) + .from(schema.user) + .where( + and( + className ? eq(schema.user.className, className) : undefined, + username ? ilike(schema.user.username, `%${username}%`) : undefined, + ), + ) +} + export async function matchedUsers(username: string) { return db .select({ diff --git a/apps/api/src/routes/submission-statistics.ts b/apps/api/src/routes/submission-statistics.ts index 5aebe7e..f878db1 100644 --- a/apps/api/src/routes/submission-statistics.ts +++ b/apps/api/src/routes/submission-statistics.ts @@ -9,7 +9,6 @@ import { type SubmissionStatistics, type SubmissionStatisticsGrid, - type SubmissionStatisticsItems, type TodaySubmissionStatistics, } from "@oj2/contract" import { and, count, desc, eq, ilike, inArray, isNull, or, sql, type SQL } from "drizzle-orm" @@ -18,11 +17,11 @@ import { Hono } from "hono" import { optionalAuth, requireTeacher } from "../auth/middleware" import { db, schema } from "../db" import { failure, success } from "../http" -import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from "../judge/status" +import { JudgeStatus, UNJUDGED_RESULTS } from "../judge/status" import { type ContestEnv } from "../services/contest" import { getBooleanOption } from "../services/options" import { localTime, dayStart } from "../time" -import { isAdminRole, matchedUsers, parseDisplayIds, rounded, stripClassPrefix } from "./helpers" +import { isAdminRole, parseDisplayIds, rounded, scopedUsers, stripClassPrefix } from "./helpers" export const submissionStatisticsRoutes = new Hono() @@ -160,107 +159,6 @@ async function findPublicProblemsByDisplayIds(displayIds: string[]) { return { ids: rows.map((row) => row.id), missing: missing ?? null } } -/** - * 展开行一次只看一个人(表格的 updateExpandedRowKeys 只留最后一个 key),所以明细 - * **按需拉**,不再随统计一起下发。 - * - * 原来是随 data 一起给所有人各带一份:生产快照实测,「全部时段 + 不填条件」要搬 - * 49108 行(最早那版不截断是 105631 行),而其中真正被人看到的最多一个人的那几十条。 - */ -const STATISTICS_ITEMS_LIMIT = 200 - -/** 错误摘要截断长度。编译错误能刷几十行,弹层里放不下,也没必要 */ -const FAILURE_MESSAGE_LIMIT = 400 - -/** - * 「交了没对」那一栏点开要看的:这个人**最近一条**提交错在哪。 - * - * 有了它,老师看到「张三 12次」之后不用再切到提交列表、翻到这个人、点开代码 —— - * 点一下名字就知道是编译错了还是答案错了、报的什么。err_info 是判题机塞进 - * statistic_info 的那一段,提交详情页读的也是它。 - */ -async function lastFailureByUser(where: SQL | undefined, userIds: number[]) { - // result 手写成 JudgeStatusValue:这条裸 SQL 读的就是 submission.result 那一列, - // 口径要和列上的 $type 一致 - const byUser = new Map< - number, - { - id: string - problemDisplayId: string - result: JudgeStatusValue - error: string | null - } - >() - if (!userIds.length) return byUser - - // 不给 submission 起别名:where 里的条件是 drizzle 拼的,引用的是 "submission"."x" - const rows = await db.execute<{ - user_id: number - id: string - problem: string - result: JudgeStatusValue - error: string | null - }>(sql` - select user_id, id, problem, result, error from ( - select - ${schema.submission.userId} as user_id, - ${schema.submission.id} as id, - ${schema.problem.displayId} as problem, - ${schema.submission.result} as result, - left(${schema.submission.statisticInfo}->>'err_info', ${FAILURE_MESSAGE_LIMIT}) as error, - row_number() over ( - partition by ${schema.submission.userId} - order by ${schema.submission.createTime} desc - ) as rn - from ${schema.submission} - join ${schema.problem} on ${schema.problem.id} = ${schema.submission.problemId} - where ${and(where, inArray(schema.submission.userId, userIds))} - ) t - where rn = 1 - `) - - for (const row of rows) { - byUser.set(row.user_id, { - id: row.id, - problemDisplayId: row.problem, - result: row.result, - error: row.error, - }) - } - return byUser -} - -/** - * 「答案对了,但没按要求的语法写」的题数(AST_CHECK_FAILED)。 - * - * 只算**最后也没改对**的:同一道题上既有 AST_CHECK_FAILED 又有 ACCEPTED,说明学生后来 - * 改成要求的写法了,不该再拿这个提醒老师。所以要先按「人 × 题」聚一层,不能直接 - * `count(distinct problem_id) filter (result = 10)`。 - * - * 口径本身不动 —— AST_CHECK_FAILED 仍然算通过(答案确实对了,全站一致)。这里只是 - * 让教师看得见「这几个人是绕过要求做出来的」,教学上那不算达标。 - */ -async function astOnlyByUser(where: SQL | undefined, userIds: number[]) { - const byUser = new Map() - if (!userIds.length) return byUser - - const rows = await db.execute<{ user_id: number; n: number }>(sql` - select user_id, count(*)::int as n from ( - select - ${schema.submission.userId} as user_id, - bool_or(${schema.submission.result} = ${JudgeStatus.AST_CHECK_FAILED}) as has_ast, - bool_or(${schema.submission.result} = ${JudgeStatus.ACCEPTED}) as has_ac - from ${schema.submission} - where ${and(where, inArray(schema.submission.userId, userIds))} - group by ${schema.submission.userId}, ${schema.submission.problemId} - ) t - where has_ast and not has_ac - group by user_id - `) - for (const row of rows) byUser.set(row.user_id, row.n) - return byUser -} - /** * 两条提交列表的用户名筛选。**两边都要匹配**: * @@ -419,13 +317,15 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async if (!scope.ok) return failure(c, scope.status, scope.code, scope.message) const filters = scope.filters - const username = c.req.query("username")?.trim() - // 用户名先解析成账号,再拿 user_id 去筛提交。这一趟查询挡在 Promise.all 前面, + const username = c.req.query("username")?.trim() || undefined + const className = c.req.query("className")?.trim() || undefined + // 用户名 / 班级先解析成账号,再拿 user_id 去筛提交。这一趟查询挡在 Promise.all 前面, // 但换掉的是下面**四条**语句各一次的 submission 全表扫:`ilike` 走不了索引, // 换成 `user_id in (...)` 之后四条全走索引(生产快照实测单条 18448 → 537 // buffers;同一个快照上整个接口查一个班 120~250ms → 10ms 上下),多这一次往返是赚的。 - const matched = username ? await matchedUsers(username) : [] - if (username) { + const scoped = await scopedUsers(username, className) + const matched = scoped ?? [] + if (scoped) { const matchedIds = matched.map((row) => row.id) // 一个账号都没匹配上时得留个恒假条件。少推一个 filter 的话过滤条件整个消失, // 「查无此班」会变成「全站统计」 @@ -512,16 +412,11 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async * 「提交记录」那张表列的是**窗口里交过东西的所有人**,`done` 标出谁做完了 —— * 原来只给做完的人,于是一次没对的学生连同他的提交在这张表里根本不存在, * 教师想看「他到底错在哪」得切到提交列表再翻。展开一行拉的是那个人的全部 - * 提交(GET /submissions/statistics/items 不按结果过滤),对错都在里面。 + * 提交,对错都在里面。 * * 「完成人数」这些数字跟着 `done` 算,不是 `data.length`。 */ const doneCount = perUser.filter(isDone).length - // 要等 perUser 回来才能查,所以进不了上面那个 Promise.all - const astOnlyByUserMap = await astOnlyByUser( - where, - perUser.map((row) => row.userId), - ) const submittedUserIds = new Set(perUser.map((row) => row.userId)) @@ -531,7 +426,6 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async submissionCount: row.submissionCount, acceptedCount: row.acceptedCount, solvedCount: row.solvedCount, - astOnlyCount: astOnlyByUserMap.get(row.userId) ?? 0, judgingCount: row.judgingCount, correctRate: judgedRate(row.acceptedCount, row.submissionCount - row.judgingCount), done: isDone(row), @@ -560,14 +454,8 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async const rosterIds = new Set(rosterRows.map((row) => row.id)) const attemptedRows = perUser.filter((row) => { if (isDone(row)) return false - return username - ? rosterIds.has(row.userId) - : !row.isDisabled && row.adminType === "Regular User" + return scoped ? rosterIds.has(row.userId) : !row.isDisabled && row.adminType === "Regular User" }) - const failureByUser = await lastFailureByUser( - where, - attemptedRows.map((row) => row.userId), - ) const dataAttempted = attemptedRows.map((row) => ({ username: row.username, /** @@ -575,10 +463,9 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async * 不传用户名的全站视图里各班混在一起,剥完只剩一串重名的名字,反而认不出谁, * 所以原样给完整用户名。班名取 perUser join 出来的那一列,和花名册同一份数据。 */ - realName: username ? stripClassPrefix(row.username, row.className) : row.username, + realName: scoped ? stripClassPrefix(row.username, row.className) : row.username, submissionCount: row.submissionCount, solvedCount: row.solvedCount, - lastFailure: failureByUser.get(row.userId) ?? null, })) // 「学生已删号但提交记录还在」时完成人数会大于花名册人数,分母兜到完成人数为止。 @@ -599,61 +486,6 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async } satisfies SubmissionStatistics) }) -/** - * 统计面板展开一行时拉这个人的提交明细。 - * - * 用户名这里是**精确匹配**,不是统计接口那种 ilike —— 那边填 `ks251` 要圈出整个班, - * 这边是「点开的这一行是谁」。时间窗和题号沿用同一个 scope,不然展开行看到的 - * 会是另一个范围的数据。 - */ -submissionStatisticsRoutes.get("/submissions/statistics/items", requireTeacher, async (c) => { - const username = c.req.query("username")?.trim() - if (!username) return failure(c, 400, "invalid-request", "username is required") - - const scope = await statisticsScope(c) - if (!scope.ok) return failure(c, scope.status, scope.code, scope.message) - - /** - * 展开的那一行给的是**当前**用户名,先换成 user_id 再查 —— 直接按 - * `submission.username` 精确匹配的话,改过名的学生展开来是空的(他的提交 - * 全挂在旧名字下)。 - * - * 查不到账号才退回按提交里冻结的用户名匹配:已删号的学生仍然会出现在统计 - * 表格里(那一行的名字取自提交),展开行不能因此空着。 - */ - const [account] = await db - .select({ id: schema.user.id }) - .from(schema.user) - .where(eq(schema.user.username, username)) - .limit(1) - const identity = account - ? eq(schema.submission.userId, account.id) - : eq(schema.submission.username, username) - - // 多取一条,好知道是不是被截断了 - // innerJoin 不会漏行:submission.problem_id 是 NOT NULL 且外键是 NO ACTION, - // 题目删不掉(真要删会被外键拦住并提示改为隐藏) - const rows = await db - .select({ - id: schema.submission.id, - result: schema.submission.result, - createTime: schema.submission.createTime, - problemDisplayId: schema.problem.displayId, - problemTitle: schema.problem.title, - }) - .from(schema.submission) - .innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId)) - .where(and(...scope.filters, identity)) - .orderBy(desc(schema.submission.createTime), desc(schema.submission.id)) - .limit(STATISTICS_ITEMS_LIMIT + 1) - - const truncated = rows.length > STATISTICS_ITEMS_LIMIT - return success(c, { - items: rows.slice(0, STATISTICS_ITEMS_LIMIT), - truncated, - } satisfies SubmissionStatisticsItems) -}) - /** 方块串一次最多给多少条。一个班一个月七八百条,5000 是全年级查一周的量 */ const GRID_LIMIT = 5000 @@ -666,9 +498,10 @@ submissionStatisticsRoutes.get("/submissions/statistics/grid", requireTeacher, a if (!scope.ok) return failure(c, scope.status, scope.code, scope.message) const filters = [...scope.filters] - const username = c.req.query("username")?.trim() - if (username) { - const ids = (await matchedUsers(username)).map((row) => row.id) + const username = c.req.query("username")?.trim() || undefined + const scoped = await scopedUsers(username, c.req.query("className")?.trim() || undefined) + if (scoped) { + const ids = scoped.map((row) => row.id) filters.push(ids.length ? inArray(schema.submission.userId, ids) : sql`false`) } @@ -759,7 +592,7 @@ submissionStatisticsRoutes.get("/submissions/statistics/grid", requireTeacher, a // 按班级汇总时要知道每个班多少人,「一道没交」才算得出来 const classSizes: Record = {} - if (!username) { + if (!scoped) { const names = [...new Set(kept.map((row) => row.className).filter((name) => name !== null))] if (names.length) { const sizes = await db diff --git a/apps/api/src/routes/submission.ts b/apps/api/src/routes/submission.ts index 335417b..20879cc 100644 --- a/apps/api/src/routes/submission.ts +++ b/apps/api/src/routes/submission.ts @@ -216,22 +216,25 @@ submissionRoutes.post("/submissions/:id/rejudge", requireTeacher, async (c) => { .select({ id: schema.submission.id, problemId: schema.submission.problemId, + result: schema.submission.result, }) .from(schema.submission) .where(and(eq(schema.submission.id, c.req.param("id")), isNull(schema.submission.contestId))) .limit(1) if (!row) return failure(c, 404, "submission-not-found", "Submission does not exist") + // info 一起清:不清的话判题中那几秒,列表和详情按旧的逐点结果算出「通过 2/3」 await db .update(schema.submission) - .set({ statisticInfo: {}, result: JudgeStatus.PENDING }) + .set({ statisticInfo: {}, info: {}, result: JudgeStatus.PENDING }) .where(eq(schema.submission.id, row.id)) // jobId 必须带时间戳。队列保留最近 100 个已完成任务,沿用 submissionId 做 jobId 的话 // BullMQ 会认为这个任务已经存在,重判静默变成空操作。与 flowcharts/:id/retry 同一处理。 await judgeQueue.add( "judge", - { submissionId: row.id, problemId: row.problemId }, + // 带上重判之前的结果,落库时只做差量(见 judge/run.ts 的 persistResult) + { submissionId: row.id, problemId: row.problemId, rejudgedFrom: row.result }, { jobId: `${row.id}:rejudge:${Date.now()}` }, ) return success(c, null) diff --git a/apps/api/src/scripts/recount.ts b/apps/api/src/scripts/recount.ts index 98035ac..68fa53c 100644 --- a/apps/api/src/scripts/recount.ts +++ b/apps/api/src/scripts/recount.ts @@ -9,9 +9,10 @@ import { metaAchievements, refreshUnlockedCount, rescanAchievement } from "../se * 把反范式的计数列重算回与 submission 表一致。 * * 这几个列不是缓存、是真值的副本:判题落库时由 `judge/run.ts` 的 persistResult 手工 - * 加减,谁都没在事后核对过。已知的漂移来源是**重判**——`routes/submission.ts` 的 - * rejudge 把 result 打回 PENDING 就重新入队,**不回退任何计数**,于是 persistResult - * 再加一次:重判一条题目的 submission_number 就永久多一。删提交、直接改库同理。 + * 加减,谁都没在事后核对过。已知的漂移来源是**重判**—— 2026-09-29 之前 rejudge 把 + * result 打回 PENDING 就重新入队、不回退任何计数,persistResult 再加一次:重判一条题目 + * 的 submission_number 就永久多一。现在 rejudge 带上旧结果、persistResult 只做差量, + * 这条不再新增漂移,存量靠这里订正。删提交、直接改库仍然会漂。 * * 管的六个列: * problem.submission_number / accepted_number / statistic_info diff --git a/packages/contract/src/flowchart.ts b/packages/contract/src/flowchart.ts index 5b2dd46..c2f768b 100644 --- a/packages/contract/src/flowchart.ts +++ b/packages/contract/src/flowchart.ts @@ -117,8 +117,14 @@ export const flowchartStatisticsSchema = z.object({ bestScore: z.number().nullable(), bestGrade: z.string().nullable(), count: z.number().int(), + /** 拿到 A / S 的题数(查几道题时要每道都拿到才算完成) */ + goodProblems: z.number().int(), }), ), + /** people 超过上限被截了(截掉的是成绩最好的那些) */ + peopleTruncated: z.boolean(), + /** 查了几道题(没填题号为 0,此时拿到一道 A / S 就算完成) */ + problemCount: z.number().int(), }) export const flowchartUpdateSchema = z.object({ diff --git a/packages/contract/src/submission.ts b/packages/contract/src/submission.ts index ef5bb30..30dcbfe 100644 --- a/packages/contract/src/submission.ts +++ b/packages/contract/src/submission.ts @@ -314,20 +314,6 @@ export const attemptedStudentSchema = unacceptedStudentSchema.extend({ * 差几道决定了老师先管谁 —— 所以名字后面要缀 `2/3`。 */ solvedCount: z.number().int(), - /** - * 最近一条提交错在哪。教师点名字就能看到「是编译错了还是答案错了」, - * 不必再切去提交列表翻这个人。`error` 是判题机写进 statistic_info 的 err_info, - * 已截断;没有错误文本(比如答案错误那种)时为 null。 - */ - lastFailure: z - .object({ - id: z.string(), - /** 题目的展示编号,用来告诉老师错在哪道题 */ - problemDisplayId: z.string(), - result: judgeStatusSchema, - error: z.string().nullable(), - }) - .nullable(), }) export const submissionStatisticsUserSchema = z.object({ @@ -341,11 +327,6 @@ export const submissionStatisticsUserSchema = z.object({ * 不指定题号查「这节课全班」时,条数和题数能差出好几倍。 */ solvedCount: z.number().int(), - /** - * 「答案对了但语法没按要求写」且**最后也没改对**的题数。这些题算在 solvedCount 里 - * (AST_CHECK_FAILED 全站都算通过),单列出来只是让教师看得见教学上没达标的那几个。 - */ - astOnlyCount: z.number().int(), /** 这个人还在判题队列里的条数。`submissionCount` 含它,`correctRate` 的分母不含 */ judgingCount: z.number().int(), // 百分比数值,不带 %。旧后端返回 "85.5%" 字符串,展示格式化交给前端。 @@ -359,41 +340,6 @@ export const submissionStatisticsUserSchema = z.object({ done: z.boolean(), }) -/** - * 展开行的明细,**按需拉**(GET /submissions/statistics/items)。 - * - * 原来是随统计一起给每个人各带一份,可表格一次只展开一行 —— 生产快照上那是 - * 4.9 万行没人看的数据。`truncated` 为真时前端要说明「只显示最近 N 条」, - * 免得老师以为这人就交了这么多。 - */ -export const submissionStatisticsItemsSchema = z.object({ - /** - * 展开某个学生时列出他这段时间的提交。**带上题目**:一节课里学生往往在好几道题 - * 之间来回跳,一串只有编号的按钮看不出他卡在哪一道 —— 前端按题目分组展示。 - * - * 字段名沿用 submissionListItemSchema 的口径:`problemDisplayId` 是展示用题号(problem._id), - * `problemTitle` 是标题。 - */ - items: z.array( - z.object({ - id: z.string(), - result: judgeStatusSchema, - createTime: z.string(), - problemDisplayId: z.string(), - problemTitle: z.string(), - }), - ), - truncated: z.boolean(), -}) - -/** - * 统计页的方块串(GET /submissions/statistics/grid):范围内每个学生的每一次提交, - * 前端按「人 × 题」排成方块串,并据此算每道题做完几人。计数口径(正确率、做完、没交) - * 仍以 submissionStatisticsSchema 为准,这里只给「每一次交了什么结果」。 - * - * 只含普通学生(老师试题留下的提交不混进来),最多 5000 条最近的,超了 `truncated`。 - * `classSizes` 只在没传用户名(从题目页进来、按班级汇总)时给:各班花名册人数。 - */ export const submissionStatisticsGridSchema = z.object({ /** 传了题号就按传的顺序;没传就是范围内出现过的题,按第一次有人交的时间排 */ problems: z.array(z.object({ problemDisplayId: z.string(), title: z.string() })), @@ -559,7 +505,6 @@ export type SubmissionUpdate = z.infer export type SubmissionStatistics = z.infer export type TodaySubmissionStatistics = z.infer export type SubmissionStatisticsUser = z.infer -export type SubmissionStatisticsItems = z.infer export type SubmissionStatisticsGrid = z.infer export type UnacceptedStudent = z.infer export type AttemptedStudent = z.infer