Compare commits

...

2 Commits

Author SHA1 Message Date
2bf0e7bfc1 ops(备份): 加 backup-db.sh,按容器名导出、校验完整性、带保留策略
Some checks failed
Deploy / deploy (push) Has been cancelled
原来手敲的那条

    docker compose exec -T oj-postgres pg_dumpall -c -U onlinejudge > db_backup_xxx.sql

在服务器上报 `service "oj-postgres" is not running`。容器明明在跑 —— 线上是外接
形态,postgres 由 /root/OJDeploy/docker-compose.yml 起,不归 OJ2 这套 compose 管;
compose.debian.yml 里那个 oj-postgres 挂着 `profiles: ["local-data"]`,只在自带
数据形态下启动。所以脚本直接按容器名 `docker exec`,跟谁起的无关,两种形态都能用。

比原来那条命令多做的事:

- **先写 .partial,验完才改名**。`> file.sql` 中途失败(容器挂了、盘满了、
  pg_dumpall 报错)会留个半截文件,看着像备份,等到要恢复那天才发现不是。
  中断也清掉。
- **验完整性**。gzip -t,再检查结尾有没有「PostgreSQL database cluster dump
  complete」,缺了就当失败。
- **umask 077 + chmod 600**。备份里有 user.raw_password(明文密码,本来就是留给
  老师查的)和角色口令散列,不该落成 644。
- **自检真连一次库**(psql -c 'select 1' 而不是 pg_isready)。后者用户名写错照样
  说 OK,要到 pg_dumpall 才炸出一句 role does not exist。
- **默认存到仓库外面**。deploy.sh 头部那条 rsync 带 --delete,备份放仓库里下次
  部署就没了;真放进去了会警告。
- **保留策略带兜底**。删超期的,但最新 3 份永远保留 —— 时钟错乱或者 --keep-days
  手滑填 0,都不该把手头唯一的备份删掉。
- **磁盘余量检查**。剩余空间比库还小就拒绝,--force 才继续。备份把生产盘写满比
  没备份更糟。

默认 gzip,--plain 关掉。头部写了 cron 的写法,以及恢复时那几条
`does not exist` / `already exists` 是 pg_dumpall -c 的正常噪音。

本机 dev 库(245 MiB)实跑:正常路径 38.9 MiB gz / 2 秒;容器名写错、用户名写错都在
自检拦住;导出中途失败后 .partial 被清掉;保留策略造 5 份 30 天前的 → 删 5 留 3,
把全部文件做旧 → 仍然保住最新 3 份。**恢复也真跑了**:起一个干净的 postgres:16-alpine
灌进去,submission=12 / user=11 / problem=20 / 28 张表,和原库一致。

只管数据库。判题测试点在 data/backend/test_case,不在库里,那份还没有备份手段。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KqjE6qPo67fqVDKn6Bx7yd
2026-09-08 05:21:13 -06:00
f66bafafaf feat(教师统计): 提交统计面板返修 —— 统计口径、未完成分两栏、多题、自动刷新
排查这个面板时发现的一串问题和缺口,一起修掉。改动集中在
`GET /submissions/statistics` 和 `StatisticsPanel.vue`,两边互相咬着,
拆不成独立的 commit。

**「已解决」数的不是题数**(真在显示错数字)。`count(*) filter (accepted)`
按用户名分组、没有 distinct problem_id,同一道题重复 AC 会重复计数。查单道题时
看不出来,老师查「这节课全班」时「已解决 5」可能是同一道题交了 5 次。新增
`solvedCount = count(distinct problem_id) filter (accepted)`,表格那一列换成它;
`acceptedCount` 保留,正确率的分子仍然是提交条数。

**正确率把判题中的算进了分母**。PENDING / JUDGING 进分母不进分子,全班同时交卷的
那几秒正确率凭空掉一截 —— 而老师盯着看的就是这个数。分母改成判完的条数,
`UNJUDGED_RESULTS` 提到 judge/status.ts 共用。总提交仍是全部条数(交了就算交过,
否则人数口径会跟着变,正在判的学生会掉进「没交」名单),另外下发 `judgingCount`,
非零时面板多显示一块「判题中」,三个数字才对得上。

**「未完成」实际是「一次没交」**。做了但一次没对的学生既不在「完成人数」也不在
未完成名单,等于从屏幕上消失 —— 而那恰恰是最该去看一眼的人。新增
`dataAttempted`,未完成栏拆成「还没交」/「交了没对」两组,tab 计数是两者之和。
请假隐藏对两组同时生效:他们同样占着班级人数这个分母,只藏一半会把完成度算错。

**点名字能看到错在哪**。`dataAttempted` 带上最近一条提交的题号、状态和
`statistic_info.err_info`(截断 400 字),点名字弹出来,还能一步跳到代码。
老师不用再切到提交列表、翻到这个人、点开代码。

**支持一次查几道题**。题号框接受 `1001,1005,1010`(中英文逗号、分号、空格都当
分隔符,投影前手敲不该因为打了全角逗号就查不出来),有一个题号不存在就整体 404。
**完成 = 这几道全解决**;只填一道时和原来完全等价,不填题号时退回「至少做出一道」。
差一道的人落在「交了没全对」里,名字后面缀 `2/3题`。

**零提交时整块面板消失**。判空条件是 `count.total > 0`,可一节课刚开始一条提交都
没有、后端已经把整份花名册当作「未完成」返回了 —— 最该看名单的时刻反而只显示
「暂无数据」。流程图那边更彻底:`/flowcharts/statistics` 的零提交分支把
`dataUnaccepted` 写死成空数组,名单压根没下发。

**面板不会自己刷新**。打开是空的、要点一次按钮,拿到的是那一刻的快照。改成打开即查
+ 每 15 秒滚动重查,页面切到后台就停,关掉面板随组件卸载停掉。上一次没回来就跳过
这一次。班级从手打 `ks251` 换成下拉(选项来自网站配置的 class_list,和登录框同一份),
保留自由输入,查过的班级记进 localStorage 下次带上 —— 机房电脑一台对一个班。

**明细查询没有 LIMIT**。展开行用的 submissionItems 原来把窗口内**全部**提交捞进内存
再原样序列化,「全部时段 + 不填条件」就是十几万条。改成只取有 AC 的人、每人最近 50 条,
截断用窗口函数发生在数据库侧;表格「提交数」仍是真实总数。

**顺带**:`personRate` 前端从来没读过(完成度是前端按「减掉请假人数的分母」自己算的),
从契约里删掉;「语法未过」的题数单列出来(AST_CHECK_FAILED 全站仍然算通过,口径没动,
只是让老师看得见谁是绕过语法要求做出来的,那批人教学上没达标)。

实跑验证(dev 全栈 + 浏览器):

- 已解决:student 两条 AC 都在题号 5 上 —— 改前显示 2,改后显示 1
- 正确率:临时把两条改成 PENDING/JUDGING,12 条提交 2 条通过 —— 16.67% → 20%,
  面板多出「判题中 2」,表格显示「12(2 条判题中)」
- 未完成两栏:student2 没交、student 交了 12 次没对,请假隐藏 student 后
  班级人数 2→1、tab 2→1、出现「恢复 1 位」
