Linux 交付链(新增)
- deploy/install.sh:一键安装器(CLASSHELPER 大字 TUI、native/docker 双形态、向导选安装目录/
端口/数据库/初始管理员密码);识别 apt/yum/dnf 只补缺的依赖、建独立系统用户、下载内置 Node
运行时(glibc < 2.28 的 CentOS 7 自动改用 unofficial-builds 的 glibc-217 构建)、幂等覆盖安装
(保留 config.env 与 data/,不重置密钥)、全程写 /var/log/classhelper/install.log;
`--check` 只体检(不需要 root)、`--yes` 无人值守、`--package` 支持内网离线分发。
- deploy/classhelper:运维命令 —— status / doctor / password / backup(list,restore,schedule) /
upgrade / rollback / start|stop|restart|reload / logs / config / port / key rotate / uninstall /
completion / help。特权操作一律校验 EUID,关键动作写 /var/log/classhelper/audit.log。
- deploy/tools/admin-cli.mjs:离线账号工具(改密、列账号、VACUUM INTO 一致性快照、哈希回滚),
直连数据库 —— 服务停机也能救急,不需要先有一个能登录的账号。
- deploy/{classhelper.service,profile.d,logrotate.d,systemd}/ 模板 + Dockerfile.linux-package。
- scripts/dist-server.mjs 增加 --platform linux(根脚本 `pnpm dist:server:linux`),产物
classhelper-server-linux-x64-<版本>.tar.gz + 同名 .sha256;新增 GitHub Actions workflow 在
ubuntu runner 上构建并挂 Release(@libsql/linux-* 这类平台相关依赖只能在 Linux 上装)。
- docs/linux-deploy.md:安装 / 运维命令 / 升级回滚 / 备份恢复 / 排障 / 安全清单 /
Ubuntu 22.04 与 Rocky 9 的验收清单。deploy/install-linux.sh 改为转发壳,不留两份实现。
三处必须知道的取舍(已写进 AGENTS.md §5 第 59 条)
- 配置真身在 /etc/classhelper/config.env,安装目录里的 .env 是指向它的软链:Web 端
「数据库管理 → 一键切换」改写的是 <安装目录>/.env,而 systemd 注入的是同一份配置 ——
两处不是同一个文件时,切换会「看起来成功、重启后又连回旧库」。classhelper doctor 有专门断言。
- 升级/回滚替换程序文件用「改名 + 就位」而不是 rm+cp:升级时正在运行的就是 bin/classhelper
自己,直接删掉同名文件会让 bash 读到半个脚本。
- 交叉构建(在非 Linux 机器上打 Linux 包)会在包内写 .cross-built 标记、doctor 据此提示:
已核对 @libsql/linux-x64-gnu 与 -musl 就位且无 win32 残留,但 prisma CLI 的原生 schema engine
是宿主平台的,Web 端「数据库一键切换」那一步存疑;正式产物一律走 CI。
更新检查(此前只在工作区,本次一并提交)
- 服务端 modules/update:由服务端转发 GitHub Releases(Web 端 CSP 不允许直连),离线/限流/超时
一律收敛成 ok:false + 一句人话,失败结果同样进缓存;?force=1 只对管理员生效(避免刷爆匿名限流)。
- 版本比较放 @classhelper/shared(compareVersions / isNewerVersion),三端共用;服务端版本号
改由 lib/version.ts 读 package.json,不再在 app.ts 里写字面量。
- Web 端顶栏「检查更新」弹窗、客户端「关于」页的检查更新与诊断信息。
Web 端 / 客户端
- 成绩图表改用自包含的轻量组件(ScoreBarChart / ScoreLineChart),不再全量引入 echarts
(原先 echarts 占桌面端渲染产物 1.12MB、Web 端 1.13MB);柱状图是纯 HTML/CSS,折线图是
viewBox SVG,均无 resize 监听。
- Element Plus 图标改为按需注册(两端各一份 icons.ts),新增 scripts/check-icons.mjs 静态门禁
并挂进两端 build —— 漏注册的后果是界面静默渲染空白,只在控制台一条 warning。
版本号统一 1.1.0
- 5 份 package.json、deploy/package.runtime.json、.env.example 的 APP_VERSION、
docker-compose 与两个 Dockerfile 的镜像标签、Linux 安装包名示例、README 首页版本;
- ClassIsland 插件 manifest.yml 与 BridgeService.PluginVersion 同步(verify:classisland-plugin
有 manifest ↔ code 的一致性断言);
- 刻意保持不变的是**历史性表述与举例**:docs/mysql.md 的「自 v1.0.0 起内置 MySQL 适配器」、
shared/utils.ts 讲版本比较用的 1.0.0 / 1.0.0-fix、README 验收表里的实测记录。
336 lines
14 KiB
JavaScript
336 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 班级小助手 · 离线账号工具(随 Linux 包发布,安装在 ${INSTALL_DIR}/tools/admin-cli.mjs)
|
||
*
|
||
* 为什么不用 HTTP API 改密码:`classhelper password` 要能在**服务停机**时救急,
|
||
* 而调 API 必须先有一个能登录的账号 —— 管理员忘了密码时就是死锁。
|
||
* 因此这里直连数据库,复用服务端自己的 Prisma Client 与 bcrypt 参数(`server/dist/lib/*`),
|
||
* 保证写入的哈希与登录校验用的是同一套实现。
|
||
*
|
||
* 安全约定(务必保持):
|
||
* - **密码只从 stdin 读**,绝不接受 argv / 环境变量形式的密码(否则会进 shell history 与 ps 输出);
|
||
* - 本工具自己**不打印任何密码**,也不写任何日志;
|
||
* - `dump-hash` 会输出 bcrypt 哈希 —— 仅由 root 调用的 `classhelper password` 用于改前备份,
|
||
* 调用方负责把结果落到 600 权限的文件里。
|
||
*
|
||
* 用法(全部由 classhelper 封装,一般不需要手工调):
|
||
* node tools/admin-cli.mjs info
|
||
* node tools/admin-cli.mjs list [--json]
|
||
* node tools/admin-cli.mjs dump-hash <用户名|class:班级码>
|
||
* node tools/admin-cli.mjs set-password <用户名> # 新密码从 stdin 读
|
||
* node tools/admin-cli.mjs set-class-password <班级码> # 新密码从 stdin 读
|
||
* node tools/admin-cli.mjs verify <用户名> # 待校验密码从 stdin 读
|
||
* node tools/admin-cli.mjs restore-hash <备份json路径>
|
||
* node tools/admin-cli.mjs snapshot <目标db文件> # SQLite 一致性快照(VACUUM INTO)
|
||
*
|
||
* 退出码:0 成功 / 1 通用失败 / 2 用法错误 / 3 目标不存在 / 4 密码不合规 / 5 数据库不可用
|
||
*/
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||
|
||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||
/** 安装根目录(tools/ 的上一级) */
|
||
const installDir = path.resolve(scriptDir, '..');
|
||
const serverDist = path.join(installDir, 'server', 'dist');
|
||
|
||
const EXIT = { OK: 0, FAIL: 1, USAGE: 2, NOT_FOUND: 3, WEAK: 4, NO_DB: 5 };
|
||
|
||
function fail(message, code = EXIT.FAIL) {
|
||
process.stderr.write(`admin-cli: ${message}\n`);
|
||
process.exitCode = code;
|
||
return null;
|
||
}
|
||
|
||
/** 一次性读完 stdin(去掉行尾换行;保留内部空白,密码里可以有空格) */
|
||
async function readSecretFromStdin() {
|
||
const chunks = [];
|
||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||
return Buffer.concat(chunks).toString('utf8').replace(/\r?\n$/, '');
|
||
}
|
||
|
||
/** 与服务端一致的密码强度规则(classhelper 里还有一份交互式版本,两边保持同样口径) */
|
||
function checkStrength(password, username) {
|
||
const problems = [];
|
||
if (password.length < 8) problems.push('长度至少 8 位');
|
||
if (password.length > 128) problems.push('长度不要超过 128 位');
|
||
const kinds = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((re) => re.test(password)).length;
|
||
if (kinds < 3) problems.push('需包含小写字母 / 大写字母 / 数字 / 符号 中至少 3 类');
|
||
if (username && password.toLowerCase() === username.toLowerCase()) problems.push('不能与用户名相同');
|
||
if (/^(123456|password|admin|classhelper|qwerty|111111|abc123)/i.test(password) && password.length < 12) {
|
||
problems.push('过于常见,请换一个');
|
||
}
|
||
return problems;
|
||
}
|
||
|
||
/** 懒加载服务端模块:先做"库在不在"的检查,避免 libSQL 在连接时凭空建出一个空库 */
|
||
async function loadServerModules() {
|
||
const envFile = path.join(serverDist, 'config', 'env.js');
|
||
if (!fs.existsSync(envFile)) {
|
||
fail(`找不到服务端产物:${envFile}`, EXIT.NO_DB);
|
||
return null;
|
||
}
|
||
const env = await import(pathToFileURL(envFile).href);
|
||
if (env.env.databaseProvider === 'sqlite') {
|
||
// classhelper 做备份时会用 CLASSHELPER_SNAPSHOT_DB 显式指定数据文件(跳过硬编码路径假设)
|
||
const dbFile = process.env.CLASSHELPER_SNAPSHOT_DB || env.env.sqliteFilePath;
|
||
if (!dbFile || !fs.existsSync(dbFile)) {
|
||
fail(
|
||
`数据库文件不存在:${dbFile || '(未配置)'}\n` +
|
||
' 提示:全新安装请先 systemctl start classhelper 完成建表(AUTO_MIGRATE);',
|
||
EXIT.NO_DB,
|
||
);
|
||
return null;
|
||
}
|
||
}
|
||
const db = await import(pathToFileURL(path.join(serverDist, 'lib', 'db.js')).href);
|
||
const password = await import(pathToFileURL(path.join(serverDist, 'lib', 'password.js')).href);
|
||
return { env: env.env, prisma: db.prisma, disconnectPrisma: db.disconnectPrisma, ...password };
|
||
}
|
||
|
||
/** 目标账号:普通用户按 username,班级账号按 Class.code(学生端登录用的是班级码) */
|
||
async function resolveTarget(prisma, key) {
|
||
if (key.startsWith('class:')) {
|
||
const code = key.slice('class:'.length).toUpperCase();
|
||
const klass = await prisma.class.findUnique({ where: { code } });
|
||
if (!klass) return { error: `班级码不存在:${code}`, code: EXIT.NOT_FOUND };
|
||
return { kind: 'class', id: klass.id, label: `班级 ${klass.name}(码 ${klass.code})`, hash: klass.passwordHash };
|
||
}
|
||
const user = await prisma.user.findUnique({ where: { username: key } });
|
||
if (!user) return { error: `用户名不存在:${key}`, code: EXIT.NOT_FOUND };
|
||
if (user.role === 'STUDENT') {
|
||
return {
|
||
error: `学生没有个人账号(${key} 只是名单记录),学生端用「班级码 + 班级密码」登录,请用 --class 改班级密码`,
|
||
code: EXIT.NOT_FOUND,
|
||
};
|
||
}
|
||
return { kind: 'user', id: user.id, label: `${user.name}(${user.username} / ${user.role})`, hash: user.passwordHash };
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ 子命令 */
|
||
|
||
/**
|
||
* SQLite 一致性快照:`VACUUM INTO` 在服务运行中也能拿到自洽的副本
|
||
* (直接 cp 数据文件可能抓到写入中的中间状态)。
|
||
* 目标文件必须不存在 —— 这是 SQLite 的硬要求,先删掉。
|
||
*/
|
||
async function cmdSnapshot(modules, dest) {
|
||
const { env, prisma } = modules;
|
||
if (env.databaseProvider !== 'sqlite') {
|
||
return fail('snapshot 只支持 SQLite;MySQL 请用 mysqldump', EXIT.FAIL);
|
||
}
|
||
const target = path.resolve(dest);
|
||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||
fs.rmSync(target, { force: true });
|
||
// VACUUM INTO 不接受参数占位符,只能拼字面量 —— 单引号按 SQL 规则翻倍转义
|
||
await prisma.$executeRawUnsafe(`VACUUM INTO '${target.replace(/'/g, "''")}'`);
|
||
if (!fs.existsSync(target)) return fail('快照未生成(VACUUM INTO 未报错但没有产出文件)', EXIT.FAIL);
|
||
process.stdout.write(`${target}\n`);
|
||
}
|
||
|
||
async function cmdInfo(modules) {
|
||
const { env, prisma } = modules;
|
||
const counts = {};
|
||
for (const model of ['user', 'class', 'course', 'schedule', 'homework', 'notification', 'grade']) {
|
||
counts[model] = await prisma[model].count();
|
||
}
|
||
const payload = {
|
||
provider: env.databaseProvider,
|
||
sqliteFile: env.sqliteFilePath || null,
|
||
serverRoot: env.serverRoot,
|
||
termStartDate: env.termStartDate || null,
|
||
counts,
|
||
};
|
||
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
||
}
|
||
|
||
async function cmdList(modules, asJson) {
|
||
const { prisma } = modules;
|
||
const users = await prisma.user.findMany({
|
||
where: { role: { in: ['ADMIN', 'TEACHER'] } },
|
||
select: { username: true, name: true, role: true, classId: true, updatedAt: true },
|
||
orderBy: [{ role: 'asc' }, { username: 'asc' }],
|
||
});
|
||
const classes = await prisma.class.findMany({
|
||
select: { code: true, name: true, passwordHash: true },
|
||
orderBy: { code: 'asc' },
|
||
});
|
||
|
||
if (asJson) {
|
||
process.stdout.write(
|
||
`${JSON.stringify(
|
||
{
|
||
users,
|
||
classes: classes.map((item) => ({ code: item.code, name: item.name, hasPassword: !!item.passwordHash })),
|
||
},
|
||
null,
|
||
2,
|
||
)}\n`,
|
||
);
|
||
return;
|
||
}
|
||
|
||
process.stdout.write('可登录账号(教师 / 管理员):\n');
|
||
for (const user of users) {
|
||
process.stdout.write(` ${user.role.padEnd(8)} ${user.username.padEnd(16)} ${user.name}\n`);
|
||
}
|
||
process.stdout.write('\n班级账号(学生端用「班级码 + 班级密码」登录):\n');
|
||
for (const item of classes) {
|
||
const state = item.passwordHash ? '已设置' : '⚠ 未设置密码(该班当前无法登录)';
|
||
process.stdout.write(` ${item.code.padEnd(8)} ${item.name.padEnd(16)} ${state}\n`);
|
||
}
|
||
}
|
||
|
||
async function cmdDumpHash(modules, key) {
|
||
const { prisma } = modules;
|
||
const target = await resolveTarget(prisma, key);
|
||
if (target.error) return fail(target.error, target.code);
|
||
if (!target.hash) return fail(`${target.label} 当前没有密码(passwordHash 为空)`, EXIT.NOT_FOUND);
|
||
// 只给调用方(root)看的原始数据,不落到本工具的日志里
|
||
process.stdout.write(`${JSON.stringify({ target: key, kind: target.kind, id: target.id, hash: target.hash })}\n`);
|
||
}
|
||
|
||
async function cmdSetPassword(modules, key, readSecret) {
|
||
const { prisma, hashPassword } = modules;
|
||
const target = await resolveTarget(prisma, key);
|
||
if (target.error) return fail(target.error, target.code);
|
||
|
||
const secret = await readSecret();
|
||
const problems = checkStrength(secret, key);
|
||
if (problems.length > 0) return fail(`密码不合规:${problems.join(';')}`, EXIT.WEAK);
|
||
|
||
const hash = await hashPassword(secret);
|
||
if (target.kind === 'class') {
|
||
await prisma.class.update({ where: { id: target.id }, data: { passwordHash: hash } });
|
||
} else {
|
||
await prisma.user.update({ where: { id: target.id }, data: { passwordHash: hash } });
|
||
}
|
||
|
||
// 写完立刻复核一次:曾经出现过"bcrypt 参数不一致导致写进去的哈希验证不过"的排查成本
|
||
const ok = await modules.verifyPassword(secret, hash);
|
||
if (!ok) return fail('写入后复核失败:哈希无法通过校验,请用 restore-hash 回滚', EXIT.FAIL);
|
||
|
||
process.stdout.write(`已更新密码:${target.label}\n`);
|
||
}
|
||
|
||
async function cmdVerify(modules, key, readSecret) {
|
||
const { prisma, verifyPassword } = modules;
|
||
const target = await resolveTarget(prisma, key);
|
||
if (target.error) return fail(target.error, target.code);
|
||
const secret = await readSecret();
|
||
const ok = !!target.hash && (await verifyPassword(secret, target.hash));
|
||
process.stdout.write(`${ok ? 'match' : 'mismatch'}\n`);
|
||
if (!ok) process.exitCode = EXIT.FAIL;
|
||
}
|
||
|
||
async function cmdRestoreHash(modules, backupFile) {
|
||
const { prisma } = modules;
|
||
if (!fs.existsSync(backupFile)) return fail(`备份文件不存在:${backupFile}`, EXIT.NOT_FOUND);
|
||
let payload;
|
||
try {
|
||
payload = JSON.parse(fs.readFileSync(backupFile, 'utf8'));
|
||
} catch (error) {
|
||
return fail(`备份文件不是合法 JSON:${error.message}`);
|
||
}
|
||
const { target, hash } = payload;
|
||
if (typeof target !== 'string' || typeof hash !== 'string') {
|
||
return fail('备份文件缺少 target / hash 字段,拒绝回滚');
|
||
}
|
||
if (target.startsWith('class:')) {
|
||
await prisma.class.update({ where: { code: target.slice('class:'.length).toUpperCase() }, data: { passwordHash: hash } });
|
||
} else {
|
||
await prisma.user.update({ where: { username: target }, data: { passwordHash: hash } });
|
||
}
|
||
process.stdout.write(`已回滚密码哈希:${target}\n`);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ 入口 */
|
||
|
||
const argv = process.argv.slice(2);
|
||
const command = argv[0] ?? 'help';
|
||
const args = argv.slice(1);
|
||
const wantsJson = args.includes('--json');
|
||
const positional = args.filter((item) => !item.startsWith('--'));
|
||
|
||
function usage() {
|
||
process.stdout.write(
|
||
[
|
||
'classhelper 离线账号工具(一般由 classhelper 命令调用)',
|
||
'',
|
||
' info 输出数据库与表行数(JSON)',
|
||
' list [--json] 列出可登录账号与班级账号',
|
||
' dump-hash <用户名|class:班级码> 输出当前密码哈希(供改密前备份)',
|
||
' set-password <用户名> 从 stdin 读新密码并写入',
|
||
' set-class-password <班级码> 从 stdin 读新密码并写入班级账号',
|
||
' verify <用户名> 从 stdin 读密码,输出 match / mismatch',
|
||
' restore-hash <备份json路径> 用备份文件里的哈希回滚',
|
||
' snapshot <目标db文件> 把 SQLite 库一致性快照到指定路径(VACUUM INTO)',
|
||
'',
|
||
].join('\n'),
|
||
);
|
||
}
|
||
|
||
if (command === 'help' || command === '--help' || command === '-h') {
|
||
usage();
|
||
} else if (
|
||
![
|
||
'info',
|
||
'list',
|
||
'dump-hash',
|
||
'set-password',
|
||
'set-class-password',
|
||
'verify',
|
||
'restore-hash',
|
||
'snapshot',
|
||
].includes(command)
|
||
) {
|
||
fail(`未知子命令:${command}\n`, EXIT.USAGE);
|
||
usage();
|
||
} else {
|
||
const modules = await loadServerModules();
|
||
if (!modules) {
|
||
// 已经在 loadServerModules 里设了 exitCode
|
||
} else {
|
||
try {
|
||
switch (command) {
|
||
case 'info':
|
||
await cmdInfo(modules);
|
||
break;
|
||
case 'list':
|
||
await cmdList(modules, wantsJson);
|
||
break;
|
||
case 'dump-hash':
|
||
if (!positional[0]) fail('缺少目标账号', EXIT.USAGE);
|
||
else await cmdDumpHash(modules, positional[0]);
|
||
break;
|
||
case 'set-password':
|
||
case 'set-class-password': {
|
||
const key = command === 'set-class-password' ? `class:${positional[0] ?? ''}` : (positional[0] ?? '');
|
||
if (!positional[0]) fail('缺少目标账号', EXIT.USAGE);
|
||
else await cmdSetPassword(modules, key, readSecretFromStdin);
|
||
break;
|
||
}
|
||
case 'verify':
|
||
if (!positional[0]) fail('缺少目标账号', EXIT.USAGE);
|
||
else await cmdVerify(modules, positional[0], readSecretFromStdin);
|
||
break;
|
||
case 'restore-hash':
|
||
if (!positional[0]) fail('缺少备份文件路径', EXIT.USAGE);
|
||
else await cmdRestoreHash(modules, positional[0]);
|
||
break;
|
||
case 'snapshot':
|
||
if (!positional[0]) fail('缺少目标文件路径', EXIT.USAGE);
|
||
else await cmdSnapshot(modules, positional[0]);
|
||
break;
|
||
default:
|
||
usage();
|
||
}
|
||
} catch (error) {
|
||
fail(error instanceof Error ? error.message : String(error));
|
||
} finally {
|
||
// 不用 process.exit():在顶层 await 里硬退会踩 libuv 断言(见 AGENTS.md §7)
|
||
await modules.disconnectPrisma();
|
||
}
|
||
}
|
||
}
|