- 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 <noreply@anthropic.com>
88 lines
4.0 KiB
TypeScript
88 lines
4.0 KiB
TypeScript
// 学生的数据库:每个用户一个目录(data/databases/<用户编号>),每个库一个 SQLite 文件,长期保存,
|
|
// 就像学生自己电脑上的 SQL Server。学生程序运行时挂进沙箱可读写;数据库页面通过 SQL 服务访问。
|
|
// 评分时不用学生自己的库,而是按实验的 database.sql 新建一套干净的库。
|
|
import { cp, mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises'
|
|
import { join, resolve } from 'node:path'
|
|
import { config } from './config'
|
|
import { sqlService } from './sql-service'
|
|
|
|
/** 用户的数据库目录(不存在时创建) */
|
|
export async function userDatabaseDir(userId: number) {
|
|
const dir = resolve(config.dataDir, 'databases', String(userId))
|
|
await mkdir(dir, { recursive: true })
|
|
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<string | undefined> {
|
|
const file = Bun.file(join(config.samplesDir, sampleId, 'database.sql'))
|
|
return (await file.exists()) ? file.text() : undefined
|
|
}
|
|
|
|
/** 按脚本建库:先清空目录再执行(脚本里 CREATE DATABASE、CREATE TABLE、INSERT 都可以写) */
|
|
export async function runScript(dir: string, script: string) {
|
|
await mkdir(dir, { recursive: true })
|
|
await sqlService.request({ type: 'script', dir, sql: script })
|
|
}
|
|
|
|
/**
|
|
* 把学生某个实验用到的库恢复成实验的初始数据:只替换脚本里建的库,学生的其他库不动。
|
|
* onlyMissing 时只建学生还没有的库(第一次运行实验时自动建库,已有的库保留学生的数据)。
|
|
* 返回恢复了哪些库。
|
|
*/
|
|
export async function restoreExperimentDatabases(userId: number, sampleId: string, onlyMissing = false) {
|
|
const script = await experimentScript(sampleId)
|
|
if (!script) return []
|
|
const target = await userDatabaseDir(userId)
|
|
const existing = new Set(await readdir(target))
|
|
if (onlyMissing && scriptDatabases(script).every((name) => existing.has(`${name}.db`))) return []
|
|
const temp = await mkdtemp(join(config.runsDir, 'db-'))
|
|
try {
|
|
await runScript(temp, script)
|
|
const files = (await readdir(temp)).filter((f) => f.endsWith('.db') && f !== 'master.db' && !(onlyMissing && existing.has(f)))
|
|
for (const file of files) {
|
|
for (const suffix of ['', '-wal', '-shm']) await rm(join(target, file + suffix), { force: true })
|
|
await cp(join(temp, file), join(target, file))
|
|
}
|
|
return files.map((f) => f.slice(0, -3))
|
|
} finally {
|
|
await rm(temp, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
/** 脚本里 CREATE DATABASE 建的库名 */
|
|
function scriptDatabases(script: string) {
|
|
return [...script.matchAll(/^\s*CREATE\s+DATABASE\s+\[?([^\s\]]+)\]?/gim)].map((m) => m[1])
|
|
}
|
|
|
|
/** 评分用:实验数据库的模板按实验缓存,每个检查项复制一份(互不影响) */
|
|
const templates = new Map<string, Promise<string | undefined>>()
|
|
|
|
export async function gradingDatabaseDir(sampleId: string): Promise<string | undefined> {
|
|
let template = templates.get(sampleId)
|
|
if (!template) {
|
|
template = (async () => {
|
|
const script = await experimentScript(sampleId)
|
|
if (script === undefined) return undefined
|
|
const dir = resolve(config.runsDir, 'db-templates', sampleId)
|
|
await rm(dir, { recursive: true, force: true })
|
|
await runScript(dir, script)
|
|
return dir
|
|
})()
|
|
templates.set(sampleId, template)
|
|
template.catch(() => templates.delete(sampleId))
|
|
}
|
|
const source = await template
|
|
const copy = await mkdtemp(join(config.runsDir, 'grade-db-'))
|
|
if (source && (await stat(source).catch(() => null))) await cp(source, copy, { recursive: true })
|
|
return copy
|
|
}
|