From 982b1297aa4419dcde557e08ac718c5f4d2ce76f Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Sun, 25 Oct 2026 20:08:31 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AF=95=E7=94=A8=E5=89=8D=E5=87=86=E5=A4=87?= =?UTF-8?q?=EF=BC=9A=E5=85=A8=E7=8F=AD=E6=A8=A1=E6=8B=9F=E5=8E=8B=E6=B5=8B?= =?UTF-8?q?=E3=80=81=E8=AF=84=E5=88=86=E5=B9=B6=E5=8F=91=206=E2=86=9216?= =?UTF-8?q?=E3=80=81=E6=89=8B=E5=8A=A8=E5=A4=87=E4=BB=BD=E4=B8=8E=E6=81=A2?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test/classroom.ts:45 个虚拟学生走一遍登录、运行、自测、提交,统计每个环节等多久 - 评分并发可配置(SIM_GRADE_CONCURRENCY,默认 16),提交队列同时评的份数跟着走; 2 核实测项目 8 任务 3 全班自测从约 4 分钟降到约 1.5 分钟 - 检查的默认等待 2 秒→5 秒,机器忙时不再误判正确的程序 - 删除学生时删除他的数据库(编号会重新用给新学生) - deploy/backup.sh、deploy/restore.sh:手动备份到宿主机 backups/,恢复前先备份 Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 + README.md | 1 + deploy/README.md | 16 ++ deploy/backup.sh | 21 +++ deploy/restore.sh | 25 +++ docs/设计方案.md | 6 + samples/p4-t2-网络摄像机/checks.yaml | 4 +- samples/p8-t4-校园安防/checks.yaml | 8 +- samples/p8-t5-历史图片/checks.yaml | 6 +- server/src/app.ts | 6 +- server/src/backup.ts | 38 ++++ server/src/classes.ts | 3 +- server/src/config.ts | 6 + server/src/databases.ts | 8 + server/src/grading/checks.ts | 11 +- server/src/grading/grader.ts | 7 +- server/src/grading/queue.ts | 5 +- server/src/grading/selfcheck.ts | 2 +- server/src/index.ts | 10 ++ server/test/api.test.ts | 16 ++ server/test/backup.test.ts | 46 +++++ server/test/classroom.ts | 254 +++++++++++++++++++++++++++ server/test/grading.test.ts | 2 +- 23 files changed, 479 insertions(+), 25 deletions(-) create mode 100755 deploy/backup.sh create mode 100755 deploy/restore.sh create mode 100644 server/src/backup.ts create mode 100644 server/test/backup.test.ts create mode 100644 server/test/classroom.ts diff --git a/.gitignore b/.gitignore index 21bf3d7..3734b4b 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ obj/ # 本机开发的数据库 /data/ + +# 服务器上 deploy/backup.sh 做的备份 +/backups/ diff --git a/README.md b/README.md index 74d49f3..a3d21d8 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ cd server bun test # 端到端测试:编译并运行示例,模拟浏览器操作 cd ../web && bun test src # 窗体设计器:Designer.cs 读写、编辑操作、插入事件处理方法 bun test/load.ts 100 # 压力测试:同时运行 100 个学生程序 +BASE=http://localhost:3000 bun test/classroom.ts 45 # 全班模拟:45 个学生登录、运行、自测、提交,统计每个环节等多久 ``` ## 部署 diff --git a/deploy/README.md b/deploy/README.md index 047b023..c1a6b9d 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -103,8 +103,23 @@ | 学生账号 | 老师登录后在"班级管理"里导入名单、查看和重置密码 | | 看日志 | `docker compose logs -f app` | | 看资源占用 | `docker stats` | +| 备份数据 | `deploy/backup.sh`,见下文"备份与恢复" | | 重启 / 停止 | `docker compose restart` / `docker compose down` | +## 备份与恢复 + +平台不自动备份,在服务器的 `~/IOT_SIM` 下手动执行: + +```bash +deploy/backup.sh # 备份到 backups/<日期时间>/,平台运行中也可以,学生不受影响 +deploy/restore.sh backups/20261025-153000 # 用某份备份替换现在的全部数据 +``` + +- 备份包括账号、班级、学生作品、作业、提交、评阅(`iot-sim.db`)和每个学生自己的数据库(`databases/`)。一个班一学期的数据估计两三百 MB,主要是提交时的界面截图。 +- 备份放在宿主机的 `~/IOT_SIM/backups/` 里,不在 Docker 数据卷里,误删数据卷也还在;**要防服务器硬盘坏,把这个目录拷到别的机器上**。 +- 建议在部署大的新版本前、学期中间、期末导出成绩后各备份一次。 +- 恢复会先自动备份现在的数据(恢复错了还能换回来),然后停止平台约十几秒、替换数据、重新启动;正在运行的学生程序会结束,尽量不在上课时恢复。 + ## 可调参数 写在 `.env` 里,改完 `docker compose up -d` 生效。 @@ -120,3 +135,4 @@ | `SIM_HEAP_LIMIT_MB` | `128` | 每个学生程序的内存上限 | | `SIM_CPU_LIMIT_SECONDS` | `120` | 每次运行累计 CPU 时间上限(防死循环) | | `SIM_MAX_RUN_MINUTES` | `60` | 每次运行最长时间 | +| `SIM_GRADE_CONCURRENCY` | `16` | 全站同时跑几个评分检查项(提交评分和"自己检查"共用)。2 核实测:45 人同时自测项目 8 任务 3,最后一人约 1.5 分钟出结果;调到 24 以上 CPU 不够,定时器来不及触发,正确的程序会被误判 | diff --git a/deploy/backup.sh b/deploy/backup.sh new file mode 100755 index 0000000..cd8338b --- /dev/null +++ b/deploy/backup.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# 在服务器上执行:备份平台的全部数据(账号、班级、学生作品、作业、提交、评阅,以及每个学生自己的数据库) +# 到 backups/<日期时间>/。备份在宿主机上,不在 Docker 数据卷里,数据卷被删也还在;要防硬盘坏,把它拷到别的机器上。 +# 平台运行中也可以备份,学生不受影响。恢复见 deploy/restore.sh。 +# 用法:deploy/backup.sh +set -euo pipefail +cd "$(dirname "$0")/.." + +mkdir -p backups +name=$(date +%Y%m%d-%H%M%S) +# 同一秒里做了两次(如 restore.sh 恢复前先备份)时,等到下一秒,不和已有的备份重名 +while [[ -e backups/$name ]]; do + sleep 1 + name=$(date +%Y%m%d-%H%M%S) +done +inside=/data/.backup-$name +# 在容器里导出(SQLite 一致读取,不会备份出写了一半的库),再拷到宿主机 +docker compose exec -T app bun server.js backup "$inside" >/dev/null +docker compose cp "app:$inside" "backups/$name" 2>/dev/null +docker compose exec -T app rm -rf "$inside" +echo "已备份到 $(pwd)/backups/$name($(du -sh "backups/$name" | cut -f1))" diff --git a/deploy/restore.sh b/deploy/restore.sh new file mode 100755 index 0000000..6e272e9 --- /dev/null +++ b/deploy/restore.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# 在服务器上执行:用 deploy/backup.sh 做的备份替换平台现在的全部数据。 +# 替换前先自动备份现在的数据(恢复错了还能换回来);恢复时平台停止约十几秒,正在运行的学生程序会结束。 +# 用法:deploy/restore.sh backups/<日期时间> +set -euo pipefail +cd "$(dirname "$0")/.." + +dir=${1:?用法:deploy/restore.sh backups/<日期时间>} +if [[ ! -f $dir/iot-sim.db ]]; then + echo "$dir 不是备份目录(里面应该有 iot-sim.db)" + exit 1 +fi +read -r -p "用 $dir 替换平台现在的全部数据?现在的数据会先备份。[y/N] " answer +[[ $answer == y || $answer == Y ]] || exit 1 + +echo "== 备份现在的数据" +deploy/backup.sh +echo "== 停止平台、替换数据" +docker compose stop app +# 旧库的 -wal、-shm 文件一定要删掉,否则会被当成新库的未写完的日志,把恢复的库弄坏 +docker compose run --rm --no-deps -T -v "$(realpath "$dir"):/restore:ro" app \ + sh -c 'rm -rf /data/iot-sim.db /data/iot-sim.db-wal /data/iot-sim.db-shm /data/databases && cp -r /restore/. /data/' +echo "== 启动平台" +docker compose up -d app +echo "已恢复到 $dir" diff --git a/docs/设计方案.md b/docs/设计方案.md index dc15d5f..22ee515 100644 --- a/docs/设计方案.md +++ b/docs/设计方案.md @@ -316,6 +316,12 @@ flowchart TB - 2026-10-22:代码编辑器的语言功能:代码补全(输入 `btn_Open.` 列出按钮的属性、方法、事件,只列出平台仿真了的成员)、悬停提示、参数提示(`MessageBox.Show(` 列出重载,标出正在填的参数)、停止输入 1 秒后自动检查编译错误(提示和点"运行"时一样)。语言服务是一个常驻进程,每个浏览器标签页一份工程,只更新有变化的文件。实测(限制 2 核):50 个标签页同时检查 1.2 秒内全部完成,同时补全 0.7 秒;内存约 260 MB。 - 2026-10-22:AI 评阅:学生提交时浏览器给每个窗体截图;自动评分后由 DeepSeek Flash(支持图片输入和 JSON 输出)按书上评价表给老师评分项打建议分、写评语,发出的内容不含姓名学号,学生代码里像指令的文字一律当作品内容。提示词按教材的命名习惯校准("缩写_含义"、书上规定的控件名、界面上不用的标签保留默认名都不扣分):教材参考答案得 28/30,默认控件名、没有注释的作品得 15/30。评阅页显示截图和 AI 建议,可逐个采纳或"全部采纳"(不覆盖老师已评的分);学生在老师评阅后又提交时提示老师重新看。一份作业约 0.02 元。 - 2026-10-25:评价项和自测(为一个班试用做准备):实验页工具栏"评价项"打开侧栏,列出这个实验(作业则是布置时的评分规则和各项分值)自动检查的功能项;"自己检查"在服务器上按评分规则运行学生现在的程序,逐项显示通过与否和不通过的原因。不计分、不留记录,自由练习和作业都能用;每人同时一次,结果在内存里保留到下一次,检查之后改过代码会提示结果可能过时;和提交评分共用全站的并发上限。评分时操作控件前最多等 3 秒让它出现(上一步刚打开的窗体在机器忙时晚一点显示)。 +- 2026-10-25:一个班试用前的准备。 + - **全班模拟**(`server/test/classroom.ts`):45 个虚拟学生通过 HTTP / WebSocket 依次登录、打开作业、边写边检查、运行、点按钮、自己检查、提交,每个环节在 10 秒内陆续开始,统计每人等了多久。服务器限制在 2 核、用 Release 版运行时:登录、打开、检查、运行到窗体出现、点按钮都在 1.5 秒内(项目 8 第一次运行要建 WisdomCampus 库,约 5 秒)。 + - **评分并发 6 → 16**:评分时 CPU 只用了两三成,慢在并发上限。45 人同时自测项目 8 任务 3,最后一人原来要等约 4 分钟,现在约 1.5 分钟;项目 3 任务 2 从约 1.5 分钟降到半分钟。并发 24 时 CPU 接近 80%,定时器来不及触发,参考答案被误判,所以停在 16(可用 `SIM_GRADE_CONCURRENCY` 调整)。提交评分队列同时评的份数跟着并发上限走,不再固定为 3 份。 + - **检查的默认等待 2 秒 → 5 秒**:机器忙时,点"数据分析"后查库、画图表会超过 2 秒,正确的程序被判不通过。条件满足就立即往下走,放宽只让不通过的检查多等一会儿;规则里原来写的 3 秒一并去掉。 + - **删除学生时删除他的数据库**:用户编号在删掉最大的编号后会重新用给下一个新学生,原来新学生会看到前一个人的库。 + - **手动备份与恢复**:`deploy/backup.sh` 把平台数据库和每个学生的库(SQLite 一致读取)导出到宿主机的 `backups/<日期时间>/`;`deploy/restore.sh` 先备份现在的数据,再停平台、替换(删掉旧库的 -wal 文件)、启动。本机 Docker 实测备份、改数据、恢复后数据回到备份时的样子。 ## 技术风险与待确认事项 diff --git a/samples/p4-t2-网络摄像机/checks.yaml b/samples/p4-t2-网络摄像机/checks.yaml index 5ad6971..3fcec0f 100644 --- a/samples/p4-t2-网络摄像机/checks.yaml +++ b/samples/p4-t2-网络摄像机/checks.yaml @@ -14,7 +14,7 @@ steps: - text: { control: txt_ip, value: 192.168.2.50 } - click: btn_open - - expect: { control: pictureBox1, image: true, within: 3s, desc: 显示摄像机拍到的图像 } + - expect: { control: pictureBox1, image: true, desc: 显示摄像机拍到的图像 } - title: 能控制摄像机上下左右旋转 steps: @@ -35,6 +35,6 @@ steps: - text: { control: txt_ip, value: 192.168.2.50 } - click: btn_open - - expect: { control: pictureBox1, image: true, within: 3s, desc: 显示摄像机拍到的图像 } + - expect: { control: pictureBox1, image: true, desc: 显示摄像机拍到的图像 } - click: btn_camera - expect: { savedImage: Camera/, desc: 把照片保存到 Camera 文件夹 } diff --git a/samples/p8-t4-校园安防/checks.yaml b/samples/p8-t4-校园安防/checks.yaml index df7f18e..150af5e 100644 --- a/samples/p8-t4-校园安防/checks.yaml +++ b/samples/p8-t4-校园安防/checks.yaml @@ -5,15 +5,15 @@ - click: pic_security - expect: { window: Security, desc: 点"校园安防"打开 Security 窗体 } - expect: { camera: on } - - expect: { control: pic_video, image: true, within: 3s, desc: 显示摄像机拍到的图像 } + - expect: { control: pic_video, image: true, desc: 显示摄像机拍到的图像 } - title: 人体入侵有文字提示 steps: - click: pic_security - board: { infrared: true } - - expect: { control: lb_infrared, text: 发现入侵, within: 3s, desc: 红外对射被遮挡时显示"发现入侵" } + - expect: { control: lb_infrared, text: 发现入侵, desc: 红外对射被遮挡时显示"发现入侵" } - board: { infrared: false } - - expect: { control: lb_infrared, text: 正常, within: 3s } + - expect: { control: lb_infrared, text: 正常 } - title: 摄像机可以调整方向 steps: @@ -33,7 +33,7 @@ - title: 摄像机能拍照并保存图片 steps: - click: pic_security - - expect: { control: pic_video, image: true, within: 3s } + - expect: { control: pic_video, image: true } - click: btn_camera - expect: { savedImage: Camera/, desc: 把照片保存到 Camera 文件夹 } diff --git a/samples/p8-t5-历史图片/checks.yaml b/samples/p8-t5-历史图片/checks.yaml index 1821c5a..9197ea5 100644 --- a/samples/p8-t5-历史图片/checks.yaml +++ b/samples/p8-t5-历史图片/checks.yaml @@ -5,7 +5,7 @@ steps: - clock: '09:00:00' - click: pic_security - - expect: { control: pic_video, image: true, within: 3s } + - expect: { control: pic_video, image: true } - click: btn_camera - click: btn_history - expect: { window: SecurityCameraData, desc: 点"查看历史图像"打开历史报警记录窗体 } @@ -17,7 +17,7 @@ steps: - clock: '09:00:00' - click: pic_security - - expect: { control: pic_video, image: true, within: 3s } + - expect: { control: pic_video, image: true } - click: btn_camera - clock: '10:30:00' - click: btn_camera @@ -34,7 +34,7 @@ steps: - clock: '09:00:00' - click: pic_security - - expect: { control: pic_video, image: true, within: 3s } + - expect: { control: pic_video, image: true } - click: btn_camera - click: btn_history - pick: { control: dateTimePicker1, value: '2024-01-01' } diff --git a/server/src/app.ts b/server/src/app.ts index f96d067..889553e 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -24,7 +24,7 @@ import { languageService } from './language-service' import { getReview, gradebook, manualScore, saveReview, studentDetail, submissionFiles } from './reviews' import type { GradingQueue } from './grading/queue' import { config } from './config' -import { restoreExperimentDatabases, userDatabaseDir } from './databases' +import { deleteUserDatabases, restoreExperimentDatabases, userDatabaseDir } from './databases' import { listSamples, loadChecks, loadSample } from './samples' import { SqlError, sqlService } from './sql-service' import { serveStatic } from './static' @@ -280,8 +280,8 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ return password ? c.json({ password }) : c.json({ message: '学生不存在' }, 404) }) - app.delete('/api/teacher/students/:id', (c) => { - deleteStudent(db, id(c)) + app.delete('/api/teacher/students/:id', async (c) => { + if (deleteStudent(db, id(c))) await deleteUserDatabases(id(c)) return c.json({}) }) diff --git a/server/src/backup.ts b/server/src/backup.ts new file mode 100644 index 0000000..6728b29 --- /dev/null +++ b/server/src/backup.ts @@ -0,0 +1,38 @@ +// 手动备份:平台数据库(账号、班级、作品、作业、提交、评阅)和每个学生的数据库,导出到一个目录, +// 目录结构和 data/ 一样。服务器上用 deploy/backup.sh 备份、deploy/restore.sh 恢复(见 deploy/README.md)。 +import { Database } from 'bun:sqlite' +import { mkdir, readdir, rename, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { config } from './config' + +/** + * 把一个 SQLite 数据库完整导出成文件。serialize 在读事务里读出所有页, + * 学生程序或平台正在写入时也能得到一份一致的数据库(直接拷文件可能拷到写了一半的状态) + */ +async function saveCopy(path: string, target: string) { + const source = new Database(path, { readonly: true }) + try { + await Bun.write(target, source.serialize()) + } finally { + source.close() + } +} + +/** 把 data/ 里的数据库导出到 target(已有备份时报错,不覆盖) */ +export async function backupTo(target: string) { + if (await Bun.file(join(target, 'iot-sim.db')).exists()) throw new Error(`${target} 里已经有备份`) + // 先写到临时目录,写完再改名,不会留下做了一半的备份 + const temp = `${target}.partial` + await rm(temp, { recursive: true, force: true }) + await mkdir(temp, { recursive: true }) + + await saveCopy(join(config.dataDir, 'iot-sim.db'), join(temp, 'iot-sim.db')) + const databases = join(config.dataDir, 'databases') + for (const user of await readdir(databases).catch(() => [] as string[])) { + const files = (await readdir(join(databases, user)).catch(() => [] as string[])).filter((f) => f.endsWith('.db')) + if (files.length > 0) await mkdir(join(temp, 'databases', user), { recursive: true }) + for (const file of files) await saveCopy(join(databases, user, file), join(temp, 'databases', user, file)) + } + await rm(target, { recursive: true, force: true }) + await rename(temp, target) +} diff --git a/server/src/classes.ts b/server/src/classes.ts index ca5702e..3822647 100644 --- a/server/src/classes.ts +++ b/server/src/classes.ts @@ -136,6 +136,7 @@ export function resetPassword(db: Database, studentId: number): string | null { } /** 删除学生,连同他的作品 */ +/** 删除学生,返回是否删掉了 */ export function deleteStudent(db: Database, studentId: number) { - db.query(`DELETE FROM users WHERE id = ? AND role = 'student'`).run(studentId) + return db.query(`DELETE FROM users WHERE id = ? AND role = 'student'`).run(studentId).changes > 0 } diff --git a/server/src/config.ts b/server/src/config.ts index e56c083..d94a974 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -26,6 +26,12 @@ export const config = { cpuLimitSeconds: Number(env.SIM_CPU_LIMIT_SECONDS ?? 120), /** 全站同时运行的学生程序最多多少个(两个班约 100 人) */ maxRuns: Number(env.SIM_MAX_RUNS ?? 150), + /** + * 全站同时跑多少个评分检查项(提交评分和自测共用)。检查大部分时间在等学生程序的定时器,并发太低时全班排队: + * 2 核实测 45 人同时自测项目 8 任务 3,6 个时最后一人等 4 分钟(CPU 才 28%),16 个时 1.5 分钟(CPU 58%); + * 24 个时 CPU 接近 80%,定时器来不及触发,参考答案被误判不通过(test/classroom.ts) + */ + gradeConcurrency: Number(env.SIM_GRADE_CONCURRENCY ?? 16), /** 一次运行最长多少分钟,到时自动结束 */ maxRunMinutes: Number(env.SIM_MAX_RUN_MINUTES ?? 60), /** SQLite 数据库所在目录(账号、班级、学生作品);部署时放在数据卷里 */ diff --git a/server/src/databases.ts b/server/src/databases.ts index 5cfc657..92aea56 100644 --- a/server/src/databases.ts +++ b/server/src/databases.ts @@ -13,6 +13,14 @@ export async function userDatabaseDir(userId: number) { return dir } +/** + * 删除用户的全部数据库(删除学生时)。用户编号在删掉最大的编号后会被下一个新用户重新使用, + * 不删的话新学生会看到前一个人的库 + */ +export async function deleteUserDatabases(userId: number) { + await rm(resolve(config.dataDir, 'databases', String(userId)), { recursive: true, force: true }) +} + /** 实验预置的数据库脚本(samples/<实验>/database.sql),没有时返回 undefined */ export async function experimentScript(sampleId: string): Promise { const file = Bun.file(join(config.samplesDir, sampleId, 'database.sql')) diff --git a/server/src/grading/checks.ts b/server/src/grading/checks.ts index 793af7c..3650c8c 100644 --- a/server/src/grading/checks.ts +++ b/server/src/grading/checks.ts @@ -22,7 +22,8 @@ export type Step = | { kind: 'board'; analog: Partial>; digital: Partial> } | { kind: 'wait'; ms: number } /** desc:给学生看的期望说明,如"显示当前时间",不写就按期望自动生成 */ - | { kind: 'expect'; expect: Expectation; within: number; desc?: string } + /** timed:规则里写了 within(不通过时说明"几秒内") */ + | { kind: 'expect'; expect: Expectation; within: number; timed: boolean; desc?: string } export type Expectation = /** 控件属性等于某个值:text → Text,ledText → LedText(LED 控件),enabled → Enabled,checked → Checked,visible → Visible(连同所在的容器都可见),on → IsOn(灯泡、报警灯),value → Value(滑动条) */ @@ -54,8 +55,11 @@ export type Expectation = | { kind: 'row'; control: string; cells: Record; present: boolean } | { kind: 'console'; text: string } -/** 期望默认最多等多久(毫秒);依赖定时器的检查在规则里写 within */ -const DEFAULT_WITHIN = 2000 +/** + * 期望默认最多等多久(毫秒);依赖定时器的检查在规则里写 within。条件一满足就往下走,放宽只让不通过的检查多等一会儿; + * 全班同时评分时机器忙,点按钮后查数据库、画图表可能超过 2 秒(test/classroom.ts 实测),所以不能太紧 + */ +const DEFAULT_WITHIN = 5000 const PROP_KEYS: Record = { text: 'Text', ledText: 'LedText', enabled: 'Enabled', checked: 'Checked', visible: 'Visible', on: 'IsOn', value: 'Value' } const STRING_PROPS = new Set(['Text', 'LedText']) @@ -158,6 +162,7 @@ function parseStep(raw: unknown, where: string): Step { kind: 'expect', expect: parseExpectation(expectation, where), within: within === undefined ? DEFAULT_WITHIN : duration(within, where), + timed: within !== undefined, ...(desc === undefined ? {} : { desc: String(desc) }), } } diff --git a/server/src/grading/grader.ts b/server/src/grading/grader.ts index 6cb916b..29418d6 100644 --- a/server/src/grading/grader.ts +++ b/server/src/grading/grader.ts @@ -1,5 +1,5 @@ // 自动评分:编译一次学生工程,每个检查项各启动一次程序,按步骤操作并核对结果。 -// 检查大部分时间在等定时器,CPU 主要花在编译和启动程序上;2 核的服务器全站同时跑 GRADE_CONCURRENCY 个检查,其余排队。 +// 检查大部分时间在等定时器,CPU 主要花在编译和启动程序上;全站同时跑 config.gradeConcurrency 个检查,其余排队。 import type { CheckResult, ControlNode, GradeResult } from '@iot/shared' import { rm } from 'node:fs/promises' import { dirname } from 'node:path' @@ -9,7 +9,6 @@ import { gradingDatabaseDir } from '../databases' import type { Check, Expectation, Step } from './checks' import { ProgramDriver } from './driver' -const GRADE_CONCURRENCY = 6 /** 点数据表格的单元格前,最多等多久让那一行出现(程序可能还在查询数据库、刷新表格) */ const GRID_WAIT = 5000 /** 操作控件前最多等多久让它出现 */ @@ -28,7 +27,7 @@ let running = 0 const waiting: (() => void)[] = [] async function withSlot(task: () => Promise): Promise { - if (running >= GRADE_CONCURRENCY) await new Promise((resolve) => waiting.push(resolve)) + if (running >= config.gradeConcurrency) await new Promise((resolve) => waiting.push(resolve)) running++ try { return await task() @@ -211,7 +210,7 @@ const quote = (value: unknown) => (value === undefined || value === null || valu async function expectStep(driver: ProgramDriver, step: Extract) { const { expect: e, within } = step - const wait = within > 2000 ? `${seconds(within)}内` : '' + const wait = step.timed ? `${seconds(within)}内` : '' if (e.kind === 'unchanged') { const node = need(driver, e.control) diff --git a/server/src/grading/queue.ts b/server/src/grading/queue.ts index ae7b21c..8fda30b 100644 --- a/server/src/grading/queue.ts +++ b/server/src/grading/queue.ts @@ -1,4 +1,4 @@ -// 评分队列:学生提交后立即返回,后台按提交顺序评分(同时最多评 MAX_ACTIVE 份),结果写回数据库。 +// 评分队列:学生提交后立即返回,后台按提交顺序评分(同时评的份数不超过检查项的并发上限,保证评分槽位能用满),结果写回数据库。 // 服务器重启前没评完的提交,启动时继续评。评完调用 onGraded(接着做 AI 评阅)。 import type { Database } from 'bun:sqlite' import type { SourceFile } from '@iot/shared' @@ -9,7 +9,6 @@ import { config } from '../config' import { safeJoin } from '../run-session' import { grade } from './grader' -const MAX_ACTIVE = 3 /** 把作品写进一个干净的工程目录(评分和自测用) */ export async function writeProject(dir: string, files: SourceFile[]) { @@ -44,7 +43,7 @@ export function createGradingQueue(db: Database, onGraded: (submissionId: number } function pump() { - while (active < MAX_ACTIVE && queue.length > 0) { + while (active < config.gradeConcurrency && queue.length > 0) { const id = queue.shift()! active++ void gradeOne(id).finally(() => { diff --git a/server/src/grading/selfcheck.ts b/server/src/grading/selfcheck.ts index 0c94320..cc4dcec 100644 --- a/server/src/grading/selfcheck.ts +++ b/server/src/grading/selfcheck.ts @@ -1,6 +1,6 @@ // 自测:学生提交前自己跑一遍评分,告诉他哪几项不通过、为什么;不计分、不留记录。 // 每人同时只能有一次,结果放在内存里,保留到下一次自测(服务器重启后就没有了)。 -// 检查项和提交评分一样受全站并发上限(grader.ts 的 GRADE_CONCURRENCY)约束,机器忙时排队。 +// 检查项和提交评分一样受全站并发上限(config.gradeConcurrency)约束,机器忙时排队。 import type { SelfCheck, SourceFile } from '@iot/shared' import { rm } from 'node:fs/promises' import { join } from 'node:path' diff --git a/server/src/index.ts b/server/src/index.ts index 720eff8..ee4475a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,6 +1,7 @@ import type { ClientMessage, ServerMessage } from '@iot/shared' import { createAiQueue } from './ai-review' import { createApp } from './app' +import { backupTo } from './backup' import { ensureTeacher, tokenUser } from './auth' import { compileService } from './compile-service' import { languageService } from './language-service' @@ -13,6 +14,15 @@ import { cleanupStaleRuns, RunSession } from './run-session' import { sqlService } from './sql-service' import { checkSandbox, sandboxError } from './sandbox' +// 手动备份:bun server.js backup <目录>(deploy/backup.sh 调用),导出数据库后退出,不启动服务器 +if (process.argv[2] === 'backup') { + const target = process.argv[3] + if (!target) throw new Error('用法:backup <目录>') + await backupTo(target) + console.log(`已备份到 ${target}`) + process.exit(0) +} + await cleanupStaleRuns() await checkSandbox() if (sandboxError) console.error(sandboxError) diff --git a/server/test/api.test.ts b/server/test/api.test.ts index c1cd5b4..23a3fb5 100644 --- a/server/test/api.test.ts +++ b/server/test/api.test.ts @@ -499,6 +499,22 @@ describe('数据库页面', () => { expect((await new Browser().get('/api/db')).status).toBe(401) }, 60000) + test('删除学生时删除他的库:编号重新用给新学生时看不到前一个人的库', async () => { + const t = await teacher() + const classId = await classWithStudents(t, [{ username: 's1', name: '甲', password: 'p1' }]) + const s1 = new Browser() + await s1.login('s1', 'p1') + await s1.post('/api/db/query', { database: 'master', sql: 'CREATE DATABASE 甲的库' }) + const [{ id }] = (await t.get(`/api/teacher/classes/${classId}/students`)).body + await t.delete(`/api/teacher/students/${id}`) + + await t.post(`/api/teacher/classes/${classId}/import`, { rows: [{ username: 's2', name: '乙', password: 'p2' }] }) + expect((await t.get(`/api/teacher/classes/${classId}/students`)).body[0].id).toBe(id) + const s2 = new Browser() + await s2.login('s2', 'p2') + expect((await s2.get('/api/db')).body.databases).toEqual([]) + }, 60000) + test('恢复实验初始数据:只替换实验用到的库', async () => { const t = await teacher() await t.post('/api/db/query', { database: 'master', sql: 'CREATE DATABASE 我的库' }) diff --git a/server/test/backup.test.ts b/server/test/backup.test.ts new file mode 100644 index 0000000..7e1e5e7 --- /dev/null +++ b/server/test/backup.test.ts @@ -0,0 +1,46 @@ +// 手动备份:平台数据库和学生数据库都导出完整(正在使用、数据还在 -wal 文件里时也一样)。 +import { afterAll, beforeAll, expect, test } from 'bun:test' +import { Database } from 'bun:sqlite' +import { mkdir, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { backupTo } from '../src/backup' +import { config } from '../src/config' +import { openDatabase } from '../src/db' + +const saved = config.dataDir +const target = `${config.runsDir}/test-backup-copy` + +beforeAll(async () => { + config.dataDir = `${config.runsDir}/test-backup-data` + await rm(config.dataDir, { recursive: true, force: true }) + await rm(target, { recursive: true, force: true }) +}) + +afterAll(async () => { + await rm(config.dataDir, { recursive: true, force: true }) + await rm(target, { recursive: true, force: true }) + config.dataDir = saved +}) + +test('导出平台数据库和每个学生的数据库,不覆盖已有的备份', async () => { + // 平台和学生程序都开着库(WAL 模式,刚写入的数据还在 -wal 文件里) + const db = openDatabase() + db.query(`INSERT INTO users (username, name, password, role, created_at) VALUES ('s1', '学生', '123456', 'student', 0)`).run() + await mkdir(join(config.dataDir, 'databases', '7'), { recursive: true }) + const student = new Database(join(config.dataDir, 'databases', '7', 'WisdomCampus.db'), { create: true }) + student.run('PRAGMA journal_mode = WAL') + student.run('CREATE TABLE EnvironmentData (id INTEGER PRIMARY KEY, temperature REAL) STRICT') + student.run('INSERT INTO EnvironmentData (temperature) VALUES (25.5), (26)') + + await backupTo(target) + await expect(backupTo(target)).rejects.toThrow('已经有备份') + student.close() + db.close() + + const mainCopy = new Database(join(target, 'iot-sim.db'), { readonly: true }) + expect(mainCopy.query(`SELECT name FROM users WHERE username = 's1'`).get()).toEqual({ name: '学生' }) + mainCopy.close() + const studentCopy = new Database(join(target, 'databases', '7', 'WisdomCampus.db'), { readonly: true }) + expect(studentCopy.query('SELECT temperature FROM EnvironmentData ORDER BY id').all()).toEqual([{ temperature: 25.5 }, { temperature: 26 }]) + studentCopy.close() +}) diff --git a/server/test/classroom.ts b/server/test/classroom.ts new file mode 100644 index 0000000..c8ff24b --- /dev/null +++ b/server/test/classroom.ts @@ -0,0 +1,254 @@ +// 全班模拟:N 个虚拟学生像在课堂上一样使用平台,按环节统计每个学生等了多久。 +// 和 load.ts 不同,它从外面通过 HTTP / WebSocket 访问一台正在运行的服务器,经过登录、语言服务、编译、评分队列的全过程。 +// +// 环节(每个环节等全班做完再进入下一个,学生在 SPREAD 秒内陆续开始,像老师说"开始"之后的样子): +// 登录 → 打开作业并保存 → 边写边检查 → 运行(到窗体出现)→ 点按钮到实训台响应 → 自己检查 → 提交并等自动评分 +// +// 用法:bun test/classroom.ts [人数,默认 45] +// 环境变量:BASE(默认 http://localhost:3000)、TEACHER_USERNAME / TEACHER_PASSWORD(默认 admin / admin)、 +// SPREAD(秒,默认 10)、KEEP=1(结束后保留压测班级和账号)、 +// SAMPLE(实验,默认项目 3 任务 2;"点按钮"环节只对默认实验做)、CPUS(和服务器在同一台机器时填服务器用的核,如 0,1,每个环节显示这几个核的平均占用) +// 会建一个"压测班"和 N 个 loadtest 开头的学生账号,结束时删除。按服务器配置测试:taskset -c 0,1 bun src/index.ts +import type { ControlNode, ServerMessage, SourceFile } from '@iot/shared' + +const COUNT = Number(process.argv[2] ?? 45) +const BASE = process.env.BASE ?? 'http://localhost:3000' +const SPREAD_MS = Number(process.env.SPREAD ?? 10) * 1000 +const SAMPLE = process.env.SAMPLE ?? 'p3-t2-人体红外ZigBee联动' +const PASSWORD = 'loadtest-123' +const CPUS = process.env.CPUS?.split(',').map(Number) ?? [] + +/** /proc/stat 里这几个核的忙碌时间和总时间 */ +async function cpuTimes() { + const stat = await Bun.file('/proc/stat').text() + let busy = 0 + let total = 0 + for (const cpu of CPUS) { + const fields = new RegExp(`^cpu${cpu} (.*)$`, 'm').exec(stat)![1].trim().split(/\s+/).map(Number) + const idle = fields[3] + fields[4] + const sum = fields.reduce((a, b) => a + b, 0) + busy += sum - idle + total += sum + } + return { busy, total } +} + +class Client { + token = '' + + async request(method: string, path: string, json?: unknown) { + const headers: Record = {} + if (this.token) headers.authorization = `Bearer ${this.token}` + if (json !== undefined) headers['content-type'] = 'application/json' + const response = await fetch(`${BASE}${path}`, { method, headers, body: json === undefined ? undefined : JSON.stringify(json) }) + const body = (await response.json().catch(() => null)) as any + if (!response.ok) throw new Error(`${method} ${path}:${response.status} ${body?.message ?? ''}`) + return body + } + + async login(username: string, password: string) { + this.token = (await this.request('POST', '/api/login', { username, password })).accessToken + } +} + +function findId(node: ControlNode, name: string): string | undefined { + if (node.name === name) return node.id + for (const child of node.children) { + const id = findId(child, name) + if (id) return id + } +} + +/** 一个学生的运行窗口:WebSocket 连接 + 收到的消息 */ +class RunWindow { + private readonly ws: WebSocket + private listeners: ((m: ServerMessage) => void)[] = [] + form?: ControlNode + compileMs?: number + + constructor(token: string) { + this.ws = new WebSocket(`${BASE.replace(/^http/, 'ws')}/ws?token=${encodeURIComponent(token)}`) + this.ws.onmessage = (event) => { + const message = JSON.parse(String(event.data)) as ServerMessage + if (message.type === 'compiled') this.compileMs = message.ms + for (const listener of this.listeners) listener(message) + } + } + + opened() { + return new Promise((resolve, reject) => { + this.ws.onopen = () => resolve() + this.ws.onerror = () => reject(new Error('WebSocket 连接失败')) + }) + } + + send(message: object) { + this.ws.send(JSON.stringify(message)) + } + + /** 等到某条消息出现;出错消息直接失败 */ + waitFor(match: (m: ServerMessage) => boolean, timeoutMs = 60_000) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => done(new Error('超时')), timeoutMs) + const listener = (m: ServerMessage) => { + if (m.type === 'serverError' || m.type === 'exception') done(new Error(m.message)) + else if (m.type === 'compiled' && !m.ok) done(new Error('编译失败')) + else if (match(m)) done(null, m) + } + const done = (error: Error | null, m?: ServerMessage) => { + clearTimeout(timer) + this.listeners = this.listeners.filter((l) => l !== listener) + if (error) reject(error) + else resolve(m!) + } + this.listeners.push(listener) + }) + } + + action(name: string, action: object) { + this.send({ type: 'action', id: findId(this.form!, name)!, ...action }) + } + + close() { + this.ws.close() + } +} + +interface Student { + username: string + client: Client + files: SourceFile[] + window?: RunWindow +} + +const elapsed = async (work: () => Promise) => { + const start = performance.now() + await work() + return performance.now() - start +} + +/** 一个环节:每个学生在 SPREAD 内随机时刻开始,统计用时和失败 */ +async function phase(name: string, students: Student[], work: (s: Student) => Promise, spreadMs = SPREAD_MS) { + const times: number[] = [] + const errors = new Map() + const start = performance.now() + const cpuStart = CPUS.length ? await cpuTimes() : null + await Promise.all( + students.map(async (student) => { + await Bun.sleep(Math.random() * spreadMs) + try { + times.push(await elapsed(() => work(student))) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + errors.set(message, (errors.get(message) ?? 0) + 1) + } + }), + ) + times.sort((a, b) => a - b) + const cpuEnd = cpuStart && (await cpuTimes()) + const cpu = cpuStart && cpuEnd ? ` CPU ${Math.round(((cpuEnd.busy - cpuStart.busy) / (cpuEnd.total - cpuStart.total)) * 100)}%` : '' + const pct = (p: number) => (times.length ? `${(times[Math.min(times.length - 1, Math.floor((times.length * p) / 100))] / 1000).toFixed(1)}s` : '-') + console.log( + `${name.padEnd(12, ' ')} 成功 ${String(times.length).padStart(3)}/${students.length} 中位数 ${pct(50).padStart(6)} P95 ${pct(95).padStart(6)} 最长 ${pct(100).padStart(6)} 整个环节 ${((performance.now() - start) / 1000).toFixed(1)}s${cpu}`, + ) + for (const [message, count] of errors) console.log(` 失败 ${count} 次:${message}`) +} + +/** 每秒查一次,直到 done 返回 true */ +async function poll(read: () => Promise, timeoutMs = 600_000) { + const deadline = Date.now() + timeoutMs + while (!(await read())) { + if (Date.now() > deadline) throw new Error('超时') + await Bun.sleep(1000) + } +} + +// ---- 准备:老师建班、导入学生、布置作业 ---- +const teacher = new Client() +await teacher.login(process.env.TEACHER_USERNAME ?? 'admin', process.env.TEACHER_PASSWORD ?? 'admin') +const usernames = Array.from({ length: COUNT }, (_, i) => `loadtest${String(i + 1).padStart(3, '0')}`) +const { id: classId } = await teacher.request('POST', '/api/teacher/classes', { name: `压测班 ${new Date().toLocaleString('zh-CN')}` }) +await teacher.request('POST', `/api/teacher/classes/${classId}/import`, { + rows: usernames.map((username, i) => ({ username, name: `压测${i + 1}`, password: PASSWORD })), +}) +const checks: string[] = await teacher.request('GET', `/api/samples/${encodeURIComponent(SAMPLE)}/checks`) +const template: SourceFile[] = await teacher.request('GET', `/api/samples/${encodeURIComponent(SAMPLE)}`) +const assignment = await teacher.request('POST', '/api/teacher/assignments', { + classId, + sampleId: SAMPLE, + title: '压测', + startMode: 'ui', + template, + scoring: { auto: checks.map(() => 10), manual: [] }, + deadline: null, +}) +console.log(`${COUNT} 个学生,实验 ${SAMPLE},服务器 ${BASE},每个环节 ${SPREAD_MS / 1000} 秒内陆续开始\n`) + +const students: Student[] = usernames.map((username) => ({ username, client: new Client(), files: [] })) + +try { + await phase('登录', students, (s) => s.client.login(s.username, PASSWORD)) + + await phase('打开作业', students, async (s) => { + const { template } = await s.client.request('GET', `/api/assignments/${assignment.id}`) + s.files = template + await s.client.request('PUT', `/api/works/${encodeURIComponent(`assignment:${assignment.id}`)}`, { files: s.files }) + }) + + await phase('边写边检查', students, (s) => + s.client.request('POST', '/api/language', { type: 'diagnostics', session: 'tab', files: s.files }), + ) + + // 老师说"运行看看":全班 SPREAD 内先后按 F5 + await phase('运行到窗体出现', students, async (s) => { + const window = new RunWindow(s.client.token) + s.window = window + await window.opened() + const shown = window.waitFor((m) => m.type === 'formShown') + window.send({ type: 'run', files: s.files, sampleId: SAMPLE }) + window.form = ((await shown) as Extract).form + }) + + if (!process.env.SAMPLE) await phase('点按钮到实训台', students.filter((s) => s.window?.form), async (s) => { + const window = s.window! + window.action('com_lamp', { action: 'dropDown' }) + window.action('com_lamp', { action: 'select', index: 3 }) + window.action('btn_open', { action: 'click' }) + await Bun.sleep(300) + // 设备消息由服务器记进实训台状态,再以 board 消息推给浏览器 + const lit = window.waitFor((m) => m.type === 'board' && m.state.zigbee[1] === true, 30_000) + window.action('btn_lampOpen', { action: 'click' }) + await lit + }) + + // 程序都还开着,再自己检查一遍(评分进程和正在运行的程序抢 CPU) + await phase('自己检查', students, async (s) => { + await s.client.request('POST', `/api/assignments/${assignment.id}/selfcheck`, { files: s.files }) + let state: any + await poll(async () => (state = await s.client.request('GET', '/api/selfcheck'))?.status !== 'running') + // 参考答案应该全部通过;机器太忙时定时器检查可能超时误判 + const failed = state.result?.results.filter((r: any) => !r.passed) ?? [] + if (state.status !== 'done' || failed.length > 0) throw new Error(`自测没通过:${failed.map((r: any) => r.message).join(';') || state.status}`) + }) + + for (const s of students) s.window?.close() + + await phase('提交到评完', students, async (s) => { + await s.client.request('POST', `/api/assignments/${assignment.id}/submit`, { files: s.files }) + await poll(async () => { + const latest = await s.client.request('GET', `/api/assignments/${assignment.id}/submission`) + if (latest.status === 'error') throw new Error('评分出错') + if (latest.status === 'done' && latest.autoScore !== checks.length * 10) throw new Error(`参考答案只得了 ${latest.autoScore} 分`) + return latest.status === 'done' + }) + }) +} finally { + for (const s of students) s.window?.close() + if (!process.env.KEEP) { + await teacher.request('DELETE', `/api/teacher/assignments/${assignment.id}`) + for (const student of await teacher.request('GET', `/api/teacher/classes/${classId}/students`)) { + await teacher.request('DELETE', `/api/teacher/students/${student.id}`) + } + await teacher.request('DELETE', `/api/teacher/classes/${classId}`) + } +} diff --git a/server/test/grading.test.ts b/server/test/grading.test.ts index 61bad62..034bda4 100644 --- a/server/test/grading.test.ts +++ b/server/test/grading.test.ts @@ -89,7 +89,7 @@ describe('改坏的代码', () => { const dir = await prepare(id, 'camera', { file: 'Form1.cs', from: 'DeviceCtrlHelper.ipCameraHelper.ImageRead += ipCameraHelper_ImageRead;', to: '' }) const result = await grade(dir, await checksOf(id), id) expect(result.results.map((r) => r.passed)).toEqual([true, false, true, false]) - expect(result.results[1].message).toBe('期望3 秒内 pictureBox1 显示摄像机拍到的图像,实际没有图片') + expect(result.results[1].message).toBe('期望 pictureBox1 显示摄像机拍到的图像,实际没有图片') }, 60000) test('数据库:照书上 INSERT 不写列名,温度和光照存反了', async () => {