Files
OJ2/apps/api/src/routes/problem-stats.ts
T
xuyueandClaude Opus 5.5 af2ca31de5
Deploy / deploy (push) Canceled after 0s
fix(学生管理员): 260 班看板 23 人、数据统计只有 22 人,学生管理员还能看同学的作业代码——学生管理员一律按学生算
- 统计、题目页各班表、流程图统计的班级名单原来只认普通用户,改成和看板、排行榜一样的
  STUDENT_ROLES,学生管理员进名单、进分母
- 看别人的提交代码和流程图只给老师、超管和出题人;题单防抄对学生管理员也生效。
  原来「比赛进行中学生管理员不能看」那条特例随之去掉
- 题单的「用户进度」原来只给题单创建者,其余老师点开报「题单不存在」;改成所有老师都能看,
  和看板、统计对所有班开放一致。后台能改能删的接口仍只给创建者

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:00:11 +08:00

217 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { STUDENT_ROLES, type ProblemStats } from "@oj2/contract"
import { and, count, eq, inArray, 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 { UNJUDGED_RESULTS, type JudgeStatusValue } from "../judge/status"
import { canAccessContest, contestDetailsAllowed, findAccessibleContest } from "../services/contest"
import { accepted } from "../services/learning-stats"
import { isAdminRole, isTeacherOrAbove, queryInteger } from "./helpers"
export const problemStatsRoutes = new Hono<AppEnv>()
const EMPTY: ProblemStats = {
locked: false,
tried: 0,
solved: 0,
tries: { one: 0, few: 0, many: 0 },
failures: [],
me: null,
myClass: null,
classes: null,
}
// type 而不是 interface:db.execute 的泛型约束是 Record<string, unknown>,interface 不带索引签名
type UserRow = {
user_id: number
username: string | null
class_name: string | null
attempts: number
/** 做对之前(含做对那次)交了几次;没做对就是全部次数 */
tries: number
solved: boolean
}
/**
* 题目页「统计」页签:讲的是**这道题**(难不难、几次做对、常错在哪),老师多一张各班的表。
* 看谁没做对是提交页「数据统计」的活,这里只到班级这一层。
* id 是内部题号(problem.id):比赛题的展示题号是 1、2、3,拿 displayId 查会撞上
* 同号的公开题(原来的「历年 AC 率」就是这么查错的)。
*/
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 [users, failureRows] = await Promise.all([
db.execute<UserRow>(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
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
`),
])
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<JudgeStatusValue, number>) =>
[...map].map(([result, count]) => ({ result, count })).sort((a, b) => b.count - a.count)
const siteFailures = new Map<JudgeStatusValue, number>()
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
if (!inContest && isTeacherOrAbove(user)) classes = await classRows(problem.id)
return success(c, {
locked: false,
tried: users.length,
solved: solvedUsers.length,
tries,
failures: sortFailures(siteFailures),
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,
} satisfies ProblemStats)
})
/**
* 各班一行,和数据统计「按班级汇总」(statistics/components/ByClass.vue)同一个口径:
* 只算学生角色、没禁用的号,班级人数也这么数。那边是前端拿逐条提交现算的(最多 5000 条),
* 这里在库里直接聚合,数是一样的。
*/
async function classRows(problemId: number): Promise<NonNullable<ProblemStats["classes"]>> {
const acceptedList = sql.join(
accepted.map((value) => sql`${value}`),
sql`, `,
)
const unjudgedList = sql.join(
UNJUDGED_RESULTS.map((value) => sql`${value}`),
sql`, `,
)
const rows = await db.execute<{
class_name: string | null
tried: number
solved: number
accepted: number
judged: number
last_time: string
}>(sql`
select u.class_name,
count(distinct s.user_id)::int as tried,
count(distinct s.user_id) filter (where s.result in (${acceptedList}))::int as solved,
count(*) filter (where s.result in (${acceptedList}))::int as accepted,
count(*) filter (where s.result not in (${unjudgedList}))::int as judged,
max(s.create_time) as last_time
from ${schema.submission} s
join ${schema.user} u on u.id = s.user_id
where s.problem_id = ${problemId} and s.contest_id is null
and u.admin_type = 'Regular User' and u.is_disabled = false
group by u.class_name
`)
const names = rows.map((row) => row.class_name).filter((name) => name !== null)
const sizes = names.length
? await db
.select({ className: schema.user.className, value: count() })
.from(schema.user)
.where(
and(
inArray(schema.user.className, names),
inArray(schema.user.adminType, [...STUDENT_ROLES]),
eq(schema.user.isDisabled, false),
),
)
.groupBy(schema.user.className)
: []
return (
rows
.map((row) => {
const classSize =
(row.class_name && sizes.find((item) => item.className === row.class_name)?.value) ||
row.tried
return {
className: row.class_name,
classSize,
solved: row.solved,
unsolved: row.tried - row.solved,
untouched: Math.max(0, classSize - row.tried),
correctRate: row.judged ? Math.round((row.accepted / row.judged) * 100) : null,
lastTime: row.last_time,
}
})
// 交过的人多的在前:按最近一次排的话,零星一两个人补做的班会压在全班做过的班上面
.sort(
(a, b) =>
b.solved + b.unsolved - (a.solved + a.unsolved) || b.lastTime.localeCompare(a.lastTime),
)
)
}