feat(阶段3): 补齐用户侧依赖的三个 admin 端点
重判、提交统计、流程图统计这三条挂在旧后端的 admin 路由下,权限也确实是 teacher/super admin,但入口在用户侧页面里(提交列表页的重判按钮、两个统计面板)。 不做完,阶段 3 的出口标准「用户侧全部功能跑在新后端上」就不成立 —— 按 URL 前缀切阶段会漏掉它们。 - POST submissions/:id/rejudge ← GET admin/submission/rejudge - GET submissions/statistics ← GET admin/submission/statistics - GET flowcharts/statistics ← GET admin/flowchart/statistics 几处对齐旧后端的细节: - AST_CHECK_FAILED(10) 与 ACCEPTED(0) 同算通过 - 完成度先用原始花名册人数算、再修正 person_count,顺序照搬,兜住「学生已删号 但提交记录还在」 - 有提交但零通过的学生,两个名单里都不出现(旧后端同样口径) - 词云用 @node-rs/jieba,STOPWORDS 与 38 个自定义词逐词照搬;jieba@2 没有 insertWord,改用 loadDict 加载用户词典 - avgScore 分母是有分数的条数,对齐 Django Avg() 跳过 NULL rejudge 的 jobId 带时间戳。队列保留最近 100 个已完成任务,沿用 submissionId 做 jobId 的话 BullMQ 会认为任务已存在,重判会静默变成空操作。 correctRate 改成数值不带 %,展示格式化交给前端。stripClassPrefix 用 startsWith+slice 而不是 replace,前缀对不上时不会从中间截出乱码;site.ts 原有的 replace 写法一并改掉。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,9 +7,10 @@ import {
|
||||
flowchartDetailSchema,
|
||||
flowchartListItemSchema,
|
||||
flowchartListSchema,
|
||||
flowchartStatisticsSchema,
|
||||
flowchartSubmissionSchema,
|
||||
} from "@oj2/contract"
|
||||
import { and, asc, count, desc, eq, ilike, sql } from "drizzle-orm"
|
||||
import { and, asc, count, desc, eq, ilike, isNull, sql } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { requireAuth, type AppEnv } from "../auth/middleware"
|
||||
@@ -17,7 +18,16 @@ import { config } from "../config"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, success } from "../http"
|
||||
import { flowchartQueue } from "../queue"
|
||||
import { isAdminRole, objectValue, queryInteger, todayStart } from "./helpers"
|
||||
import { buildWordFrequencies } from "../services/word-frequency"
|
||||
import {
|
||||
isAdminRole,
|
||||
isTeacherOrAbove,
|
||||
objectValue,
|
||||
queryInteger,
|
||||
rounded,
|
||||
stripClassPrefix,
|
||||
todayStart,
|
||||
} from "./helpers"
|
||||
|
||||
export const flowchartRoutes = new Hono<AppEnv>()
|
||||
|
||||
@@ -127,6 +137,137 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
|
||||
}))
|
||||
})
|
||||
|
||||
const FLOWCHART_COMPLETED = 2
|
||||
|
||||
flowchartRoutes.get("/flowcharts/statistics", requireAuth, async (c) => {
|
||||
if (!isTeacherOrAbove(c.get("user"))) {
|
||||
return failure(c, 403, "permission-denied", "Teacher permission required")
|
||||
}
|
||||
const end = c.req.query("end")?.trim()
|
||||
if (!end) return failure(c, 400, "invalid-request", "end is required")
|
||||
const start = c.req.query("start")?.trim()
|
||||
|
||||
const filters = [
|
||||
eq(schema.flowchartSubmission.status, FLOWCHART_COMPLETED),
|
||||
sql`${schema.flowchartSubmission.createTime} <= ${end}`,
|
||||
]
|
||||
if (start) filters.push(sql`${schema.flowchartSubmission.createTime} >= ${start}`)
|
||||
|
||||
const displayId = c.req.query("problemId")?.trim()
|
||||
if (displayId) {
|
||||
const [problem] = await db
|
||||
.select({ id: schema.problem.id })
|
||||
.from(schema.problem)
|
||||
.where(and(
|
||||
sql`lower(${schema.problem.displayId}) = lower(${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")
|
||||
filters.push(eq(schema.flowchartSubmission.problemId, problem.id))
|
||||
}
|
||||
|
||||
const username = c.req.query("username")?.trim()
|
||||
if (username) filters.push(ilike(schema.user.username, `%${username}%`))
|
||||
|
||||
// 只有指定了用户名才谈得上「班级人数」,不指定时分母无意义
|
||||
const roster = username
|
||||
? await db
|
||||
.select({ username: schema.user.username, className: schema.user.className })
|
||||
.from(schema.user)
|
||||
.where(and(
|
||||
ilike(schema.user.username, `%${username}%`),
|
||||
eq(schema.user.isDisabled, false),
|
||||
eq(schema.user.adminType, "Regular User"),
|
||||
))
|
||||
: []
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
username: schema.user.username,
|
||||
score: schema.flowchartSubmission.aiScore,
|
||||
grade: schema.flowchartSubmission.aiGrade,
|
||||
criteria: schema.flowchartSubmission.aiCriteriaDetails,
|
||||
feedback: schema.flowchartSubmission.aiFeedback,
|
||||
suggestions: schema.flowchartSubmission.aiSuggestions,
|
||||
})
|
||||
.from(schema.flowchartSubmission)
|
||||
.innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id))
|
||||
.where(and(...filters))
|
||||
|
||||
const empty = {
|
||||
totalCount: 0,
|
||||
avgScore: 0,
|
||||
gradeDistribution: {},
|
||||
criteriaAverages: {},
|
||||
personCount: roster.length,
|
||||
completedCount: 0,
|
||||
wordFrequencies: [],
|
||||
dataUnaccepted: [],
|
||||
}
|
||||
if (rows.length === 0) return success(c, flowchartStatisticsSchema.parse(empty))
|
||||
|
||||
const gradeDistribution: Record<string, number> = {}
|
||||
const criteriaTotals = new Map<string, { sum: number; count: number; max: number }>()
|
||||
const texts: string[] = []
|
||||
const submitted = new Set<string>()
|
||||
let scoreSum = 0
|
||||
let scoreCount = 0
|
||||
|
||||
for (const row of rows) {
|
||||
submitted.add(row.username)
|
||||
// 旧后端用 values_list("ai_grade") 分组,null 也会成为一个桶;这里保持同样的口径
|
||||
const grade = row.grade ?? ""
|
||||
gradeDistribution[grade] = (gradeDistribution[grade] ?? 0) + 1
|
||||
if (row.score !== null) {
|
||||
scoreSum += row.score
|
||||
scoreCount += 1
|
||||
}
|
||||
for (const [key, value] of Object.entries(objectValue(row.criteria))) {
|
||||
const detail = objectValue(value)
|
||||
if (typeof detail.score !== "number") continue
|
||||
const bucket = criteriaTotals.get(key)
|
||||
if (bucket) {
|
||||
bucket.sum += detail.score
|
||||
bucket.count += 1
|
||||
} else {
|
||||
// max 取第一次见到的那条,与旧后端 `if key not in criteria_max` 一致
|
||||
criteriaTotals.set(key, {
|
||||
sum: detail.score,
|
||||
count: 1,
|
||||
max: typeof detail.max === "number" ? detail.max : 100,
|
||||
})
|
||||
}
|
||||
if (typeof detail.comment === "string" && detail.comment) texts.push(detail.comment)
|
||||
}
|
||||
if (row.feedback) texts.push(row.feedback)
|
||||
if (row.suggestions) texts.push(row.suggestions)
|
||||
}
|
||||
|
||||
const criteriaAverages: Record<string, { avg: number; max: number }> = {}
|
||||
for (const [key, bucket] of criteriaTotals) {
|
||||
criteriaAverages[key] = { avg: rounded(bucket.sum / bucket.count, 1), max: bucket.max }
|
||||
}
|
||||
|
||||
return success(c, flowchartStatisticsSchema.parse({
|
||||
totalCount: rows.length,
|
||||
// 分母是有分数的条数,不是总条数 —— 对齐 Django 的 Avg(),它跳过 NULL
|
||||
avgScore: scoreCount ? rounded(scoreSum / scoreCount, 1) : 0,
|
||||
gradeDistribution,
|
||||
criteriaAverages,
|
||||
personCount: roster.length,
|
||||
completedCount: submitted.size,
|
||||
wordFrequencies: buildWordFrequencies(texts),
|
||||
dataUnaccepted: roster
|
||||
.filter((row) => !submitted.has(row.username))
|
||||
.map((row) => ({
|
||||
username: row.username,
|
||||
realName: stripClassPrefix(row.username, row.className),
|
||||
})),
|
||||
}))
|
||||
})
|
||||
|
||||
flowchartRoutes.get("/flowcharts/:id", requireAuth, async (c) => {
|
||||
const [row] = await db.select({ flowchart: schema.flowchartSubmission, username: schema.user.username, problem: schema.problem })
|
||||
.from(schema.flowchartSubmission).innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id))
|
||||
|
||||
Reference in New Issue
Block a user