Files
ClassHelper/packages/server/scripts/apply-column-migrations.cjs
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

128 lines
4.8 KiB
JavaScript
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.
/**
* 维护脚本:给"已有数据库"补齐结构性新增列(不依赖安装程序升级)。
*
* 为什么需要它:安装版服务端只在**库为空**时执行随包迁移 SQL(首次启动建表);
* 用安装程序覆盖升级时,升级逻辑会自动补齐新迁移。若像本地联调那样**手工覆盖 dist**
* 部署,就需要显式跑一次本脚本。
*
* 用法(用安装目录自带的 node 即可,无需 SDK):传入**已编译的 `lib/db.js`**,
* 直接复用服务端自己配置好的 Prisma 客户端(Prisma 7 需要 driver adapter,不能裸 new):
*
* node apply-column-migrations.cjs "<安装目录>/server/dist/lib/db.js"
*
* 环境变量(与服务端一致):
* DATABASE_PROVIDER=sqlite
* DATABASE_URL="file:<绝对路径>/classhelper.db"
*
* 幂等:已存在的列会跳过。
*/
const { pathToFileURL } = require('node:url');
const dbEntry = process.argv[2];
if (!dbEntry) {
console.error('用法:node apply-column-migrations.cjs <已编译的 lib/db.js 绝对路径>');
process.exit(2);
}
/** 需要保证存在的列(新增迁移时在这里追加一行) */
const COLUMNS = [
{
table: 'Schedule',
column: 'weekParity',
sql: `ALTER TABLE "Schedule" ADD COLUMN "weekParity" TEXT NOT NULL DEFAULT 'ALL'`,
note: '课表单双周(ALL/ODD/EVEN)',
},
{
table: 'Class',
column: 'termWeeks',
sql: `ALTER TABLE "Class" ADD COLUMN "termWeeks" INTEGER NOT NULL DEFAULT 20`,
note: '班级教学周数(班主任可调,默认 20)',
},
{
table: 'Class',
column: 'studentGradeQueryEnabled',
sql: `ALTER TABLE "Class" ADD COLUMN "studentGradeQueryEnabled" BOOLEAN NOT NULL DEFAULT true`,
note: 'ClassHelper 班级端按学号查成绩的开关(默认开启)',
},
{
table: 'Grade',
column: 'levelType',
sql: `ALTER TABLE "Grade" ADD COLUMN "levelType" TEXT NOT NULL DEFAULT 'percent'`,
note: '成绩等级口径:percent / letter / custom',
},
{
table: 'Grade',
column: 'level',
sql: `ALTER TABLE "Grade" ADD COLUMN "level" TEXT NOT NULL DEFAULT ''`,
note: '成绩等级文本(percent 下由得分率换算,可手改)',
},
{
table: 'IntegrationDevice',
column: 'lastHeartbeatAt',
sql: `ALTER TABLE "IntegrationDevice" ADD COLUMN "lastHeartbeatAt" DATETIME`,
note: 'ClassHelper 班级端最后一次心跳(在线判定看它)',
},
{
table: 'User',
column: 'phone',
sql: `ALTER TABLE "User" ADD COLUMN "phone" TEXT NOT NULL DEFAULT ''`,
note: '教师手机号',
},
];
/**
* ⚠ 本脚本只能补**新增的列**,补不了结构变化。
*
* 2026-10-06 那版「学生从账号变成名单记录」(`prisma/migrations/20261006120000_add_student_table`)
* 是一次真正的表重构:新建 `Student` / `StudentClassTransfer`、把 `Grade`/`HomeworkStatus`/
* `NotificationRead` 的外键从 `userId` 换到 `studentId`、删掉 `Enrollment` 与 `ClassTeacher`。
* 这些**必须走正式的迁移链路**(`prisma migrate deploy`,安装程序升级时自动执行):
*
* node apply-column-migrations.cjs "<安装目录>/server/dist/lib/db.js" # 只补列
* pnpm --filter @classhelper/server db:deploy # 结构变化走这条
*
* 只跑本脚本、不跑迁移的库会停在"列有了但表没换"的中间态 —— 下面的列已经能把
* 界面撑起来不报错,但学生数据仍留在 `User` 里,属于不可用状态。
*/
(async () => {
let prisma;
try {
const module = await import(pathToFileURL(dbEntry).href);
prisma = module.prisma ?? module.default?.prisma;
} catch (error) {
console.error(
'[migrate] 无法加载服务端的 Prisma 客户端:',
error && error.message ? error.message : error,
);
process.exitCode = 1;
return;
}
if (!prisma) {
console.error('[migrate] lib/db.js 里没有导出 prisma');
process.exitCode = 1;
return;
}
try {
for (const item of COLUMNS) {
const columns = await prisma.$queryRawUnsafe(`PRAGMA table_info("${item.table}")`);
if (columns.some((column) => column.name === item.column)) {
console.log(`[migrate] ${item.table}.${item.column} 已存在(跳过)`);
continue;
}
await prisma.$executeRawUnsafe(item.sql);
console.log(`[migrate] 已新增 ${item.table}.${item.column} —— ${item.note}`);
}
for (const table of [...new Set(COLUMNS.map((item) => item.table))]) {
const after = await prisma.$queryRawUnsafe(`PRAGMA table_info("${table}")`);
console.log(`[migrate] ${table} 列:${after.map((column) => column.name).join(', ')}`);
}
} catch (error) {
console.error('[migrate] 失败:', error && error.message ? error.message : error);
process.exitCode = 1;
} finally {
await prisma.$disconnect?.();
}
})();