- 访问令牌:JWT(HS256),15 分钟,Authorization: Bearer;WebSocket 放在网址参数里 - 刷新令牌:30 天,每次刷新换新的,数据库只存哈希;退出登录即作废 - 重置密码、删除学生:令牌版本加一并删除刷新令牌,所有设备立即退出 - 签名密钥首次使用时生成,存在数据库 settings 表 - 浏览器:令牌存 localStorage;快过期先刷新,401 时刷新一次再重试;多个标签页同时刷新不冲突 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
142 lines
5.1 KiB
TypeScript
142 lines
5.1 KiB
TypeScript
// 班级和学生名单(老师用)。学生账号由老师上传名单导入,密码由老师发放,学生不能自己改。
|
|
import type { Database } from 'bun:sqlite'
|
|
import { revokeTokens } 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) revokeTokens(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
|
|
revokeTokens(db, studentId)
|
|
return password
|
|
}
|
|
|
|
/** 删除学生,连同他的作品 */
|
|
export function deleteStudent(db: Database, studentId: number) {
|
|
db.query(`DELETE FROM users WHERE id = ? AND role = 'student'`).run(studentId)
|
|
}
|