Files
ClassHelper/packages/server/src/lib/snapshot.ts
T
LoveChengke 9d5ac7adf9 feat(shared,server,web,client,plugin)!: 角色与权限重构 + 学期周次 + 课表方向反转(1.2.0)
学生、任课关系、周次口径三处长期靠约定维持的事情,改为由数据模型保证。

学生不是账号
- 拆出独立 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 原样搬迁,历史数据不丢),
升级前请先备份。
2026-10-06 19:41:18 +08:00

170 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}