第 2 阶段:账号与班级,作品保存在服务器

- SQLite(bun:sqlite,编号迁移):班级、用户、会话(只存令牌哈希)、作品
- 登录取代统一访问口令:老师账号由配置设定;接口和 WebSocket 都要登录;按 IP 限制输错次数
- 班级管理页:建班、改名、删除(有学生时不能删);上传 CSV 名单导入(兼容 GBK / UTF-8,
  识别表头,标出被 Excel 变成科学计数法的学号),密码留空生成 6 位数字;查看、重置密码,
  导出带密码的名单
- 学生作品自动保存到服务器(按账号和实验),显示保存状态;离开页面前立即保存
- 前端加路由:登录页、实验页、班级管理页
- 部署:TEACHER_PASSWORD 必填,数据库放在数据卷

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-22 17:36:11 +08:00
1 parent a00622be15
commit 7cec273d58
31 files changed
+1776 -382

No files matched your search

-58
View File
@@ -1,58 +0,0 @@
// 访问口令:账号系统(第 2 阶段)做好之前,用一个统一口令挡住外人。
// 输对口令后发一个 cookie,值是口令的哈希;老师换口令后,旧 cookie 全部失效。
import type { Context } from 'hono'
import { setCookie } from 'hono/cookie'
import { config } from './config'
const COOKIE = 'iot_access'
// 全班通常共用学校的一个出口 IP,上限不能太小,否则几个同学输错就把全班挡在外面
const MAX_FAILURES = 30
const FAILURE_WINDOW_MS = 10 * 60_000
const token = config.accessPassword
? new Bun.CryptoHasher('sha256').update(`iot-sim-access:${config.accessPassword}`).digest('hex')
: ''
export const accessRequired = token !== ''
/** 请求是否带着有效的口令 cookie(没设口令时总是放行) */
export function hasAccess(cookieHeader: string | null) {
if (!accessRequired) return true
const value = cookieHeader
?.split(';')
.map((part) => part.trim().split('='))
.find(([name]) => name === COOKIE)?.[1]
return value === token
}
/** 每个 IP 一段时间内的失败次数,防止暴力猜口令 */
const failures = new Map<string, { count: number; since: number }>()
export function tooManyFailures(ip: string) {
const entry = failures.get(ip)
if (!entry) return false
if (Date.now() - entry.since > FAILURE_WINDOW_MS) {
failures.delete(ip)
return false
}
return entry.count >= MAX_FAILURES
}
/** 检查口令;对了就设置 cookie */
export function login(c: Context, password: string, ip: string) {
if (password !== config.accessPassword) {
const entry = failures.get(ip) ?? { count: 0, since: Date.now() }
entry.count++
failures.set(ip, entry)
return false
}
failures.delete(ip)
setCookie(c, COOKIE, token, {
httpOnly: true,
sameSite: 'Lax',
secure: new URL(c.req.url).protocol === 'https:' || c.req.header('x-forwarded-proto') === 'https',
path: '/',
maxAge: 180 * 24 * 3600,
})
return true
}
+130
View File
@@ -0,0 +1,130 @@
// HTTP 接口。除登录外都要先登录;/api/teacher/* 只有老师能用。
import type { Database } from 'bun:sqlite'
import { Hono, type Context } from 'hono'
import { deleteCookie, setCookie } from 'hono/cookie'
import { login, logout, sessionMaxAge, SESSION_COOKIE, sessionToken, sessionUser, tooManyFailures, type User } from './auth'
import { createClass, deleteClass, deleteStudent, importStudents, listClasses, listStudents, renameClass, resetPassword, type ImportRow } from './classes'
import { listSamples, loadSample } from './samples'
import { serveStatic } from './static'
import { deleteWork, getWork, saveWork, validFiles } from './works'
/** Bun.serve 的 server 对象(测试里直接调用 app.fetch 时没有) */
interface Env {
requestIP?: (req: Request) => { address: string } | null
}
type AppContext = Context<{ Bindings: Env; Variables: { user: User } }>
/** 客户端 IP:经过 Caddy 转发时由 Caddy 填在 X-Real-IP 里(它已核对过前面代理的 X-Forwarded-For) */
function clientIp(c: AppContext) {
return c.req.header('x-real-ip') || c.env?.requestIP?.(c.req.raw)?.address || ''
}
const isHttps = (c: AppContext) => new URL(c.req.url).protocol === 'https:' || c.req.header('x-forwarded-proto') === 'https'
/** 读取 JSON 请求体,格式不对时返回空对象 */
const body = <T>(c: AppContext) => c.req.json<Partial<T>>().catch(() => ({}) as Partial<T>)
export function createApp(db: Database) {
const app = new Hono<{ Bindings: Env; Variables: { user: User } }>()
app.post('/api/login', async (c) => {
const ip = clientIp(c)
if (tooManyFailures(ip)) return c.json({ message: '输错次数太多,请 10 分钟后再试' }, 429)
const { username, password } = await body<{ username: string; password: string }>(c)
const token = login(db, String(username ?? ''), String(password ?? ''), ip)
if (!token) return c.json({ message: '账号或密码不对' }, 401)
setCookie(c, SESSION_COOKIE, token, { httpOnly: true, sameSite: 'Lax', secure: isHttps(c), path: '/', maxAge: sessionMaxAge })
return c.json({ user: sessionUser(db, token) })
})
app.post('/api/logout', (c) => {
const token = sessionToken(c.req.header('cookie'))
if (token) logout(db, token)
deleteCookie(c, SESSION_COOKIE, { path: '/' })
return c.json({})
})
// 其余接口都要先登录
app.use('/api/*', async (c, next) => {
const user = sessionUser(db, sessionToken(c.req.header('cookie')))
if (!user) return c.json({ message: '请先登录' }, 401)
c.set('user', user)
await next()
})
app.get('/api/me', (c) => c.json({ user: c.get('user') }))
app.get('/api/samples', async (c) => c.json(await listSamples()))
app.get('/api/samples/:id', async (c) => {
const files = await loadSample(c.req.param('id'))
return files ? c.json(files) : c.notFound()
})
// 自己的作品:key 如 "sample:p2-t1-LED显示屏"
app.get('/api/works/:key', (c) => c.json(getWork(db, c.get('user').id, c.req.param('key'))))
app.put('/api/works/:key', async (c) => {
const { files } = await body<{ files: unknown }>(c)
if (!validFiles(files)) return c.json({ message: '作品格式不对或太大' }, 400)
return c.json({ updatedAt: saveWork(db, c.get('user').id, c.req.param('key'), files) })
})
app.delete('/api/works/:key', (c) => {
deleteWork(db, c.get('user').id, c.req.param('key'))
return c.json({})
})
// ---- 老师 ----
app.use('/api/teacher/*', async (c, next) => {
if (c.get('user').role !== 'teacher') return c.json({ message: '只有老师能使用' }, 403)
await next()
})
const id = (c: AppContext) => Number(c.req.param('id'))
const reply = (c: AppContext, result: { error?: string }) => (result.error ? c.json({ message: result.error }, 400) : c.json(result))
app.get('/api/teacher/classes', (c) => c.json(listClasses(db)))
app.post('/api/teacher/classes', async (c) => {
const { name } = await body<{ name: string }>(c)
const result = createClass(db, String(name ?? ''))
return 'error' in result ? c.json({ message: result.error }, 400) : c.json(result)
})
app.patch('/api/teacher/classes/:id', async (c) => {
const { name } = await body<{ name: string }>(c)
return reply(c, renameClass(db, id(c), String(name ?? '')))
})
app.delete('/api/teacher/classes/:id', (c) => reply(c, deleteClass(db, id(c))))
app.get('/api/teacher/classes/:id/students', (c) => c.json(listStudents(db, id(c))))
app.post('/api/teacher/classes/:id/import', async (c) => {
const { rows } = await body<{ rows: ImportRow[] }>(c)
if (!Array.isArray(rows) || rows.some((r) => typeof r?.username !== 'string' || typeof r?.name !== 'string')) {
return c.json({ message: '名单格式不对' }, 400)
}
if (!listClasses(db).some((cls) => cls.id === id(c))) return c.json({ message: '班级不存在' }, 404)
return c.json(importStudents(db, id(c), rows))
})
app.post('/api/teacher/students/:id/reset-password', (c) => {
const password = resetPassword(db, id(c))
return password ? c.json({ password }) : c.json({ message: '学生不存在' }, 404)
})
app.delete('/api/teacher/students/:id', (c) => {
deleteStudent(db, id(c))
return c.json({})
})
app.all('/api/*', (c) => c.json({ message: '接口不存在' }, 404))
// 打包好的网页(部署时);开发时网页由 Vite 提供
app.get('*', (c) => serveStatic(new URL(c.req.url).pathname))
return app
}
+105
View File
@@ -0,0 +1,105 @@
// 登录与会话:账号密码登录后发一个 HttpOnly cookie,数据库只存令牌的哈希。
// 老师账号只有一个,账号密码来自配置;学生账号由老师导入名单创建。
import type { Database } from 'bun:sqlite'
import { config } from './config'
export const SESSION_COOKIE = 'iot_session'
const SESSION_DAYS = 180
// 全班通常共用学校的一个出口 IP,上限不能太小,否则几个同学输错就把全班挡在外面
const MAX_FAILURES = 30
const FAILURE_WINDOW_MS = 10 * 60_000
export interface User {
id: number
username: string
name: string
role: 'student' | 'teacher'
classId: number | null
className: string | null
}
const hash = (token: string) => new Bun.CryptoHasher('sha256').update(token).digest('hex')
let warnedDefaultPassword = false
/** 服务器启动时按配置创建或更新老师账号 */
export function ensureTeacher(db: Database) {
let password = config.teacherPassword
if (!password) {
password = 'admin'
if (!warnedDefaultPassword) console.warn(`没有设置 TEACHER_PASSWORD,老师账号为 ${config.teacherUsername} / admin(只适合本机开发)`)
warnedDefaultPassword = true
}
db.query(
`INSERT INTO users (username, name, password, role, created_at) VALUES (?, '老师', ?, 'teacher', ?)
ON CONFLICT (username) DO UPDATE SET password = excluded.password, role = 'teacher', class_id = NULL`,
).run(config.teacherUsername, password, Date.now())
}
/** 每个 IP 一段时间内的失败次数,防止暴力猜密码 */
const failures = new Map<string, { count: number; since: number }>()
export function tooManyFailures(ip: string) {
const entry = failures.get(ip)
if (!entry) return false
if (Date.now() - entry.since > FAILURE_WINDOW_MS) {
failures.delete(ip)
return false
}
return entry.count >= MAX_FAILURES
}
/** 账号密码正确时创建会话,返回令牌(放进 cookie);不正确返回 null */
export function login(db: Database, username: string, password: string, ip: string): string | null {
const row = db.query<{ id: number; password: string }, [string]>('SELECT id, password FROM users WHERE username = ?').get(username.trim())
if (!row || row.password !== password) {
const entry = failures.get(ip) ?? { count: 0, since: Date.now() }
entry.count++
failures.set(ip, entry)
return null
}
failures.delete(ip)
const token = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('hex')
db.query('INSERT INTO sessions (token_hash, user_id, created_at) VALUES (?, ?, ?)').run(hash(token), row.id, Date.now())
return token
}
export function logout(db: Database, token: string) {
db.query('DELETE FROM sessions WHERE token_hash = ?').run(hash(token))
}
/** 删除某个用户的所有会话(重置密码、删除学生时,让他已登录的浏览器失效) */
export function endSessions(db: Database, userId: number) {
db.query('DELETE FROM sessions WHERE user_id = ?').run(userId)
}
/** 从 Cookie 请求头取出令牌 */
export function sessionToken(cookieHeader: string | null | undefined) {
return cookieHeader
?.split(';')
.map((part) => part.trim().split('='))
.find(([name]) => name === SESSION_COOKIE)?.[1]
}
/** 令牌对应的用户;没登录或会话已过期返回 null */
export function sessionUser(db: Database, token: string | undefined): User | null {
if (!token) return null
const row = db
.query<
{ id: number; username: string; name: string; role: User['role']; class_id: number | null; class_name: string | null; created_at: number },
[string]
>(
`SELECT u.id, u.username, u.name, u.role, u.class_id, c.name AS class_name, s.created_at
FROM sessions s JOIN users u ON u.id = s.user_id LEFT JOIN classes c ON c.id = u.class_id
WHERE s.token_hash = ?`,
)
.get(hash(token))
if (!row) return null
if (Date.now() - row.created_at > SESSION_DAYS * 86_400_000) {
logout(db, token)
return null
}
return { id: row.id, username: row.username, name: row.name, role: row.role, classId: row.class_id, className: row.class_name }
}
export const sessionMaxAge = SESSION_DAYS * 86_400
+141
View File
@@ -0,0 +1,141 @@
// 班级和学生名单(老师用)。学生账号由老师上传名单导入,密码由老师发放,学生不能自己改。
import type { Database } from 'bun:sqlite'
import { endSessions } from './auth'
export interface ClassInfo {
id: number
name: string
studentCount: number
}
export interface Student {
id: number
username: string
name: string
password: string
}
export interface ImportRow {
username: string
name: string
password?: string
}
export interface ImportResult {
created: number
updated: number
/** 有问题的行(行号从 1 开始,对应上传的名单),这些行没有导入 */
errors: { row: number; message: string }[]
}
/** 平台生成的初始密码:6 位数字,学生好输入;老师在名单里能看到 */
export function generatePassword() {
return String(crypto.getRandomValues(new Uint32Array(1))[0] % 1_000_000).padStart(6, '0')
}
export function listClasses(db: Database): ClassInfo[] {
return db
.query<ClassInfo, []>(
`SELECT c.id, c.name, COUNT(u.id) AS studentCount
FROM classes c LEFT JOIN users u ON u.class_id = c.id
GROUP BY c.id ORDER BY c.name`,
)
.all()
}
/** 新建班级;名字为空或重名时返回错误信息 */
export function createClass(db: Database, name: string): { id: number } | { error: string } {
name = name.trim()
if (!name) return { error: '班级名不能为空' }
if (db.query('SELECT 1 FROM classes WHERE name = ?').get(name)) return { error: `已经有叫"${name}"的班级` }
const result = db.query('INSERT INTO classes (name, created_at) VALUES (?, ?)').run(name, Date.now())
return { id: Number(result.lastInsertRowid) }
}
export function renameClass(db: Database, id: number, name: string): { error?: string } {
name = name.trim()
if (!name) return { error: '班级名不能为空' }
if (db.query('SELECT 1 FROM classes WHERE name = ? AND id <> ?').get(name, id)) return { error: `已经有叫"${name}"的班级` }
db.query('UPDATE classes SET name = ? WHERE id = ?').run(name, id)
return {}
}
/** 只能删除没有学生的班级,避免误删全班的作品 */
export function deleteClass(db: Database, id: number): { error?: string } {
if (db.query('SELECT 1 FROM users WHERE class_id = ?').get(id)) return { error: '班级里还有学生,先删除学生再删除班级' }
db.query('DELETE FROM classes WHERE id = ?').run(id)
return {}
}
export function listStudents(db: Database, classId: number): Student[] {
return db
.query<Student, [number]>(`SELECT id, username, name, password FROM users WHERE class_id = ? AND role = 'student' ORDER BY username`)
.all(classId)
}
/**
* 导入名单到一个班:账号不存在就新建,已存在就更新姓名、班级(以及填了的密码)。
* 密码留空时:新学生生成 6 位数字密码,老学生保持原密码。
*/
export function importStudents(db: Database, classId: number, rows: ImportRow[]): ImportResult {
const result: ImportResult = { created: 0, updated: 0, errors: [] }
const seen = new Set<string>()
const find = db.query<{ id: number; role: string }, [string]>('SELECT id, role FROM users WHERE username = ?')
const insert = db.query(`INSERT INTO users (username, name, password, role, class_id, created_at) VALUES (?, ?, ?, 'student', ?, ?)`)
const update = db.query('UPDATE users SET name = ?, class_id = ?, password = COALESCE(?, password) WHERE id = ?')
db.transaction(() => {
for (const [index, row] of rows.entries()) {
const fail = (message: string) => result.errors.push({ row: index + 1, message })
const username = row.username.trim()
const name = row.name.trim()
const password = row.password?.trim() || undefined
if (!username) {
fail('账号为空')
continue
}
if (/\s/.test(username)) {
fail(`账号"${username}"里有空格`)
continue
}
if (!name) {
fail(`账号"${username}"的姓名为空`)
continue
}
if (seen.has(username)) {
fail(`账号"${username}"在名单里重复`)
continue
}
seen.add(username)
const existing = find.get(username)
if (existing?.role === 'teacher') {
fail(`"${username}"是老师账号`)
continue
}
if (existing) {
update.run(name, classId, password ?? null, existing.id)
if (password) endSessions(db, existing.id)
result.updated++
} else {
insert.run(username, name, password ?? generatePassword(), classId, Date.now())
result.created++
}
}
})()
return result
}
/** 重置为新的 6 位数字密码,学生已登录的浏览器需要重新登录 */
export function resetPassword(db: Database, studentId: number): string | null {
const password = generatePassword()
const changed = db.query(`UPDATE users SET password = ? WHERE id = ? AND role = 'student'`).run(password, studentId).changes
if (!changed) return null
endSessions(db, studentId)
return password
}
/** 删除学生,连同他的作品和登录会话 */
export function deleteStudent(db: Database, studentId: number) {
db.query(`DELETE FROM users WHERE id = ? AND role = 'student'`).run(studentId)
}
+5 -2
View File
@@ -28,7 +28,10 @@ export const config = {
maxRuns: Number(env.SIM_MAX_RUNS ?? 150),
/** 一次运行最长多少分钟,到时自动结束 */
maxRunMinutes: Number(env.SIM_MAX_RUN_MINUTES ?? 60),
/** 访问口令;不设置表示不需要口令(本机开发) */
accessPassword: env.ACCESS_PASSWORD ?? '',
/** SQLite 数据库所在目录(账号、班级、学生作品);部署时放在数据卷里 */
dataDir: resolve(ROOT, env.DATA_DIR ?? 'data'),
/** 老师账号(只有一个)。开发时不设置密码就用 admin / admin */
teacherUsername: env.TEACHER_USERNAME ?? 'admin',
teacherPassword: env.TEACHER_PASSWORD ?? '',
timeZone: env.TZ ?? 'Asia/Shanghai',
}
+58
View File
@@ -0,0 +1,58 @@
// SQLite 数据库:账号、班级、登录会话、学生作品。
// 表结构用编号迁移:MIGRATIONS 只能在末尾追加,已执行过的不再改动。
import { Database } from 'bun:sqlite'
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { config } from './config'
const MIGRATIONS = [
`
CREATE TABLE classes (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL
);
-- 学生的密码由老师发放、老师可以查看,所以按原样保存(学生不能自己改)
CREATE TABLE users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
password TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('student', 'teacher')),
class_id INTEGER REFERENCES classes (id),
created_at INTEGER NOT NULL
);
CREATE INDEX users_class ON users (class_id);
-- 只保存令牌的哈希,数据库泄露也拿不到可用的登录令牌
CREATE TABLE sessions (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
created_at INTEGER NOT NULL
);
-- 学生在某个实验上的代码(自动保存),key 如 "sample:p2-t1-LED显示屏"
CREATE TABLE works (
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
key TEXT NOT NULL,
files TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_id, key)
);
`,
]
/** 打开数据库并执行未执行过的迁移。path 为 ":memory:" 时用内存数据库(测试用) */
export function openDatabase(path = join(config.dataDir, 'iot-sim.db')) {
if (path !== ':memory:') mkdirSync(config.dataDir, { recursive: true })
const db = new Database(path, { create: true, strict: true })
db.run('PRAGMA journal_mode = WAL')
db.run('PRAGMA foreign_keys = ON')
db.run('PRAGMA busy_timeout = 5000')
const version = db.query<{ user_version: number }, []>('PRAGMA user_version').get()!.user_version
for (let i = version; i < MIGRATIONS.length; i++) {
db.transaction(() => {
db.run(MIGRATIONS[i])
db.run(`PRAGMA user_version = ${i + 1}`)
})()
}
return db
}
+7 -38
View File
@@ -1,50 +1,19 @@
import { Hono } from 'hono'
import type { Server } from 'bun'
import type { ClientMessage, ServerMessage } from '@iot/shared'
import { accessRequired, hasAccess, login, tooManyFailures } from './access'
import { createApp } from './app'
import { ensureTeacher, sessionToken, sessionUser } from './auth'
import { compileService } from './compile-service'
import { config } from './config'
import { openDatabase } from './db'
import { cleanupStaleRuns, RunSession } from './run-session'
import { listSamples, loadSample } from './samples'
import { checkSandbox, sandboxError } from './sandbox'
import { serveStatic } from './static'
await cleanupStaleRuns()
await checkSandbox()
if (sandboxError) console.error(sandboxError)
const app = new Hono<{ Bindings: Server<SocketData> }>()
/** 客户端 IP:经过 Caddy 转发时由 Caddy 填在 X-Real-IP 里(它已核对过前面代理的 X-Forwarded-For) */
function clientIp(req: Request, server: Server<SocketData>) {
return req.headers.get('x-real-ip') || server.requestIP(req)?.address || ''
}
app.get('/api/auth', (c) => c.json({ required: accessRequired, ok: hasAccess(c.req.header('cookie') ?? null) }))
app.post('/api/login', async (c) => {
const ip = clientIp(c.req.raw, c.env)
if (tooManyFailures(ip)) return c.json({ ok: false, message: '输错次数太多,请 10 分钟后再试' }, 429)
const { password } = await c.req.json<{ password?: string }>().catch(() => ({ password: undefined }))
if (!login(c, String(password ?? ''), ip)) return c.json({ ok: false, message: '口令不对' }, 401)
return c.json({ ok: true })
})
// 其余接口都要先输入口令
app.use('/api/*', async (c, next) => {
if (!hasAccess(c.req.header('cookie') ?? null)) return c.json({ message: '请先输入访问口令' }, 401)
await next()
})
app.get('/api/samples', async (c) => c.json(await listSamples()))
app.get('/api/samples/:id', async (c) => {
const files = await loadSample(c.req.param('id'))
return files ? c.json(files) : c.notFound()
})
// 打包好的网页(部署时);开发时网页由 Vite 提供
app.get('*', (c) => serveStatic(new URL(c.req.url).pathname))
const db = openDatabase()
ensureTeacher(db)
const app = createApp(db)
interface SocketData {
session?: RunSession
@@ -54,7 +23,7 @@ const server = Bun.serve({
port: config.port,
fetch(req, server) {
if (new URL(req.url).pathname === '/ws') {
if (!hasAccess(req.headers.get('cookie'))) return new Response('请先输入访问口令', { status: 401 })
if (!sessionUser(db, sessionToken(req.headers.get('cookie')))) return new Response('请先登录', { status: 401 })
if (server.upgrade(req, { data: {} })) return
return new Response('WebSocket 升级失败', { status: 400 })
}
+38
View File
@@ -0,0 +1,38 @@
// 学生作品:每个人在每个实验上的代码,浏览器改动后自动保存,换电脑登录也能接着做。
import type { Database } from 'bun:sqlite'
import type { SourceFile } from '@iot/shared'
/** 一份作品最大字节数(全部文件内容合计),防止误传大文件 */
export const MAX_WORK_BYTES = 1_000_000
export function getWork(db: Database, userId: number, key: string): { files: SourceFile[]; updatedAt: number } | null {
const row = db
.query<{ files: string; updated_at: number }, [number, string]>('SELECT files, updated_at FROM works WHERE user_id = ? AND key = ?')
.get(userId, key)
return row ? { files: JSON.parse(row.files) as SourceFile[], updatedAt: row.updated_at } : null
}
export function saveWork(db: Database, userId: number, key: string, files: SourceFile[]) {
const updatedAt = Date.now()
db.query(
`INSERT INTO works (user_id, key, files, updated_at) VALUES (?, ?, ?, ?)
ON CONFLICT (user_id, key) DO UPDATE SET files = excluded.files, updated_at = excluded.updated_at`,
).run(userId, key, JSON.stringify(files), updatedAt)
return updatedAt
}
/** 删除作品(学生点"恢复原始代码") */
export function deleteWork(db: Database, userId: number, key: string) {
db.query('DELETE FROM works WHERE user_id = ? AND key = ?').run(userId, key)
}
/** 检查浏览器传来的文件列表:路径和内容都是字符串,总大小不超限 */
export function validFiles(value: unknown): value is SourceFile[] {
if (!Array.isArray(value) || value.length > 100) return false
let bytes = 0
for (const file of value) {
if (typeof file?.path !== 'string' || typeof file?.content !== 'string') return false
bytes += file.path.length + file.content.length
}
return bytes <= MAX_WORK_BYTES
}