学生、任课关系、周次口径三处长期靠约定维持的事情,改为由数据模型保证。 学生不是账号 - 拆出独立 Student 表,没有 passwordHash / role / 任何登录字段 - 「学生不能登录」由表结构保证,不再靠「登录前判 role 再 403」的代码分支 - 三张个人数据表外键 userId → studentId;删掉 Enrollment 与 ClassTeacher - 学生一律称「学号」、教师一律称「工号」,「学生账号」这个概念消失 任课关系唯一来源 = Course(班级 + 科目 + 教师) - 班主任 = Class.teacherId,科任 = Course.teacherId,同一人两处指向同一账号、不重复建号 - 作业与成绩按科目再收一道:挂科目只由该科任课老师改,没挂科目归班主任 / 管理员 - 新增「任课教师分配」与「班级教师批量导入」(带「使用工号匹配」开关) 成绩与名单 - 成绩等级:百分制 / 等级制 A·B·C·D / 自定义,支持单个与批量改等级 - 按学号查成绩明细(教师端 + 班级端 + 设备令牌),受按班开关控制 - 调班 / 转出(单个与批量)+ 可查历史;学号不变、历史作业成绩留原归属 - 毕业归档页(按年度记录毕业班级 / 毕业生 / 转出学生 / 该届通知与作业) - 学年升级(未毕业不归档、数据沿用);班级称呼改为「2026级1班」 ClassHelper 班级端在线状态 - Web 端显示 班级 / 设备名 / 设备号 / 在线离线 / 最后在线 / 最后心跳 - lastHeartbeatAt 驱动 60 秒在线窗口,与只表示「带状态上报」的 lastSeenAt 分开 学期周次 - 新增 TermWeek(classId 空串 = 全校默认),管理员可用日期范围选择器逐周配置 - 一键按开学日期生成整学期;联网拉取调休建议(只给建议、不改数据,无外网失败属正常) - 改周次后随课表下发给教室的 ClassHelper 班级端 课表方向反转 - 主方向改为「服务端 → 教室」自动下发;教室不再自动回传 - 取教室课表改为手动动作(点击前弹警告),插件按待办标记在下一次心跳补报 改名 - 班级小助手 → ClassHelper:界面、窗口标题、托盘、灵动岛、EXE、安装包、 安装目录、systemd 描述、插件清单与设置页、官网、文档站、README BREAKING CHANGE: 产品名与 EXE 名变更(班级小助手.exe → ClassHelper.exe), 安装目录随之改变,不支持覆盖安装,需先卸载旧版。 服务端首次启动会自动应用 6 个数据库迁移(学生行按 id 原样搬迁,历史数据不丢), 升级前请先备份。
170 lines
6.3 KiB
TypeScript
170 lines
6.3 KiB
TypeScript
import type { PrismaClient } from '../generated/prisma/client.js';
|
||
import type { DatabaseProvider } from './db.js';
|
||
|
||
/**
|
||
* 全库 JSON 快照:备份 / 恢复 / 导入导出 / 跨库迁移共用这一种格式。
|
||
*
|
||
* 为什么不用「复制数据库文件」或 mysqldump:
|
||
* - 文件级备份只对 SQLite 有意义,跨库迁移根本用不上;
|
||
* - JSON 快照与 provider 无关,一份实现同时服务备份、导入导出和 SQLite⇄MySQL 迁移。
|
||
*
|
||
* 外键注意:`Class.teacherId → User` 与 `Student.classId → Class` 构成一条链,
|
||
* 而 `Class.archivedYearId` / `Student.archivedYearId → ArchivedYear` 又是另一条;
|
||
* 恢复时必须**临时关闭外键检查**再按序写入,否则任何排序都会被其中一侧卡死。
|
||
* - SQLite:PRAGMA foreign_keys=OFF(libsql 连接级,进程内安全)
|
||
* - MySQL:SET FOREIGN_KEY_CHECKS=0/1(会话级,连接池下同一客户端实例内生效)
|
||
*/
|
||
|
||
export const SNAPSHOT_FORMAT = 'classhelper-snapshot';
|
||
export const SNAPSHOT_VERSION = 1;
|
||
|
||
/**
|
||
* 导出与恢复顺序(外键关闭后顺序不再影响写入,但保持拓扑序便于人工阅读与排查)。
|
||
*
|
||
* **学生不是账号**:`Student` 是独立的一张表(没有密码、没有 role),
|
||
* `Grade` / `HomeworkStatus` / `NotificationRead` 都按 `studentId` 指向它;
|
||
* `StudentClassTransfer` 存调班与转出历史;`ArchivedYear` 是毕业归档的届别档案。
|
||
* 历史上那两张 `ClassTeacher`(无科目的协作关系)与 `Enrollment`(与 Student.classId 双写)
|
||
* 已随 2026-10-06 的重构删除。
|
||
*/
|
||
export const SNAPSHOT_TABLES = [
|
||
'User',
|
||
'ArchivedYear',
|
||
'Class',
|
||
'Student',
|
||
'Course',
|
||
'StudentClassTransfer',
|
||
'Schedule',
|
||
'TimeLayout',
|
||
'Homework',
|
||
'HomeworkStatus',
|
||
'Notification',
|
||
'NotificationRead',
|
||
'Grade',
|
||
'IntegrationDevice',
|
||
'ClassIslandPush',
|
||
] as const;
|
||
|
||
export type SnapshotTable = (typeof SNAPSHOT_TABLES)[number];
|
||
|
||
export interface DatabaseSnapshot {
|
||
format: string;
|
||
version: number;
|
||
createdAt: string;
|
||
source: { provider: string };
|
||
/** 每张表的行数(导出时统计,恢复时校验总量用) */
|
||
counts: Record<string, number>;
|
||
/** 表名 → 行数组(DateTime 已是 ISO 字符串,Prisma 接受 ISO 字符串作为 DateTime 输入) */
|
||
data: Record<SnapshotTable, Record<string, unknown>[]>;
|
||
}
|
||
|
||
/** 模型代理的最小接口(避免 any:Prisma 各模型的 delegate 形状一致) */
|
||
interface ModelDelegate {
|
||
findMany(): Promise<Record<string, unknown>[]>;
|
||
count(): Promise<number>;
|
||
createMany(args: { data: Record<string, unknown>[] }): Promise<unknown>;
|
||
deleteMany(): Promise<unknown>;
|
||
}
|
||
|
||
function delegatesOf(prisma: PrismaClient): Record<SnapshotTable, ModelDelegate> {
|
||
return prisma as unknown as Record<SnapshotTable, ModelDelegate>;
|
||
}
|
||
|
||
/** 单次 createMany 的批量大小(班级规模数据量小,小批次足够且省内存) */
|
||
const CHUNK_SIZE = 200;
|
||
|
||
/** 导出全库快照(行内 DateTime 为 Date 对象,JSON.stringify 时自动转 ISO) */
|
||
export async function exportSnapshot(
|
||
prisma: PrismaClient,
|
||
provider: DatabaseProvider,
|
||
): Promise<DatabaseSnapshot> {
|
||
const delegates = delegatesOf(prisma);
|
||
const data = {} as Record<SnapshotTable, Record<string, unknown>[]>;
|
||
const counts: Record<string, number> = {};
|
||
for (const table of SNAPSHOT_TABLES) {
|
||
const rows = await delegates[table].findMany();
|
||
data[table] = rows;
|
||
counts[table] = rows.length;
|
||
}
|
||
return {
|
||
format: SNAPSHOT_FORMAT,
|
||
version: SNAPSHOT_VERSION,
|
||
createdAt: new Date().toISOString(),
|
||
source: { provider },
|
||
counts,
|
||
data,
|
||
};
|
||
}
|
||
|
||
/** 快照结构校验:给出可读的失败原因("为什么恢复失败"比失败本身重要) */
|
||
export function assertValidSnapshot(value: unknown): asserts value is DatabaseSnapshot {
|
||
if (typeof value !== 'object' || value === null) {
|
||
throw new Error('快照内容不是合法的 JSON 对象');
|
||
}
|
||
const snapshot = value as Partial<DatabaseSnapshot>;
|
||
if (snapshot.format !== SNAPSHOT_FORMAT) {
|
||
throw new Error(`快照格式不正确(期望 ${SNAPSHOT_FORMAT},收到 ${String(snapshot.format ?? '未知')})`);
|
||
}
|
||
if (snapshot.version !== SNAPSHOT_VERSION) {
|
||
throw new Error(`快照版本不兼容(期望 v${SNAPSHOT_VERSION},收到 v${String(snapshot.version)})`);
|
||
}
|
||
if (typeof snapshot.data !== 'object' || snapshot.data === null) {
|
||
throw new Error('快照缺少 data 字段');
|
||
}
|
||
for (const table of SNAPSHOT_TABLES) {
|
||
const rows = (snapshot.data as Record<string, unknown>)[table];
|
||
if (!Array.isArray(rows)) {
|
||
throw new Error(`快照缺少表 ${table} 的数据`);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function setForeignKeys(prisma: PrismaClient, provider: DatabaseProvider, enabled: boolean): Promise<void> {
|
||
const sql =
|
||
provider === 'mysql'
|
||
? `SET FOREIGN_KEY_CHECKS=${enabled ? 1 : 0}`
|
||
: `PRAGMA foreign_keys=${enabled ? 'ON' : 'OFF'}`;
|
||
await prisma.$executeRawUnsafe(sql);
|
||
}
|
||
|
||
/**
|
||
* 把快照写入目标库(先清空再写入)。
|
||
* 用于:备份恢复、快照导入、跨库迁移的子进程。
|
||
*/
|
||
export async function restoreSnapshot(
|
||
prisma: PrismaClient,
|
||
provider: DatabaseProvider,
|
||
snapshot: DatabaseSnapshot,
|
||
options: { clean?: boolean } = {},
|
||
): Promise<{ counts: Record<string, number> }> {
|
||
assertValidSnapshot(snapshot);
|
||
const delegates = delegatesOf(prisma);
|
||
const clean = options.clean !== false;
|
||
const counts: Record<string, number> = {};
|
||
|
||
await setForeignKeys(prisma, provider, false);
|
||
try {
|
||
if (clean) {
|
||
// 反向清空,保证被引用的表最后清(外键关闭后只是防御性的,保持确定性)
|
||
for (const table of [...SNAPSHOT_TABLES].reverse()) {
|
||
await delegates[table].deleteMany();
|
||
}
|
||
}
|
||
for (const table of SNAPSHOT_TABLES) {
|
||
const rows = snapshot.data[table];
|
||
for (let index = 0; index < rows.length; index += CHUNK_SIZE) {
|
||
await delegates[table].createMany({ data: rows.slice(index, index + CHUNK_SIZE) });
|
||
}
|
||
counts[table] = rows.length;
|
||
}
|
||
} finally {
|
||
await setForeignKeys(prisma, provider, true).catch(() => undefined);
|
||
}
|
||
return { counts };
|
||
}
|
||
|
||
/** 汇总快照总行数(列表展示 / 切换任务结果用) */
|
||
export function snapshotTotalRows(snapshot: DatabaseSnapshot): number {
|
||
return Object.values(snapshot.counts).reduce((sum, count) => sum + count, 0);
|
||
}
|