- 错因弹层:点 student 弹出「最近一次:1020 · 编译失败 / Test case not found / 看代码」
- 多题:`1004` 完成 1 人;`1004,1005` 完成 0 人、交了没全对 student 1/2题 8次;
  全角逗号同上;`1004,9999` 报 `Problem 9999 does not exist`
- 自动刷新:打开即发请求,之后 04:44:22 → 04:44:36 → 04:44:51 → 04:45:06 每 15 秒
  一次且窗口跟着滚;关掉面板后 20 秒请求数不再增长
- 班级下拉:选「25计算机1班」→ ks251,重开面板自动带上并立刻查

tsc / vue-tsc / check:routes 全过。验证用的 dev 库改动已还原。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KqjE6qPo67fqVDKn6Bx7yd
2026-09-08 05:20:51 -06:00
8 changed files with 834 additions and 131 deletions

View File

@@ -43,6 +43,13 @@ export function judgeStatusName(result: number) {
return JUDGE_STATUS_NAME[result] ?? `未知状态(${result})`
}
/**
* 还没判完的两个状态。这类提交**已经落库但结果未定**,凡是算「正确率」的地方都得把
* 它们从分母里摘掉 —— 否则全班同时交卷的那几秒,分母涨了分子没涨,正确率凭空掉一截。
* 人数口径不受影响:交了但还在判的学生仍然算「交过」,不该被点名成「没做」。
*/
export const UNJUDGED_RESULTS: number[] = [JudgeStatus.PENDING, JudgeStatus.JUDGING]
/**
* **不**计入「这道题失败了几次」的状态。除了通过(含 AST_CHECK_FAILED那也是答案对了
* 和还没判完的两个,还排掉 SYSTEM_ERROR —— 判题机自己崩了不是学生的问题,

View File

@@ -235,7 +235,12 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
personCount: roster.length,
completedCount: 0,
wordFrequencies: [],
dataUnaccepted: [],
// 一条提交都没有时,花名册上的人**全都**是「没做」—— 原来这里写死空数组,
// 于是一节课刚开始、最该点名的时候,教师面板反而一个名字都不给
dataUnaccepted: roster.map((row) => ({
username: row.username,
realName: stripClassPrefix(row.username, row.className),
})),
}
if (rows.length === 0) return success(c, flowchartStatisticsSchema.parse(empty))

View File

@@ -22,7 +22,7 @@ import {
import type { AuthUser } from "../auth/session"
import { db, schema } from "../db"
import { failure, success } from "../http"
import { JudgeStatus } from "../judge/status"
import { JudgeStatus, UNJUDGED_RESULTS } from "../judge/status"
import { judgeQueue } from "../queue"
import {
canAccessContest,
@@ -180,6 +180,11 @@ submissionRoutes.get("/submissions/today-count", async (c) => {
const ACCEPTED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
/** 正确率。分母是判完的条数,一条都还没判完时给 0 而不是 NaN */
function judgedRate(accepted: number, judged: number) {
return judged > 0 ? rounded((accepted / judged) * 100) : 0
}
/**
* 统计接口共用的时间窗解析。旧后端 `end` 必填、`start` 可选(不给就是「全部时段」)。
*/
@@ -190,23 +195,173 @@ function statisticsRange(c: { req: { query(name: string): string | undefined } }
return { start: start || null, end }
}
/** 一次最多查几道题。课堂上一节课布置三五道20 是留足了余量的上限 */
const STATISTICS_MAX_PROBLEMS = 20
/**
* 题号(展示用的 _id定位公开题目。找不到时统计接口要报错而不是退化成「全部题目」
* 否则教师打错一个字就会看到全站数据还以为是本题的
* 题号框允许一次填几道:`1001,1005,1010`。中英文逗号、空格、分号都当分隔符 ——
* 老师在投影前手敲,不该因为打了个全角逗号就查不出来
*/
async function findPublicProblemByDisplayId(displayId: string) {
const [row] = await db
.select({ id: schema.problem.id })
function parseDisplayIds(raw: string) {
const seen = new Set<string>()
const ids: string[] = []
for (const part of raw.split(/[,;\s]+/)) {
const id = part.trim()
if (!id) continue
const key = id.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
ids.push(id)
}
return ids
}
/**
* 按题号(展示用的 _id定位公开题目。**有一个找不到就整体报错**,不退化成「全部题目」——
* 否则教师打错一个字就会看到全站数据还以为是这几道题的。
*/
async function findPublicProblemsByDisplayIds(displayIds: string[]) {
const lowered = displayIds.map((id) => id.toLowerCase())
const rows = await db
.select({ id: schema.problem.id, displayId: schema.problem.displayId })
.from(schema.problem)
.where(
and(
sql`lower(${schema.problem.displayId}) = lower(${displayId})`,
inArray(sql`lower(${schema.problem.displayId})`, lowered),
isNull(schema.problem.contestId),
eq(schema.problem.visible, true),
),
)
.limit(1)
return row ?? null
const found = new Set(rows.map((row) => row.displayId.toLowerCase()))
const missing = displayIds.find((id) => !found.has(id.toLowerCase()))
return { ids: rows.map((row) => row.id), missing: missing ?? null }
}
/**
* 表格展开行要看的「这个人交了哪几次」。两道闸都是为了不让「全部时段 + 不填条件」
* 把十几万条提交整个搬进响应体:
*
* - **只取有 AC 的人**。明细只挂在 `data` 里,而 `data` 本来就只留有 AC 的人,
* 原来给「一次没对的人」也捞一份明细,捞完直接扔掉。
* - **每人只留最近 50 条**。展开行是一排 120px 的按钮,几十个就已经翻不动了。
* 表格「提交数」那一列走的是 perUser 的 count仍然是真实总数不受这里截断影响。
*/
const STATISTICS_ITEMS_PER_USER = 50
async function submissionItemsByUser(where: SQL | undefined, usernames: string[]) {
const byUser = new Map<string, { id: string; result: number }[]>()
if (!usernames.length) return byUser
// 走窗口函数而不是「查全量再在 JS 里截断」:截断要发生在数据库那边才省得下来。
const rows = await db.execute<{ username: string; id: string; result: number }>(sql`
select username, id, result from (
select
${schema.submission.username} as username,
${schema.submission.id} as id,
${schema.submission.result} as result,
row_number() over (
partition by ${schema.submission.username}
order by ${schema.submission.createTime} desc
) as rn
from ${schema.submission}
where ${and(where, inArray(schema.submission.username, usernames))}
) t
where rn <= ${STATISTICS_ITEMS_PER_USER}
-- rn 就是「这个人的第几新」,外层不排的话展开行里的按钮是乱序的
order by username, rn
`)
for (const row of rows) {
const bucket = byUser.get(row.username)
if (bucket) bucket.push({ id: row.id, result: row.result })
else byUser.set(row.username, [{ id: row.id, result: row.result }])
}
return byUser
}
/** 错误摘要截断长度。编译错误能刷几十行,弹层里放不下,也没必要 */
const FAILURE_MESSAGE_LIMIT = 400
/**
* 「交了没对」那一栏点开要看的:这个人**最近一条**提交错在哪。
*
* 有了它,老师看到「张三 12次」之后不用再切到提交列表、翻到这个人、点开代码 ——
* 点一下名字就知道是编译错了还是答案错了、报的什么。err_info 是判题机塞进
* statistic_info 的那一段,提交详情页读的也是它。
*/
async function lastFailureByUser(where: SQL | undefined, usernames: string[]) {
const byUser = new Map<
string,
{ id: string; problem: string; result: number; error: string | null }
>()
if (!usernames.length) return byUser
// 不给 submission 起别名where 里的条件是 drizzle 拼的,引用的是 "submission"."x"
const rows = await db.execute<{
username: string
id: string
problem: string
result: number
error: string | null
}>(sql`
select username, id, problem, result, error from (
select
${schema.submission.username} as username,
${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.username}
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.username, usernames))}
) t
where rn = 1
`)
for (const row of rows) {
byUser.set(row.username, {
id: row.id,
problem: 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, usernames: string[]) {
const byUser = new Map<string, number>()
if (!usernames.length) return byUser
const rows = await db.execute<{ username: string; n: number }>(sql`
select username, count(*)::int as n from (
select
${schema.submission.username} as username,
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.username, usernames))}
group by ${schema.submission.username}, ${schema.submission.problemId}
) t
where has_ast and not has_ac
group by username
`)
for (const row of rows) byUser.set(row.username, row.n)
return byUser
}
/**
@@ -236,11 +391,16 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
]
if (range.start) filters.push(sql`${schema.submission.createTime} >= ${range.start}`)
const displayId = c.req.query("problemId")?.trim()
if (displayId) {
const problem = await findPublicProblemByDisplayId(displayId)
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
filters.push(eq(schema.submission.problemId, problem.id))
const displayIds = parseDisplayIds(c.req.query("problemId") ?? "")
if (displayIds.length > STATISTICS_MAX_PROBLEMS) {
return failure(c, 400, "invalid-request", `At most ${STATISTICS_MAX_PROBLEMS} problems`)
}
if (displayIds.length) {
const { ids, missing } = await findPublicProblemsByDisplayIds(displayIds)
if (missing) {
return failure(c, 404, "problem-not-found", `Problem ${missing} does not exist`)
}
filters.push(inArray(schema.submission.problemId, ids))
}
const username = c.req.query("username")?.trim()
@@ -248,10 +408,22 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
const where = and(...filters)
const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
// 判题中的条数。要单独数出来,正确率的分母才能把它们摘掉
const judgingFilter = sql`count(*) filter (where ${inArray(schema.submission.result, UNJUDGED_RESULTS)})`
/**
* **解决的题数**,不是通过的提交条数。同一道题重复 AC改完再交一次仍然对
* 在这里只算一道 —— 表格那一列叫「已解决」,数条数就名不副实了。
* 指定了题号时它最多是 1不指定时才看得出差别老师查「这节课全班」就是这种
*/
const solvedFilter = sql`count(distinct ${schema.submission.problemId}) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
const [[totals], perUser, rosterRows, items] = await Promise.all([
const [[totals], perUser, rosterRows] = await Promise.all([
db
.select({ total: count(), accepted: acceptedFilter.mapWith(Number) })
.select({
total: count(),
accepted: acceptedFilter.mapWith(Number),
judging: judgingFilter.mapWith(Number),
})
.from(schema.submission)
.where(where),
db
@@ -259,6 +431,8 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
username: schema.submission.username,
submissionCount: count(),
acceptedCount: acceptedFilter.mapWith(Number),
solvedCount: solvedFilter.mapWith(Number),
judgingCount: judgingFilter.mapWith(Number),
})
.from(schema.submission)
.where(where)
@@ -266,26 +440,34 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
.orderBy(desc(count())),
// 只有指定了用户名才有「班级人数」这个概念;不指定时分母无意义,旧后端也返回 0
username ? matchedStudents(username) : Promise.resolve([]),
db
.select({
username: schema.submission.username,
id: schema.submission.id,
result: schema.submission.result,
})
.from(schema.submission)
.where(where)
.orderBy(desc(schema.submission.createTime)),
])
const submissionCount = totals?.total ?? 0
const acceptedCount = totals?.accepted ?? 0
const judgingCount = totals?.judging ?? 0
// 正确率的分母是**判完的条数**,不是总条数
const judgedCount = submissionCount - judgingCount
const itemsByUser = new Map<string, { id: string; result: number }[]>()
for (const item of items) {
const bucket = itemsByUser.get(item.username)
if (bucket) bucket.push({ id: item.id, result: item.result })
else itemsByUser.set(item.username, [{ id: item.id, result: item.result }])
}
/**
* 「做完了」的判定。**指定了几道题,就要几道都解决**(这是教师选的口径:
* 「今天布置三道,谁全做完了」)—— 做出两道差一道的人落在「交了没全对」那一栏,
* 那里带着 `solvedCount`,老师看得出他差几道。
*
* 只填一道题时 `solvedCount >= 1` 和原来的 `acceptedCount > 0` 完全等价;
* 不填题号时无所谓「全部」,退回「至少做出一道」。
*/
const requiredSolved = displayIds.length
const isDone = (row: { solvedCount: number; acceptedCount: number }) =>
requiredSolved > 0 ? row.solvedCount >= requiredSolved : row.acceptedCount > 0
// 表格列的是做完了的人。没做完的(一条没交 / 交了没全对)在「未完成」那一栏
const acceptedUsers = perUser.filter(isDone)
// 这两个都要等 acceptedUsers 定下来才能查,所以进不了上面那个 Promise.all
const acceptedNames = acceptedUsers.map((row) => row.username)
const [itemsByUser, astOnlyByUserMap] = await Promise.all([
submissionItemsByUser(where, acceptedNames),
astOnlyByUser(where, acceptedNames),
])
const submittedUsernames = new Set(perUser.map((row) => row.username))
const classNames = new Map<string, string | null>()
@@ -297,17 +479,17 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
for (const row of rows) classNames.set(row.username, row.className)
}
// 只列出有正确提交的人。做了但一次没对的学生落在「未完成」那一栏
const data = perUser
.filter((row) => row.acceptedCount > 0)
.map((row) => ({
username: row.username,
className: classNames.get(row.username) ?? null,
submissionCount: row.submissionCount,
acceptedCount: row.acceptedCount,
correctRate: rounded((row.acceptedCount / row.submissionCount) * 100),
submissionItems: itemsByUser.get(row.username) ?? [],
}))
const data = acceptedUsers.map((row) => ({
username: row.username,
className: classNames.get(row.username) ?? null,
submissionCount: row.submissionCount,
acceptedCount: row.acceptedCount,
solvedCount: row.solvedCount,
astOnlyCount: astOnlyByUserMap.get(row.username) ?? 0,
judgingCount: row.judgingCount,
correctRate: judgedRate(row.acceptedCount, row.submissionCount - row.judgingCount),
submissionItems: itemsByUser.get(row.username) ?? [],
}))
const dataUnaccepted = rosterRows
.filter((row) => !submittedUsernames.has(row.username))
@@ -316,25 +498,42 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
realName: stripClassPrefix(row.username, row.className),
}))
// 顺序照搬旧后端:先用原始 person_count 算完成度,再修正 person_count。
// 修正是为了兜住「学生已删号但提交记录还在」——那时完成人数会大于花名册人数
// 交了但一次没对的。**按花名册取**,和 dataUnaccepted 同一个范围 ——
// 不指定用户名时没有花名册,这一栏也就跟着为空,不会冒出一堆别的班的人
const rosterNames = new Map(rosterRows.map((row) => [row.username, row.className]))
// 交了但没做完的:包括一道都没对的,也包括三道里做出两道的
const attemptedRows = perUser.filter(
(row) => !isDone(row) && rosterNames.has(row.username),
)
const failureByUser = await lastFailureByUser(
where,
attemptedRows.map((row) => row.username),
)
const dataAttempted = attemptedRows.map((row) => ({
username: row.username,
realName: stripClassPrefix(row.username, rosterNames.get(row.username) ?? null),
submissionCount: row.submissionCount,
solvedCount: row.solvedCount,
lastFailure: failureByUser.get(row.username) ?? null,
}))
// 「学生已删号但提交记录还在」时完成人数会大于花名册人数,分母兜到完成人数为止。
// 旧后端在这之前还先算了一个 person_rate 一起下发,前端从来没读过它(完成度是
// 前端自己按「减掉请假人数之后的分母」重算的),所以这条链路上只留 person_count。
let personCount = rosterRows.length
let personRate = 0
if (personCount) {
personRate = Math.min(100, rounded((data.length / personCount) * 100))
if (personCount < data.length) personCount = data.length
}
if (personCount && personCount < data.length) personCount = data.length
return success(
c,
submissionStatisticsSchema.parse({
submissionCount,
acceptedCount,
correctRate: submissionCount ? rounded((acceptedCount / submissionCount) * 100) : 0,
judgingCount,
correctRate: judgedRate(acceptedCount, judgedCount),
personCount,
personRate,
data,
dataUnaccepted,
dataAttempted,
}),
)
})

View File

@@ -20,13 +20,9 @@
<n-button type="primary" @click="handleStatistics">统计</n-button>
</n-flex>
<n-empty
v-if="data.totalCount === 0"
description="暂无数据"
style="margin: 40px 0"
/>
<n-empty v-if="!hasResult" description="暂无数据" style="margin: 40px 0" />
<template v-if="data.totalCount > 0">
<template v-if="hasResult">
<n-divider style="margin: 16px 0" />
<n-flex justify="space-around">
<div class="stat-item">
@@ -68,7 +64,7 @@
<n-tab-pane name="charts" tab="数据图表">
<n-grid :cols="2" :x-gap="20" :y-gap="20" style="margin-top: 12px">
<!-- 1. Grade pie chart -->
<n-gi>
<n-gi v-if="data.totalCount > 0">
<n-card title="等级分布">
<div class="chart-container">
<Doughnut :data="gradeChartData" :options="doughnutOptions" />
@@ -230,6 +226,14 @@ const data = reactive<FlowchartStatistics>({
dataUnaccepted: [],
})
/**
* 「查出东西了吗」。和 StatisticsPanel 同一个道理:一节课刚开始时没有任何提交,
* 但花名册整份都在 dataUnaccepted 里,那会儿正是老师要看名单的时候。
*/
const hasResult = computed(
() => data.totalCount > 0 || data.dataUnaccepted.length > 0,
)
const wordcloudCanvas = useTemplateRef<HTMLCanvasElement>("wordcloudCanvas")
let wordcloudChart: ChartJS | null = null

View File

@@ -1,15 +1,18 @@
<template>
<n-flex align="center">
<n-input
placeholder="用户(可选)"
<n-select
placeholder="班级或用户(可选)"
v-model:value="query.username"
style="width: 150px"
:options="classOptions"
style="width: 190px"
filterable
tag
clearable
/>
<n-input
placeholder="题号(可选)"
placeholder="题号(可选,逗号分隔"
v-model:value="query.problem"
style="width: 120px"
style="width: 200px"
clearable
/>
<n-select
@@ -17,19 +20,17 @@
v-model:value="query.duration"
:options="options"
/>
<n-button type="primary" @click="handleStatistics">统计</n-button>
<n-button type="primary" :loading="loading" @click="handleStatistics">
统计
</n-button>
<n-button v-if="route.name !== 'submissions'" @click="goSubmissions">
前往提交列表
</n-button>
</n-flex>
<n-empty
v-if="count.total === 0"
description="暂无数据"
style="margin: 40px 0"
/>
<n-empty v-if="!hasResult" description="暂无数据" style="margin: 40px 0" />
<template v-if="count.total > 0">
<template v-if="hasResult">
<n-divider style="margin: 16px 0" />
<n-flex justify="space-around">
<div class="stat-item">
@@ -44,13 +45,19 @@
count.accepted
}}</n-gradient-text>
</div>
<div class="stat-item" v-if="count.judging > 0">
<n-text>判题中</n-text>
<n-gradient-text type="info" font-size="28">{{
count.judging
}}</n-gradient-text>
</div>
<div class="stat-item">
<n-text>正确率</n-text>
<n-gradient-text type="warning" font-size="28"
>{{ count.rate }}%</n-gradient-text
>
</div>
<template v-if="person.count > 0">
<template v-if="personCount > 0">
<div class="stat-item">
<n-text>完成人数</n-text>
<n-gradient-text type="error" font-size="28">{{
@@ -76,12 +83,12 @@
<n-tabs animated type="line">
<n-tab-pane name="charts" tab="数据图表">
<n-grid :cols="2" :x-gap="20" :y-gap="20" style="margin-top: 12px">
<n-gi>
<n-gi v-if="count.total > 0">
<n-card title="提交正确率">
<Doughnut :data="pieChartData" :options="pieChartOptions" />
</n-card>
</n-gi>
<n-gi v-if="person.count > 0">
<n-gi v-if="personCount > 0">
<n-card title="班级完成度">
<Doughnut
:data="completionChartData"
@@ -104,12 +111,10 @@
:row-props="rowProps"
style="margin-top: 12px"
/>
<n-empty v-else description="还没有人做出来" style="margin: 24px 0" />
</n-tab-pane>
<n-tab-pane
name="unaccepted"
:tab="`未完成(${visibleUnaccepted.length}`"
>
<n-tab-pane name="unaccepted" :tab="`未完成(${unfinishedTotal}`">
<n-flex align="center" style="margin: 12px 0">
<n-switch v-model:value="hideMode" size="large">
<template #checked>请假隐藏中</template>
@@ -124,27 +129,62 @@
恢复 {{ hiddenCount }}
</n-button>
</n-flex>
<n-flex size="large" align="center">
<n-gradient-text
v-if="visibleUnaccepted.length === 0"
font-size="24"
type="success"
>
全都完成了
</n-gradient-text>
<template v-for="item in visibleUnaccepted" :key="item.username">
<n-tag
v-if="hideMode"
closable
size="large"
style="font-size: 20px"
@close="hideStudent(item.username)"
>
{{ item.realName }}
</n-tag>
<span v-else style="font-size: 24px">{{ item.realName }}</span>
</template>
</n-flex>
<n-gradient-text
v-if="unfinishedGroups.length === 0"
font-size="24"
type="success"
>
全都完成了
</n-gradient-text>
<template v-for="group in unfinishedGroups" :key="group.title">
<n-text depth="3" class="group-title">
{{ group.title }}{{ group.items.length }}
</n-text>
<n-flex size="large" align="center">
<template v-for="item in group.items" :key="item.username">
<n-popover
trigger="click"
placement="bottom"
:disabled="!item.failure"
style="max-width: 460px"
>
<template #trigger>
<n-tag
v-if="hideMode"
closable
size="large"
style="font-size: 20px"
@close="hideStudent(item.username)"
>
{{ item.label }}
</n-tag>
<span
v-else
:class="{ name: true, 'name-clickable': !!item.failure }"
>
{{ item.label }}
</span>
</template>
<n-flex vertical size="small">
<n-text depth="3">
最近一次{{ item.failure?.problem }} ·
{{ statusName(item.failure?.result) }}
</n-text>
<pre v-if="item.failure?.error" class="failure-error">{{
item.failure?.error
}}</pre>
<n-button
size="small"
tertiary
@click="openFailure(item.failure?.id)"
>
看代码
</n-button>
</n-flex>
</n-popover>
</template>
</n-flex>
</template>
</n-tab-pane>
</n-tabs>
</template>
@@ -153,12 +193,18 @@
import { h } from "vue"
import { formatISO, sub, type Duration } from "date-fns"
import { getSubmissionStatistics } from "oj/api"
import { DURATION_OPTIONS } from "utils/constants"
import { DURATION_OPTIONS, STORAGE_KEY } from "utils/constants"
import storage from "utils/storage"
import { useConfigStore } from "../store/config"
import { Doughnut } from "vue-chartjs"
import { Chart as ChartJS, ArcElement, Title, Tooltip, Legend } from "chart.js"
import { NButton, NFlex, NText, type DataTableRowKey } from "naive-ui"
import { JUDGE_STATUS } from "utils/constants"
import type { SubmissionStatisticsUser, UnacceptedStudent } from "@oj2/contract"
import type {
AttemptedStudent,
SubmissionStatisticsUser,
UnacceptedStudent,
} from "@oj2/contract"
// 注册 Chart.js 组件
ChartJS.register(ArcElement, Title, Tooltip, Legend)
@@ -207,8 +253,24 @@ const columns: DataTableColumn<SubmissionStatisticsUser>[] = [
},
},
{ title: "用户", key: "username" },
{ title: "提交数", key: "submissionCount" },
{ title: "已解决", key: "acceptedCount" },
{
title: "提交数",
key: "submissionCount",
render: (row) =>
row.judgingCount > 0
? `${row.submissionCount}${row.judgingCount} 条判题中)`
: `${row.submissionCount}`,
},
// 题数,不是通过的提交条数 —— 同一道题重复 AC 只算一道。
// 「语法未过」是答案对了但没按要求写、而且最后也没改对的,算在已解决里但教学上没达标
{
title: "已解决",
key: "solvedCount",
render: (row) =>
row.astOnlyCount > 0
? `${row.solvedCount}${row.astOnlyCount} 题语法未过)`
: `${row.solvedCount}`,
},
// 新后端返回的是数值,百分号在这里补 —— 旧后端直接返回 "85.5%" 字符串
{
title: "正确率",
@@ -217,8 +279,28 @@ const columns: DataTableColumn<SubmissionStatisticsUser>[] = [
},
]
const configStore = useConfigStore()
/**
* 班级下拉。值就是用户名前缀(`ks231`),后端那边本来就是 ilike 模糊匹配,
* 所以选班级和手打前缀是同一件事。`tag` 留着让老师仍然能直接打某个学生的名字。
*/
const classOptions = computed<SelectOption[]>(
() =>
configStore.config?.classList.map((item) => ({
label: `${item.slice(0, 2)}计算机${item.slice(2)}`,
value: `ks${item}`,
})) ?? [],
)
// 机房电脑一台对一个班,用过的班级记在本地,下次打开就带上(和登录框同一套做法)
function lastUsedClass() {
const last = storage.get(STORAGE_KEY.STATISTICS_CLASS)
return typeof last === "string" ? last : ""
}
const query = reactive({
username: props.username,
username: props.username || lastUsedClass(),
problem: props.problem,
duration: options[0].value,
})
@@ -226,25 +308,38 @@ const query = reactive({
const count = reactive({
total: 0,
accepted: 0,
// 还在判题队列里的条数。total 含它rate 的分母不含 —— 全班同时交卷的那几秒,
// 分母涨了分子没涨,正确率会凭空掉一截
judging: 0,
rate: 0,
})
const person = reactive({
count: 0,
rate: 0,
})
// 花名册人数。后端只下发这一个分母,完成度在前端算 —— 「请假隐藏」要从分母里
// 减人,那是浏览器本地状态,后端算不出来
const personCount = ref(0)
const route = useRoute()
const router = useRouter()
const list = ref<SubmissionStatisticsUser[]>([])
const listUnaccepted = ref<UnacceptedStudent[]>([])
// 交了但一次没对的。和上面那一栏合起来才是「未完成」的全部
const listAttempted = ref<AttemptedStudent[]>([])
const expandedRowKeys = ref<DataTableRowKey[]>([])
/**
* 「查出东西了吗」。**不能只看提交数** —— 一节课刚开始时一条提交都没有,但后端
* 已经把整份花名册当作「未完成」返回了,而那正是老师最想看名单的时刻。
* 原来整个面板 v-if 在 count.total > 0 上,那会儿只显示「暂无数据」。
*/
const hasResult = computed(
() => count.total > 0 || listUnaccepted.value.length > 0,
)
const HIDE_DURATION = 2 * 60 * 60 * 1000
const STORAGE_KEY = "oj_hidden_students"
const HIDDEN_KEY = "oj_hidden_students"
function loadHidden(): Record<string, number> {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}")
return JSON.parse(localStorage.getItem(HIDDEN_KEY) ?? "{}")
} catch {
return {}
}
@@ -254,7 +349,7 @@ const hiddenStudents = ref<Record<string, number>>(loadHidden())
const hideMode = ref(false)
function saveHidden(data: Record<string, number>) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
localStorage.setItem(HIDDEN_KEY, JSON.stringify(data))
}
function hideStudent(username: string) {
@@ -270,23 +365,88 @@ function showAll() {
saveHidden({})
}
const visibleUnaccepted = computed(() => {
const now = Date.now()
return listUnaccepted.value.filter((item) => {
const exp = hiddenStudents.value[item.username]
return !exp || exp <= now
})
})
function notHidden(item: { username: string }) {
const exp = hiddenStudents.value[item.username]
return !exp || exp <= Date.now()
}
const hiddenCount = computed(() => {
const now = Date.now()
return listUnaccepted.value.filter((item) => {
const exp = hiddenStudents.value[item.username]
return !!exp && exp > now
}).length
})
const visibleUnaccepted = computed(() => listUnaccepted.value.filter(notHidden))
const visibleAttempted = computed(() => listAttempted.value.filter(notHidden))
const adjustedPersonCount = computed(() => person.count - hiddenCount.value)
// 请假的人两栏都要藏 —— 他们同样占着班级人数这个分母
const hiddenCount = computed(
() =>
listUnaccepted.value.length +
listAttempted.value.length -
visibleUnaccepted.value.length -
visibleAttempted.value.length,
)
const unfinishedTotal = computed(
() => visibleUnaccepted.value.length + visibleAttempted.value.length,
)
/**
* 这次查的是几道题。**取的是「上次点统计时」的值**,不是输入框的当前内容 ——
* 老师改到一半的题号不该把已经查出来的结果重新解释一遍。
*/
const queriedProblemCount = ref(0)
/**
* 未完成的两组。合成一栏而不是各占一个 tab点名时来回切 tab 很别扭;
* 但也不能混成一个名单 —— 「卡住了」和「还没动手」在课堂上要做的事不一样。
* 次数缀在名字后面,教师一眼看得出谁卡得最久(后端按提交数倒序给)。
*/
type UnfinishedItem = {
username: string
label: string
/** 只有「交了没对」那一组有,点名字弹出来看错在哪 */
failure: AttemptedStudent["lastFailure"]
}
const unfinishedGroups = computed<{ title: string; items: UnfinishedItem[] }[]>(
() =>
[
{
title: "还没交",
items: visibleUnaccepted.value.map((item) => ({
username: item.username,
label: item.realName,
failure: null,
})),
},
{
// 查多道题时这一栏混着「一道没对」和「差一道」两种人,标题得说清是「没全对」
title: queriedProblemCount.value > 1 ? "交了没全对" : "交了没对",
items: visibleAttempted.value.map((item) => ({
username: item.username,
label: attemptedLabel(item),
failure: item.lastFailure,
})),
},
].filter((group) => group.items.length > 0),
)
type FailureResult = NonNullable<AttemptedStudent["lastFailure"]>["result"]
// 查多道题时把「做出几道」缀上:差一道和一道没做出来,老师要先管的不是同一个人
function attemptedLabel(item: AttemptedStudent) {
const total = queriedProblemCount.value
const progress = total > 1 ? ` ${item.solvedCount}/${total}` : ""
return `${item.realName}${progress} ${item.submissionCount}`
}
function statusName(result?: FailureResult) {
return result === undefined ? "" : (JUDGE_STATUS[result]?.name ?? "未知状态")
}
function openFailure(id?: string) {
if (id) openSubmission(id)
}
const adjustedPersonCount = computed(
() => personCount.value - hiddenCount.value,
)
const adjustedPersonRate = computed(() => {
if (adjustedPersonCount.value <= 0) return "0%"
@@ -304,11 +464,24 @@ onMounted(() => {
)
hiddenStudents.value = cleaned
saveHidden(cleaned)
// 打开就查一次。老师是投在屏幕上盯着看的,不该还要先点一下按钮
handleStatistics()
})
/**
* 课堂上「还剩几个没交」每十几秒就在变,所以面板自己刷。
* 时间窗是相对当下算的,每次刷都是新的窗口,不是同一份快照重放。
* 页面切到后台就停 —— 那会儿没人在看,白跑而已。
*/
const visibility = useDocumentVisibility()
useIntervalFn(() => {
if (visibility.value === "visible") handleStatistics()
}, 15000)
// 饼图数据 - 提交正确率分布
const pieChartData = computed(() => {
const wrongCount = count.total - count.accepted
// 判题中的既不算对也不算错,和正确率同一个口径
const wrongCount = count.total - count.accepted - count.judging
return {
labels: ["正确提交", "错误提交"],
datasets: [
@@ -409,27 +582,48 @@ function goSubmissions() {
},
})
}
const loading = ref(false)
async function handleStatistics() {
// 自动刷新和手点可能撞上,上一次没回来就跳过这一次
if (loading.value) return
loading.value = true
try {
await fetchStatistics()
} finally {
loading.value = false
}
}
async function fetchStatistics() {
const current = Date.now()
const end = formatISO(current)
const duration =
query.duration === "all"
? { end }
: { start: formatISO(sub(current, subOptions.value)), end }
const problems = query.problem
.split(/[,;\s]+/)
.map((item) => item.trim())
.filter(Boolean)
const res = await getSubmissionStatistics(
duration,
query.problem,
query.username,
)
queriedProblemCount.value = new Set(problems.map((p) => p.toLowerCase())).size
count.total = res.submissionCount
count.accepted = res.acceptedCount
count.judging = res.judgingCount
count.rate = res.correctRate
// 这里的 res.data 是载荷**自己**的 data 字段(每个学生一行),
// 不是原来那层信封 —— 契约 submissionStatisticsSchema 就是这么定的
list.value = res.data
listUnaccepted.value = res.dataUnaccepted
person.count = res.personCount
person.rate = res.personRate
listAttempted.value = res.dataAttempted
personCount.value = res.personCount
// 查过的班级记下来,下次打开直接带上
if (query.username) storage.set(STORAGE_KEY.STATISTICS_CLASS, query.username)
}
function rowKey(row: SubmissionStatisticsUser): DataTableRowKey {
@@ -452,6 +646,30 @@ function rowProps(row: SubmissionStatisticsUser) {
}
</script>
<style scoped>
.name {
font-size: 24px;
}
.name-clickable {
cursor: pointer;
text-decoration: underline dotted;
text-underline-offset: 4px;
}
.failure-error {
margin: 0;
max-height: 160px;
overflow: auto;
font-size: 12px;
white-space: pre-wrap;
word-break: break-all;
}
.group-title {
display: block;
margin: 16px 0 8px;
}
.stat-item {
display: flex;
flex-direction: column;

View File

@@ -144,6 +144,7 @@ export const STORAGE_KEY = {
ADMIN_PROBLEM_TAGS: "adminProblemTags",
DEMO_MODE: "demoMode",
LOGIN_CLASS: "loginClass",
STATISTICS_CLASS: "statisticsClass",
}
export const DIFFICULTY = {

214
docker/backup-db.sh Executable file
View File

@@ -0,0 +1,214 @@
#!/usr/bin/env bash
#
# 备份线上数据库pg_dumpall 全量(所有库 + 所有角色),带校验和保留策略。
#
# ## 为什么不走 docker compose exec
#
# 线上是**外接形态**postgres 容器由 /root/OJDeploy/docker-compose.yml 起,不归
# OJ2 这套 compose 管。compose.debian.yml 里虽然也定义了 oj-postgres但它挂着
# `profiles: ["local-data"]`,只在「自带数据」形态下才启动。所以在 OJ2 目录里跑
#
# docker compose exec -T oj-postgres pg_dumpall ...
#
# 会报 `service "oj-postgres" is not running` —— 容器明明在跑,只是属于另一个
# compose 项目。这里直接按容器名 `docker exec`,跟谁起的无关,两种形态都能用。
#
# ## 用法
#
# docker/backup-db.sh # 备份到 <仓库上级>/backups
# docker/backup-db.sh --out /mnt/backup # 指定目录
# docker/backup-db.sh --plain # 不压缩(默认 gzip
# docker/backup-db.sh --keep-days 30 # 保留 30 天(默认 14
# docker/backup-db.sh --force # 磁盘余量不足也照做
# CONTAINER=oj2-postgres docker/backup-db.sh # 本机 dev
#
# ## 定时跑
#
# crontab -e
# 30 3 * * * /root/OJDeploy/OJ2/docker/backup-db.sh >> /var/log/oj-backup.log 2>&1
#
# cron 的 PATH 很短docker 一般在 /usr/bin 里,通常够用;真找不到就写绝对路径。
#
# ## 恢复时这几条报错是正常的
#
# ERROR: database "xxx" does not exist DROP 一个目标机上没有的库
# ERROR: current user cannot be dropped 正连着的角色删不掉
# ERROR: role "onlinejudge" already exists 角色已经在了
#
# pg_dumpall -c 生成的是「先删后建」,往一个干净实例灌的时候这几条必然出现,
# 不影响结果。恢复完对一下行数才是准的:
#
# select count(*) from submission;
#
# ## 几个刻意的做法
#
# - **不给 docker exec 加 -t**。分配了 TTY导出的 SQL 行尾会变成 CRLF恢复时炸。
# `-T` 更是压根不存在于 `docker exec`,那是 `docker compose exec` 的参数,
# 照抄过来会直接报错。)
# - **先写 .partial验完才改名**。`> db_backup_xxx.sql` 这种写法一旦中途失败
# —— 容器挂了、磁盘满了、pg_dumpall 报错 —— 留下的是个半截文件,看着像备份,
# 等到要恢复的那天才发现不是。
# - **验完整性**pg_dumpall 正常结束的最后一行是「PostgreSQL database cluster
# dump complete」。没有这行就当失败删掉重来。
# - **umask 077**。备份里有 user.raw_password明文密码本来就是留给老师查的
# 和所有角色的口令散列,不该是 644。
# - **默认存到仓库外面**。deploy 那条 rsync 带 `--delete`,备份放在仓库目录里,
# 下次部署就被删了。
#
# 只管数据库。判题测试点在 data/backend/test_case不在库里那份要另外备。
# `sh docker/backup-db.sh` 会用 dash 跑Debian 的 /bin/sh 就是 dash而下面那行
# 的 pipefail 是 bash 专有的,一上来就报 `Illegal option -o pipefail`。
# 这行必须在 set 之前,且只能用 dash 也认的语法。deploy.sh 里是同一道垫片。
[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@"
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
REPO_DIR="$PWD"
CONTAINER="${CONTAINER:-oj-postgres}"
DB_USER="${DB_USER:-onlinejudge}"
OUT_DIR="${BACKUP_DIR:-$(dirname "$REPO_DIR")/backups}"
KEEP_DAYS="${KEEP_DAYS:-14}"
# 保留策略再狠也不动最新的这几份 —— 服务器时钟错乱、或者 --keep-days 手滑填了 0
# 都不该把手头唯一的备份删掉
KEEP_MIN=3
COMPRESS=1
FORCE=0
while [ $# -gt 0 ]; do
case "$1" in
--out) OUT_DIR="${2:?--out 后面要跟目录}"; shift 2 ;;
--keep-days) KEEP_DAYS="${2:?--keep-days 后面要跟天数}"; shift 2 ;;
--plain) COMPRESS=0; shift ;;
--force) FORCE=1; shift ;;
*) echo "未知参数:$1(可用:--out、--keep-days、--plain、--force" >&2; exit 2 ;;
esac
done
say() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
warn() { printf ' \033[33m!\033[0m %s\n' "$*"; }
die() { printf '\n\033[1;31m❌ %s\033[0m\n\n' "$*" >&2; exit 1; }
human() {
awk -v b="$1" 'BEGIN {
split("B KiB MiB GiB TiB", u, " "); i = 1
while (b >= 1024 && i < 5) { b /= 1024; i++ }
printf "%.1f %s\n", b, u[i]
}'
}
# ---------------------------------------------------------------- 自检
say "自检"
docker ps --filter "name=^/${CONTAINER}\$" --filter status=running -q | grep -q . \
|| die "容器 $CONTAINER 没在跑。用 CONTAINER=... 指定容器名;外接形态下它由
/root/OJDeploy/docker-compose.yml 起:
docker compose -f /root/OJDeploy/docker-compose.yml up -d $CONTAINER"
# 用 psql 真连一次,不用 pg_isready后者只探「服务在不在」DB_USER 写错照样说 OK
# 要等到 pg_dumpall 那步才炸出一句 role does not exist
docker exec "$CONTAINER" psql -U "$DB_USER" -d postgres -tAc 'select 1' >/dev/null 2>&1 \
|| die "连不上 $CONTAINER 里的 postgres用户 $DB_USER)—— 服务没起,或者用 DB_USER=... 指定用户"
ok "容器 $CONTAINER 在跑,$DB_USER 能连上"
mkdir -p "$OUT_DIR" || die "建不了备份目录 $OUT_DIR"
case "$OUT_DIR/" in
"$REPO_DIR"/*) warn "备份目录在仓库里 —— deploy 的 rsync --delete 会把它删掉" ;;
esac
ok "备份目录 $OUT_DIR"
# datallowconn 是为了跳过 template0它不让连pg_database_size 也算不了
db_bytes=$(docker exec "$CONTAINER" psql -U "$DB_USER" -d postgres -tAc \
"select coalesce(sum(pg_database_size(datname)), 0)::bigint from pg_database where datallowconn" \
2>/dev/null | tr -d '[:space:]' || true)
[ -n "${db_bytes:-}" ] || db_bytes=0
free_bytes=$(df -Pk "$OUT_DIR" | awk 'NR == 2 { print $4 * 1024 }')
ok "$(human "$db_bytes"),磁盘剩 $(human "$free_bytes")"
if [ "$db_bytes" -gt 0 ] && [ "$free_bytes" -lt "$db_bytes" ]; then
[ "$FORCE" -eq 1 ] \
|| die "磁盘余量比库还小。压缩后通常小一个数量级,确认够用就加 --force
—— 备份把生产磁盘写满,比没有备份更糟"
warn "磁盘余量不足,--force 已指定,继续"
fi
# ---------------------------------------------------------------- 导出
say "导出"
stamp=$(date +%Y_%m_%d_%H_%M_%S)
target="$OUT_DIR/db_backup_${stamp}.sql"
[ "$COMPRESS" -eq 1 ] && target="${target}.gz"
partial="${target}.partial"
# 明文密码在里面,别落成 644
umask 077
# 中途失败(含 Ctrl-C不留半截文件冒充备份
trap 'rm -f "$partial"' EXIT INT TERM
started=$(date +%s)
if [ "$COMPRESS" -eq 1 ]; then
# pipefail 已开pg_dumpall 挂了整条管道就算失败,不会只看 gzip 的返回码
docker exec "$CONTAINER" pg_dumpall -c -U "$DB_USER" | gzip -c > "$partial"
else
docker exec "$CONTAINER" pg_dumpall -c -U "$DB_USER" > "$partial"
fi
elapsed=$(( $(date +%s) - started ))
# ---------------------------------------------------------------- 校验
say "校验"
if [ "$COMPRESS" -eq 1 ]; then
gzip -t "$partial" 2>/dev/null || die "gzip 自检没过,文件是坏的(已删)"
ok "gzip 完整"
ending=$(gzip -cd "$partial" | tail -5)
else
ending=$(tail -5 "$partial")
fi
printf '%s\n' "$ending" | grep -q 'database cluster dump complete' \
|| die "结尾没有「PostgreSQL database cluster dump complete」导出不完整已删
最后几行:
$ending"
ok "结尾正常,导出完整"
mv -- "$partial" "$target"
trap - EXIT INT TERM
chmod 600 "$target"
size_bytes=$(wc -c < "$target")
ok "$(basename "$target")$(human "$size_bytes"),用时 ${elapsed}s"
# ---------------------------------------------------------------- 保留
say "保留 $KEEP_DAYS"
# 按 mtime 倒序取最新的几份,它们无论多旧都不删
protected=$(find "$OUT_DIR" -maxdepth 1 -type f -name 'db_backup_*.sql*' -printf '%T@ %p\n' \
| sort -rn | head -n "$KEEP_MIN" | cut -d' ' -f2-)
removed=0
while IFS= read -r old; do
[ -n "$old" ] || continue
case $'\n'"$protected"$'\n' in
*$'\n'"$old"$'\n'*) continue ;;
esac
rm -f -- "$old" && removed=$((removed + 1))
done < <(find "$OUT_DIR" -maxdepth 1 -type f -name 'db_backup_*.sql*' -mtime "+$KEEP_DAYS")
kept=$(find "$OUT_DIR" -maxdepth 1 -type f -name 'db_backup_*.sql*' | wc -l)
ok "删掉 $removed 份过期的,现存 $kept 份(最新 $KEEP_MIN 份永远保留)"
say "完成"
printf ' %s\n\n' "$target"
printf ' 恢复(会先 DROP 再建,确认连的是对的实例;开头几条 does not exist\n'
printf ' / already exists 是正常的,见本脚本头部):\n'
if [ "$COMPRESS" -eq 1 ]; then
printf ' gzip -cd %s | docker exec -i %s psql -U %s -d postgres\n\n' \
"$target" "$CONTAINER" "$DB_USER"
else
printf ' docker exec -i %s psql -U %s -d postgres < %s\n\n' \
"$CONTAINER" "$DB_USER" "$target"
fi

View File

@@ -112,20 +112,64 @@ export const submissionListItemSchema = z.object({
export const submissionListSchema = paginatedSchema(submissionListItemSchema)
/**
* 未完成学生。`realName` 是从用户名里剥掉 `ks<班级号>` 前缀后剩下的那一段,
* **一条都没交**的学生。`realName` 是从用户名里剥掉 `ks<班级号>` 前缀后剩下的那一段,
* 不是 user.real_name 列 —— 与 F2「真名默认不下发」不冲突这里只有教师能看到
* 且教师面板的用途正是点名谁没做。
*
* 注意它不是「未完成」的全部:交了但一次没对的学生在 `dataAttempted` 里。
*/
export const unacceptedStudentSchema = z.object({
username: z.string(),
realName: z.string(),
})
/**
* **交了但一次没对**的学生。这批人原来两栏都不在 —— 不在「完成人数」(没 AC
* 也不在「未完成」名单(那一栏只收一条没交的),于是课堂上最该去看一眼的人
* 反而从屏幕上消失了。`submissionCount` 是窗口内的提交次数,教师据此判断
* 「卡了多久」。
*/
export const attemptedStudentSchema = unacceptedStudentSchema.extend({
submissionCount: z.number().int(),
/**
* 已经解决的题数。查多道题时这一栏里混着「一道没对」和「三道做出两道」两种人,
* 差几道决定了老师先管谁 —— 所以名字后面要缀 `2/3`。
*/
solvedCount: z.number().int(),
/**
* 最近一条提交错在哪。教师点名字就能看到「是编译错了还是答案错了」,
* 不必再切去提交列表翻这个人。`error` 是判题机写进 statistic_info 的 err_info
* 已截断;没有错误文本(比如答案错误那种)时为 null。
*/
lastFailure: z
.object({
id: z.string(),
/** 题目的展示编号,用来告诉老师错在哪道题 */
problem: z.string(),
result: judgeStatusSchema,
error: z.string().nullable(),
})
.nullable(),
})
export const submissionStatisticsUserSchema = z.object({
username: z.string(),
className: z.string().nullable(),
submissionCount: z.number().int(),
/** 通过的**提交条数**。correctRate 的分子就是它 */
acceptedCount: z.number().int(),
/**
* 解决的**题数**(同一道题重复 AC 只算一道)。表格「已解决」那一列显示的是它 ——
* 不指定题号查「这节课全班」时,条数和题数能差出好几倍。
*/
solvedCount: z.number().int(),
/**
* 「答案对了但语法没按要求写」且**最后也没改对**的题数。这些题算在 solvedCount 里
* AST_CHECK_FAILED 全站都算通过),单列出来只是让教师看得见教学上没达标的那几个。
*/
astOnlyCount: z.number().int(),
/** 这个人还在判题队列里的条数。`submissionCount` 含它,`correctRate` 的分母不含 */
judgingCount: z.number().int(),
// 百分比数值,不带 %。旧后端返回 "85.5%" 字符串,展示格式化交给前端。
correctRate: z.number(),
submissionItems: z.array(
@@ -136,11 +180,21 @@ export const submissionStatisticsUserSchema = z.object({
export const submissionStatisticsSchema = z.object({
submissionCount: z.number().int(),
acceptedCount: z.number().int(),
/**
* 还没判完的条数PENDING / JUDGING。`submissionCount` 把它算在内,
* `correctRate` 的分母不算 —— 全班同时交卷的那几秒,分母涨了分子没涨,
* 正确率会凭空掉一截。下发它是为了让教师看得出「那几条还在判」。
*/
judgingCount: z.number().int(),
correctRate: z.number(),
// 花名册人数(未禁用的普通用户)。**只有这一个分母下发**:完成度由前端算,
// 因为「请假隐藏」会把请假的人从分母里减掉,那是后端不知道的浏览器本地状态。
personCount: z.number().int(),
personRate: z.number(),
data: z.array(submissionStatisticsUserSchema),
/** 一条都没交的(花名册里的人减去有提交的人) */
dataUnaccepted: z.array(unacceptedStudentSchema),
/** 交了但一次没对的。和 dataUnaccepted 一样只在传了用户名(有花名册)时才有内容 */
dataAttempted: z.array(attemptedStudentSchema),
})
export const formatCodeRequestSchema = z.object({
@@ -161,6 +215,7 @@ export type SubmissionStatisticsUser = z.infer<
typeof submissionStatisticsUserSchema
>
export type UnacceptedStudent = z.infer<typeof unacceptedStudentSchema>
export type AttemptedStudent = z.infer<typeof attemptedStudentSchema>
export type SubmissionListItem = z.infer<typeof submissionListItemSchema>
export type SubmissionList = z.infer<typeof submissionListSchema>