feat(server,web,desktop,shared,deploy,plugin): 数据库管理模块 + 客户端深色模式/设置拆子路由/首启引导,版本统一 1.0.0

- 服务端新增 database 模块(/api/database/*,仅 ADMIN):全库 JSON 快照(备份 / 恢复 / 导入导出 / 定时备份
  共用同一格式,14 张表按拓扑序导出、恢复时关外键检查)、连接测试与目标库校验、一键 SQLite ⇄ MySQL 切换
  (备份 → 导出 → 测试连接 → 校验空库 → 改 schema → 子进程 generate / tsc / db push → 写数据,全部成功才改写
  .env,中途失败回滚 schema);XML 快照与 SQLite 数据文件均可下载;打包形态随包带上 prisma CLI 与 typescript
  (prisma-client 生成物是 .ts,需再编进 dist/generated,安装包因之变大)
- Web 管理端新增「数据库管理」页(仅管理员),并加首次登录新手引导(el-tour,可跳过、可随时重看,版本号写
  localStorage,改版后老用户会再收到一次)
- 客户端设置改版:侧边栏改为分组折叠菜单,设置拆成 6 个子路由
  (/settings/{general,appearance,island,reminder,account,about}),新增深色模式(主题持久化到主进程配置,
  主窗口底色跟主题走,避免"先白后黑"闪一下),新增「关于」页(应用信息 / 诊断信息)
- 客户端新增首启六步引导(OnboardingWelcome,状态存主进程配置 onboardingDone)
- 客户端主窗口初始尺寸与 ClassIsland 对齐(1242x582 / minHeight 540 / 居中)
- 版本号统一提升到 1.0.0:5 个 package.json、部署配置与镜像标签(.env.example / docker-compose / Dockerfile)、
  服务端 /health 与首页、插件 manifest.yml 与 BridgeService.PluginVersion、文档里的安装包名
- 文档与验收同步:AGENTS.md 补第 37~51 条约定(深色主题实现边界、设置子路由、数据库管理三件事、侧栏与窗口
  尺寸、引导在自动化里的判据等),README 补「数据库管理 / 新手引导 / 设置改版」三节;verify:e2e 增补 13 条
  数据库管理用例,客户端冒烟与 Web UI 冒烟跟进子路由、深色主题、侧栏与引导

验证:pnpm lint 0 error;pnpm typecheck 全过;verify:e2e 186/186;verify:classisland-plugin 68/68;
build:desktop 通过(含产物凭据门禁);verify:desktop 99/102(3 项为上课时段下的"真实通知链路"用例,
客户端按产品规则暂存普通通知所致,见下);插件 dotnet 编译 0 错误。

注:verify:desktop 的「真实通知链路 / 标记已读」三项要求当前不在上课时段——本次运行时种子课表恰有
「周五 10:55-11:40 生物」正在进行,客户端正确地暂存了普通通知(AGENTS.md 第 18 条),属时间相关用例,
与本次改动无关。
This commit is contained in:
LoveChengke committed 2026-10-02 11:18:47 +08:00
1 parent 391e2c1591
commit 572295c297
66 files changed
+5131 -1027

No files matched your search

+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/server",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"description": "班级小助手后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
"type": "module",
@@ -25,6 +25,7 @@
"@classhelper/shared": "workspace:*",
"@libsql/client": "^0.17.0",
"@prisma/adapter-libsql": "7.10.0",
"@prisma/adapter-mariadb": "7.10.0",
"@prisma/client": "7.10.0",
"bcryptjs": "^3.0.3",
"compression": "^1.8.2",
@@ -34,7 +35,9 @@
"express-rate-limit": "^8.7.0",
"helmet": "^8.3.0",
"jsonwebtoken": "^9.0.3",
"prisma": "7.10.0",
"socket.io": "^4.8.3",
"typescript": "~5.9.3",
"xlsx": "0.18.5",
"zod": "^4.6.2"
},
@@ -182,7 +182,7 @@ async function main() {
// ---------------------------------------------------------------- 3. 上报(状态 + 课表 + 节次)
const reportBody = {
pluginVersion: '0.1.0.0',
pluginVersion: '1.0.0.0',
classIslandVersion: '2.1.0.0',
state: {
inClass: true,
+224
View File
@@ -2339,6 +2339,230 @@ async function main() {
`教师改=${teacherChangeHead.status} 管理员改=${changedHead.status} 详情班主任=${changedDetail.payload?.data?.teacher?.name ?? '-'}`,
);
// 清理:删除实时推送用例造的通知/作业、冒烟班级、临时权限校验班级与冒烟教师账号
// ---------------------------------------------------------------- 数据库管理(仅管理员)
// 覆盖:状态 / 越权 403 / 连接测试 / 备份→导入导出→恢复 roundtrip / 定时配置校验 / 切换校验分支。
// 注意:**不真跑切换**(会改写 .env 与 schema.prisma),只验证拒绝分支;真实切换需人工复验。
const dbStatus = await api('/database/status', { token: adminToken });
const dbStatusData = dbStatus.payload?.data ?? {};
record(
'数据库状态(管理员):已连接且返回 14 张表行数',
dbStatus.status === 200 &&
dbStatusData.connected === true &&
Array.isArray(dbStatusData.tables) &&
dbStatusData.tables.length === 14 &&
dbStatusData.tables.every((item) => item.count >= 0),
`provider=${dbStatusData.provider} 延迟=${dbStatusData.latencyMs}ms 版本=${dbStatusData.version} 表=${
(dbStatusData.tables ?? []).length
}`,
);
const dbStatusTeacher = await api('/database/status', { token: teacherToken });
const dbBackupTeacher = await api('/database/backups', { method: 'POST', token: teacherToken });
const dbScheduleTeacher = await api('/database/backup-schedule', {
method: 'PUT',
token: teacherToken,
body: { enabled: true, intervalHours: 24, keepCount: 7 },
});
const dbSwitchTeacher = await api('/database/switch', {
method: 'POST',
token: teacherToken,
body: { provider: 'sqlite', url: 'file:./data/teacher-forbidden.db' },
});
record(
'数据库管理越权(教师全部 403)',
dbStatusTeacher.status === 403 &&
dbBackupTeacher.status === 403 &&
dbScheduleTeacher.status === 403 &&
dbSwitchTeacher.status === 403,
`status=${dbStatusTeacher.status} 备份=${dbBackupTeacher.status} 定时=${dbScheduleTeacher.status} 切换=${dbSwitchTeacher.status}`,
);
// 连接测试:当前库必须可达(SQLite 用相对路径,MySQL 用一个不可达地址测失败分支)
let testOkResult;
let testOkName;
if (dbStatusData.provider === 'sqlite') {
testOkResult = await api('/database/test-connection', {
method: 'POST',
token: adminToken,
body: { provider: 'sqlite', url: 'file:./prisma/dev.db' },
});
testOkName = '连接测试(当前 SQLite 库可达)';
} else {
testOkResult = await api('/database/test-connection', {
method: 'POST',
token: adminToken,
body: { provider: 'mysql', url: 'mysql://root:wrong-password@127.0.0.1:1/none' },
});
testOkName = '连接测试(不可达 MySQL 返回 ok=false)';
}
record(
testOkName,
testOkResult.status === 200 &&
(dbStatusData.provider === 'sqlite'
? testOkResult.payload?.data?.ok === true
: testOkResult.payload?.data?.ok === false),
`ok=${testOkResult.payload?.data?.ok} 版本=${testOkResult.payload?.data?.version} 详情=${testOkResult.payload?.data?.error ?? testOkResult.payload?.data?.note ?? '-'}`,
);
const dbTestRedis = await api('/database/test-connection', {
method: 'POST',
token: adminToken,
body: { provider: 'redis', url: 'redis://127.0.0.1:6379' },
});
record(
'Redis 作为主库被拒绝(schema 层面 422,产品口径:主库仅 SQLite/MySQL)',
dbTestRedis.status === 422,
`status=${dbTestRedis.status}`,
);
const badConnection = await api('/database/test-connection', {
method: 'POST',
token: adminToken,
body: { provider: 'mysql', url: 'mysql://root:wrong@127.0.0.1:1/none' },
});
record(
'连接测试(不可达 MySQL → ok=false 且不抛 500)',
badConnection.status === 200 && badConnection.payload?.data?.ok === false,
`ok=${badConnection.payload?.data?.ok} error=${(badConnection.payload?.data?.error ?? '').slice(0, 60)}`,
);
// 切换校验分支:同库拒绝(真实切换不在此跑,避免改写 .env)
const switchSame =
dbStatusData.provider === 'sqlite'
? await api('/database/switch', {
method: 'POST',
token: adminToken,
body: { provider: 'sqlite', url: dbStatusData.databaseUrlMasked },
})
: { status: 0, payload: null };
const switchInvalid = await api('/database/switch', {
method: 'POST',
token: adminToken,
body: { provider: 'redis', url: 'redis://127.0.0.1:6379' },
});
record(
'一键切换校验分支(同库 400 / 非法 provider 422,不真跑切换)',
(dbStatusData.provider === 'sqlite' ? switchSame.status === 400 : true) && switchInvalid.status === 422,
`同库=${switchSame.status}(${dbStatusData.provider === 'sqlite' ? 'sqlite 当前库' : 'mysql 跳过'}) redis=${switchInvalid.status}`,
);
// 备份 → 导出快照 → 造数据 → 快照导入 → 造数据 → 从备份恢复(三个恢复路径全验)
const dbBackup = await api('/database/backups', { method: 'POST', token: adminToken });
const backupName = dbBackup.payload?.data?.name ?? '';
record(
'手动备份(201,文件名符合约定格式)',
dbBackup.status === 201 && /^backup-manual-\d{8}-\d{6}\.json\.gz$/.test(backupName),
`name=${backupName} size=${dbBackup.payload?.data?.sizeBytes ?? '-'}`,
);
const dbExport = await fetch(`${BASE_URL}/api/database/export`, {
headers: { Authorization: `Bearer ${adminToken}` },
});
const snapshotJson = dbExport.status === 200 ? await dbExport.text() : '';
let snapshotParsed = null;
try {
snapshotParsed = JSON.parse(snapshotJson);
} catch {
snapshotParsed = null;
}
record(
'导出快照(JSON 格式正确、14 张表、用户数 > 0)',
dbExport.status === 200 &&
snapshotParsed?.format === 'classhelper-snapshot' &&
Object.keys(snapshotParsed?.data ?? {}).length === 14 &&
(snapshotParsed?.counts?.User ?? 0) > 0,
`status=${dbExport.status} 格式=${snapshotParsed?.format ?? '-'} 用户=${snapshotParsed?.counts?.User ?? '-'}`,
);
// 造一条临时通知 → 快照导入(应被清掉)→ 再造一条 → 从备份恢复(也应被清掉)
const dbRoundtripNotice = await api('/notifications', {
method: 'POST',
token: adminToken,
body: { classId, title: '数据库roundtrip自检', content: '导入/恢复后应消失' },
});
const dbRoundtripNoticeId = dbRoundtripNotice.payload?.data?.id;
const dbImport = await api('/database/import', {
method: 'POST',
token: adminToken,
body: { data: Buffer.from(snapshotJson, 'utf8').toString('base64') },
});
const afterImport = await api(`/notifications?classId=${classId}`, { token: adminToken });
const roundtripNoticeGone = !(afterImport.payload?.data ?? []).some(
(item) => item.id === dbRoundtripNoticeId,
);
record(
'快照导入(整库覆盖:导入后临时通知消失)',
dbImport.status === 200 && roundtripNoticeGone,
`导入=${dbImport.status} 通知已清除=${roundtripNoticeGone} 行数=${JSON.stringify(dbImport.payload?.data?.counts ?? {}).slice(0, 80)}`,
);
const dbRoundtripNotice2 = await api('/notifications', {
method: 'POST',
token: adminToken,
body: { classId, title: '数据库roundtrip自检2', content: '备份恢复后应消失' },
});
const dbRoundtripNotice2Id = dbRoundtripNotice2.payload?.data?.id;
const dbRestore = await api(`/database/backups/${backupName}/restore`, { method: 'POST', token: adminToken });
const afterRestore = await api(`/notifications?classId=${classId}`, { token: adminToken });
const restoreNoticeGone = !(afterRestore.payload?.data ?? []).some(
(item) => item.id === dbRoundtripNotice2Id,
);
record(
'从备份恢复(整库覆盖:恢复后临时通知消失)',
dbRestore.status === 200 && restoreNoticeGone,
`恢复=${dbRestore.status} 通知已清除=${restoreNoticeGone}`,
);
if (dbRoundtripNoticeId) {
await api(`/notifications/${dbRoundtripNoticeId}`, { method: 'DELETE', token: adminToken });
}
if (dbRoundtripNotice2Id) {
await api(`/notifications/${dbRoundtripNotice2Id}`, { method: 'DELETE', token: adminToken });
}
const dbBackupList = await api('/database/backups', { token: adminToken });
const dbBackupDelete = await api(`/database/backups/${backupName}`, { method: 'DELETE', token: adminToken });
const dbBackupListAfter = await api('/database/backups', { token: adminToken });
record(
'备份列表与删除(创建后可见,删除后消失)',
dbBackupList.status === 200 &&
(dbBackupList.payload?.data ?? []).some((item) => item.name === backupName) &&
dbBackupDelete.status === 200 &&
!(dbBackupListAfter.payload?.data ?? []).some((item) => item.name === backupName),
`删除前=${(dbBackupList.payload?.data ?? []).length} 删除后=${(dbBackupListAfter.payload?.data ?? []).length}`,
);
const dbScheduleInvalid = await api('/database/backup-schedule', {
method: 'PUT',
token: adminToken,
body: { enabled: true, intervalHours: 0, keepCount: 7 },
});
const dbScheduleSave = await api('/database/backup-schedule', {
method: 'PUT',
token: adminToken,
body: { enabled: false, intervalHours: 24, keepCount: 7 },
});
record(
'定时备份配置(非法间隔 422 / 合法保存并回读)',
dbScheduleInvalid.status === 422 &&
dbScheduleSave.status === 200 &&
dbScheduleSave.payload?.data?.enabled === false &&
dbScheduleSave.payload?.data?.intervalHours === 24,
`非法=${dbScheduleInvalid.status} 保存=${dbScheduleSave.status} enabled=${dbScheduleSave.payload?.data?.enabled}`,
);
if (dbStatusData.provider === 'sqlite') {
const sqliteFile = await fetch(`${BASE_URL}/api/database/sqlite-file`, {
headers: { Authorization: `Bearer ${adminToken}` },
});
const sqliteBytes = sqliteFile.status === 200 ? (await sqliteFile.arrayBuffer()).byteLength : 0;
record(
'SQLite 数据文件下载(快捷导出 .db 文件)',
sqliteFile.status === 200 && sqliteBytes > 0,
`status=${sqliteFile.status} bytes=${sqliteBytes}`,
);
}
// 清理:删除实时推送用例造的通知/作业、冒烟班级、临时权限校验班级与冒烟教师账号
const realtimeNoticeId = createdNotification.payload?.data?.id;
if (realtimeNoticeId) {
+3 -3
View File
@@ -37,7 +37,7 @@ async function healthHandler(_req: Request, res: Response): Promise<void> {
res,
{
status: 'ok',
version: '0.1.0',
version: '1.0.0',
env: env.nodeEnv,
uptimeSeconds: Math.round(process.uptime()),
database: {
@@ -68,7 +68,7 @@ function createRootHandler(webDistDir: string | null) {
res,
{
service: 'class-helper-server',
version: '0.1.0',
version: '1.0.0',
environment: env.nodeEnv,
apiPrefix: API_PREFIX,
socket: '/socket.io',
@@ -115,7 +115,7 @@ function renderLandingPage(webAdminServed: boolean): string {
<body>
<div class="card">
<h1>班级小助手 · 后端服务 <span class="ok">running</span></h1>
<p class="sub">版本 0.1.0 · 运行环境 ${env.nodeEnv} · 数据库 ${env.databaseProvider}</p>
<p class="sub">版本 1.0.0 · 运行环境 ${env.nodeEnv} · 数据库 ${env.databaseProvider}</p>
<p class="sub">
健康检查 <a href="${API_PREFIX}/health">${API_PREFIX}/health</a> ·
存活 /healthz · 就绪 /readyz
+3
View File
@@ -9,6 +9,7 @@ import { ensureInitialAdmin, ensureSchema } from './lib/db-bootstrap.js';
import { logger } from './lib/logger.js';
import { resolveWebDistDir } from './lib/web-static.js';
import { initRealtime } from './realtime/socket.js';
import { startBackupScheduler } from './modules/database/database.service.js';
/** 数据库自检与可选的首次初始化 */
async function prepareDatabase(): Promise<void> {
@@ -85,6 +86,8 @@ httpServer.listen(env.port, env.host, () => {
}
logger.info(`探针:/healthz(存活) · /readyz(就绪) · ${API_PREFIX}/health(详情)`);
logger.info(`WebSocket:ws://${displayHost}:${env.port}/socket.io`);
// 数据库管理模块的定时备份调度(配置与"到点时间"持久化在 data/database-settings.json)
startBackupScheduler();
});
let shuttingDown = false;
+15 -9
View File
@@ -3,27 +3,33 @@ import { PrismaClient } from '../generated/prisma/client.js';
import { env } from '../config/env.js';
import { logger } from './logger.js';
/** 本项目支持的主数据库类型(Redis 等键值库不是 Prisma 支持的主库,不在范围内) */
export type DatabaseProvider = 'sqlite' | 'mysql';
/**
* Prisma 7 要求所有数据库都通过 driver adapter 接入:
* - SQLite -> @prisma/adapter-libsql(libSQL 内嵌模式,npm 预编译二进制,无需本地编译)
* - MySQL -> @prisma/adapter-mariadb(切换数据库时安装,见 docs/mysql.md)
* - MySQL -> @prisma/adapter-mariadb(依赖里常驻,切换数据库时直接可用)
*
* MySQL 分支使用「非字面量模块名」动态导入,这样未安装 mariadb 适配器时
* 也不会影响类型检查与 SQLite 模式的运行。
*
* 该工厂独立于进程级单例:数据库管理模块的「连接测试」与跨库迁移的工具进程
* 都需要按任意 provider/url 构建临时客户端,而不是读 env 单例。
*/
async function createAdapter(): Promise<unknown> {
if (env.databaseProvider === 'mysql') {
export async function createPrismaClient(
provider: DatabaseProvider,
url: string,
): Promise<PrismaClient> {
if (provider === 'mysql') {
const adapterModule = '@prisma/adapter-mariadb';
const mod = (await import(adapterModule)) as { PrismaMariaDb: new (url: string) => unknown };
logger.info('已加载 MySQL/MariaDB 驱动适配器');
return new mod.PrismaMariaDb(env.databaseUrl);
return new PrismaClient({ adapter: new mod.PrismaMariaDb(url) as never });
}
return new PrismaLibSql({ url: env.databaseUrl });
return new PrismaClient({ adapter: new PrismaLibSql({ url }) as never });
}
const adapter = await createAdapter();
export const prisma = new PrismaClient({ adapter: adapter as never });
export const prisma = await createPrismaClient(env.databaseProvider, env.databaseUrl);
logger.info(`Prisma 客户端已初始化(provider=${env.databaseProvider})`);
+159
View File
@@ -0,0 +1,159 @@
import type { PrismaClient } from '../generated/prisma/client.js';
import type { DatabaseProvider } from './db.js';
/**
* 全库 JSON 快照:备份 / 恢复 / 导入导出 / 跨库迁移共用这一种格式。
*
* 为什么不用「复制数据库文件」或 mysqldump:
* - 文件级备份只对 SQLite 有意义,跨库迁移根本用不上;
* - JSON 快照与 provider 无关,一份实现同时服务备份、导入导出和 SQLite⇄MySQL 迁移。
*
* 外键注意:schema 里 User.classId → Class 且 Class.teacherId → User(互相引用),
* 恢复时必须**临时关闭外键检查**再按序写入,否则任何排序都会被其中一侧卡死。
* - SQLite:PRAGMA foreign_keys=OFF(libsql 连接级,进程内安全)
* - MySQL:SET FOREIGN_KEY_CHECKS=0/1(会话级,连接池下同一客户端实例内生效)
*/
export const SNAPSHOT_FORMAT = 'classhelper-snapshot';
export const SNAPSHOT_VERSION = 1;
/** 导出与恢复顺序(外键关闭后顺序不再影响写入,但保持拓扑序便于人工阅读与排查) */
export const SNAPSHOT_TABLES = [
'User',
'Class',
'Course',
'ClassTeacher',
'Enrollment',
'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);
}
@@ -0,0 +1,124 @@
import fs from 'node:fs';
import path from 'node:path';
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { authenticate, requireRole } from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
backupNameParamSchema,
backupScheduleSchema,
importSnapshotSchema,
jobIdParamSchema,
switchDatabaseSchema,
testConnectionSchema,
type BackupScheduleInput,
type ImportSnapshotInput,
type SwitchDatabaseInput,
type TestConnectionInput,
} from './database.schemas.js';
import * as databaseService from './database.service.js';
/**
* 数据库管理(仅管理员):状态检测 / 连接测试 / 备份恢复 / 导入导出 / 定时备份 / 一键切换。
*
* 改动数据库属于最高危操作,全部接口挂在 requireRole('ADMIN') 之下;
* 前端入口同样只对 ADMIN 显示(Web 端「数据库管理」页)。
*
* 一键切换是异步任务:POST /switch 返回 jobId,前端轮询 GET /switch/jobs/:id 展示步骤进度。
* Redis 是键值库,不是 Prisma 支持的主数据库,因此不在此模块范围内。
*/
const router = Router();
router.use(authenticate());
router.use(requireRole('ADMIN'));
/** GET /api/database/status - 当前数据库状态(连接/版本/大小/各表行数/备份与定时配置) */
router.get('/status', async (_req, res) => {
sendOk(res, await databaseService.getDatabaseStatus());
});
/** POST /api/database/test-connection - 测试任意目标库连通性(切换前的前置检查) */
router.post('/test-connection', validate({ body: testConnectionSchema }), async (req, res) => {
const result = await databaseService.testConnection(validatedBody<TestConnectionInput>(req));
sendOk(res, result, result.ok ? '连接成功' : '连接失败');
});
/** GET /api/database/backups - 备份列表 */
router.get('/backups', (_req, res) => {
sendOk(res, databaseService.listBackups());
});
/** POST /api/database/backups - 立即创建一份手动备份 */
router.post('/backups', async (_req, res) => {
sendCreated(res, await databaseService.createBackup('manual'), '备份已创建');
});
/** POST /api/database/backups/:name/restore - 从备份恢复(整库覆盖) */
router.post('/backups/:name/restore', validate({ params: backupNameParamSchema }), async (req, res) => {
const { name } = validatedParams<{ name: string }>(req);
sendOk(res, await databaseService.restoreBackup(name), '已从备份恢复');
});
/** DELETE /api/database/backups/:name - 删除备份 */
router.delete('/backups/:name', validate({ params: backupNameParamSchema }), async (req, res) => {
const { name } = validatedParams<{ name: string }>(req);
databaseService.deleteBackup(name);
sendOk(res, { name }, '备份已删除');
});
/** GET /api/database/export - 下载当前库的 JSON 快照 */
router.get('/export', async (_req, res) => {
const json = await databaseService.exportSnapshotJson();
const filename = `classhelper-snapshot-${new Date().toISOString().slice(0, 19).replaceAll(':', '')}.json`;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.status(200).send(json);
});
/** GET /api/database/sqlite-file - 下载数据库文件(仅 SQLite 模式) */
router.get('/sqlite-file', (_req, res) => {
const filePath = databaseService.sqliteFilePathForDownload();
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${path.basename(filePath)}"`);
res.status(200).send(fs.readFileSync(filePath));
});
/** POST /api/database/import - 导入快照(整库覆盖;base64 随 JSON 请求体) */
router.post('/import', validate({ body: importSnapshotSchema }), async (req, res) => {
const { data } = validatedBody<ImportSnapshotInput>(req);
sendOk(res, await databaseService.importSnapshotBase64(data), '快照已导入(原数据已被覆盖)');
});
/** GET /api/database/backup-schedule - 定时备份配置 */
router.get('/backup-schedule', (_req, res) => {
sendOk(res, databaseService.getBackupSchedule());
});
/** PUT /api/database/backup-schedule - 更新定时备份配置(立即生效,重启后仍有效) */
router.put('/backup-schedule', validate({ body: backupScheduleSchema }), async (req, res) => {
sendOk(
res,
databaseService.saveSchedulePatch(validatedBody<BackupScheduleInput>(req)),
'定时备份配置已保存',
);
});
/** POST /api/database/switch - 一键切换数据库(异步任务,返回 jobId) */
router.post('/switch', validate({ body: switchDatabaseSchema }), async (req, res) => {
const job = databaseService.startSwitch(validatedBody<SwitchDatabaseInput>(req));
sendCreated(res, { jobId: job.id }, '切换任务已启动');
});
/** GET /api/database/switch/jobs/:id - 查询切换任务进度 */
router.get('/switch/jobs/:id', validate({ params: jobIdParamSchema }), async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
const job = databaseService.getSwitchJob(id);
sendOk(res, job, job ? 'ok' : '任务不存在');
});
export const databaseModule = defineModule({
name: 'database',
basePath: '/database',
router,
});
@@ -0,0 +1,65 @@
import { z } from 'zod';
/** 本项目支持的主数据库类型。Redis 等键值库不是 Prisma 支持的主库,不提供切换。 */
export const databaseProviderSchema = z.enum(['sqlite', 'mysql']);
export type DatabaseProviderInput = z.infer<typeof databaseProviderSchema>;
/** 连接串与 provider 配套校验:SQLite 必须 file: 前缀,MySQL 必须 mysql:// 前缀 */
export const targetConnectionSchema = z
.object({
provider: databaseProviderSchema,
url: z.string().min(1, '请填写连接串'),
})
.superRefine((value, ctx) => {
const url = value.url.trim();
if (value.provider === 'sqlite' && !url.startsWith('file:')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['url'],
message: 'SQLite 连接串必须以 file: 开头,例如 file:./data/classhelper.db',
});
}
if (value.provider === 'mysql' && !/^mysqls?:\/\//.test(url)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['url'],
message: 'MySQL 连接串必须以 mysql:// 或 mysqls:// 开头',
});
}
});
export type TargetConnectionInput = z.infer<typeof targetConnectionSchema>;
/** POST /database/test-connection:测试任意目标库的连通性 */
export const testConnectionSchema = targetConnectionSchema;
export type TestConnectionInput = z.infer<typeof testConnectionSchema>;
/** POST /database/switch:一键切换数据库(含数据迁移) */
export const switchDatabaseSchema = targetConnectionSchema;
export type SwitchDatabaseInput = z.infer<typeof switchDatabaseSchema>;
/** POST /database/import:上传快照(base64,随 JSON 请求体,受 12MB 请求体限制) */
export const importSnapshotSchema = z.object({
data: z.string().min(1, '请提供快照内容(base64)'),
});
export type ImportSnapshotInput = z.infer<typeof importSnapshotSchema>;
/** PUT /database/backup-schedule:定时备份配置 */
export const backupScheduleSchema = z.object({
enabled: z.boolean(),
/** 间隔小时数(1~720,默认 24 = 每天一次) */
intervalHours: z.number().int().min(1).max(720),
/** 最多保留多少份自动备份(超出后删最旧的;手动备份不受影响) */
keepCount: z.number().int().min(1).max(100),
});
export type BackupScheduleInput = z.infer<typeof backupScheduleSchema>;
/** 路径参数:备份文件名(必须是本服务生成的固定格式,杜绝路径穿越) */
export const backupNameParamSchema = z.object({
name: z.string().regex(/^backup-(manual|auto)-\d{8}-\d{6}\.json\.gz$/, '备份文件名不合法'),
});
/** 路径参数:切换任务 id(本服务生成,字母数字与连字符) */
export const jobIdParamSchema = z.object({
id: z.string().regex(/^[0-9a-z-]{6,40}$/, '任务 id 不合法'),
});
@@ -0,0 +1,771 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import zlib from 'node:zlib';
import { env } from '../../config/env.js';
import { createPrismaClient, prisma, type DatabaseProvider } from '../../lib/db.js';
import { logger } from '../../lib/logger.js';
import {
assertValidSnapshot,
exportSnapshot,
restoreSnapshot,
SNAPSHOT_TABLES,
type DatabaseSnapshot,
} from '../../lib/snapshot.js';
import { ApiError } from '../../lib/http.js';
import type {
BackupScheduleInput,
TargetConnectionInput,
} from './database.schemas.js';
/**
* 数据库管理服务:状态检测 / 连接测试 / 备份恢复 / 导入导出 / 定时备份 / 一键切换。
*
* 所有实现都基于 `lib/snapshot.ts` 的 JSON 快照:与 provider 无关,
* 因此备份可以恢复回任意一种受支持的数据库(备份即迁移的载体)。
*/
/* ================================================================ 路径 */
/** 数据目录:SQLite = 数据文件所在目录;MySQL = 安装根的 data 目录(与部署约定一致) */
function dataDir(): string {
if (env.databaseProvider === 'sqlite' && env.sqliteFilePath) {
return path.dirname(env.sqliteFilePath);
}
return path.join(env.serverRoot, '..', 'data');
}
function backupDir(): string {
return path.join(dataDir(), 'backups');
}
function settingsFilePath(): string {
return path.join(dataDir(), 'database-settings.json');
}
function ensureBackupDir(): string {
const dir = backupDir();
fs.mkdirSync(dir, { recursive: true });
return dir;
}
/* ================================================================ 定时备份配置 */
interface BackupSchedule {
enabled: boolean;
intervalHours: number;
keepCount: number;
lastAutoBackupAt: string | null;
}
const DEFAULT_SCHEDULE: BackupSchedule = {
enabled: false,
intervalHours: 24,
keepCount: 7,
lastAutoBackupAt: null,
};
function readSchedule(): BackupSchedule {
try {
const raw = JSON.parse(fs.readFileSync(settingsFilePath(), 'utf8')) as Partial<BackupSchedule>;
return {
enabled: raw.enabled === true,
intervalHours:
typeof raw.intervalHours === 'number' && raw.intervalHours >= 1 ? Math.floor(raw.intervalHours) : DEFAULT_SCHEDULE.intervalHours,
keepCount: typeof raw.keepCount === 'number' && raw.keepCount >= 1 ? Math.floor(raw.keepCount) : DEFAULT_SCHEDULE.keepCount,
lastAutoBackupAt: typeof raw.lastAutoBackupAt === 'string' ? raw.lastAutoBackupAt : null,
};
} catch {
return { ...DEFAULT_SCHEDULE };
}
}
function writeSchedule(schedule: BackupSchedule): void {
ensureBackupDir();
fs.writeFileSync(settingsFilePath(), `${JSON.stringify(schedule, null, 2)}\n`, 'utf8');
}
/** 读取定时备份配置(GET /backup-schedule) */
export function getBackupSchedule(): BackupSchedule {
return readSchedule();
}
/** 更新定时备份配置(PUT /backup-schedule),写盘后立即生效 */
export function saveSchedulePatch(patch: BackupScheduleInput): BackupSchedule {
const next: BackupSchedule = { ...readSchedule(), ...patch };
writeSchedule(next);
return next;
}
let schedulerTimer: NodeJS.Timeout | null = null;
/**
* 定时备份调度:每 10 分钟检查一次,到点自动备份并清理旧备份。
* 依赖服务端进程常驻(进程不运行不会补跑);lastAutoBackupAt 持久化在配置文件里,
* 重启后不会立刻重复备份。
*/
export function startBackupScheduler(): void {
if (schedulerTimer) return;
const tick = async (): Promise<void> => {
if (isSwitchRunning()) return;
const schedule = readSchedule();
if (!schedule.enabled) return;
const last = schedule.lastAutoBackupAt ? Date.parse(schedule.lastAutoBackupAt) : 0;
const dueAt = (Number.isFinite(last) ? last : 0) + schedule.intervalHours * 3_600_000;
if (Date.now() < dueAt) return;
try {
await createBackup('auto');
writeSchedule({ ...schedule, lastAutoBackupAt: new Date().toISOString() });
logger.info('定时备份完成');
} catch (error) {
logger.error('定时备份失败', error);
}
};
schedulerTimer = setInterval(() => void tick(), 10 * 60_000);
schedulerTimer.unref();
logger.info('定时备份调度已启动(每 10 分钟检查一次到点任务)');
}
/* ================================================================ 备份 / 恢复 */
export interface BackupMeta {
name: string;
kind: 'manual' | 'auto';
sizeBytes: number;
createdAt: string;
}
function isBackupName(name: string): boolean {
return /^backup-(manual|auto)-\d{8}-\d{6}\.json\.gz$/.test(name);
}
function backupPath(name: string): string {
if (!isBackupName(name)) {
throw ApiError.badRequest('备份文件名不合法');
}
const target = path.join(backupDir(), name);
if (path.dirname(target) !== backupDir()) {
throw ApiError.badRequest('备份文件名不合法');
}
return target;
}
function timestampSlug(): string {
const now = new Date();
const pad = (value: number): string => String(value).padStart(2, '0');
return (
`${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
`-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
);
}
/** 创建一份备份(gzip 压缩的 JSON 快照)。kind 决定文件名前缀与自动清理策略。 */
export async function createBackup(kind: 'manual' | 'auto'): Promise<BackupMeta> {
assertNotSwitching();
const dir = ensureBackupDir();
const snapshot = await exportSnapshot(prisma, env.databaseProvider);
const payload = zlib.gzipSync(Buffer.from(JSON.stringify(snapshot), 'utf8'));
const name = `backup-${kind}-${timestampSlug()}.json.gz`;
fs.writeFileSync(path.join(dir, name), payload);
if (kind === 'auto') pruneAutoBackups();
const meta: BackupMeta = { name, kind, sizeBytes: payload.length, createdAt: snapshot.createdAt };
logger.info(`已创建${kind === 'auto' ? '定时' : '手动'}备份:${name}`);
return meta;
}
/** 自动备份按 keepCount 清理最旧的(手动备份不受影响) */
function pruneAutoBackups(): void {
const schedule = readSchedule();
const autos = listBackups()
.filter((item) => item.kind === 'auto')
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
for (const stale of autos.slice(0, Math.max(0, autos.length - schedule.keepCount))) {
try {
fs.unlinkSync(path.join(backupDir(), stale.name));
} catch {
// 清理失败不影响本次备份
}
}
}
export function listBackups(): BackupMeta[] {
const dir = backupDir();
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir)
.filter(isBackupName)
.map((name) => {
const stat = fs.statSync(path.join(dir, name));
return {
name,
kind: name.includes('-manual-') ? ('manual' as const) : ('auto' as const),
sizeBytes: stat.size,
createdAt: stat.mtime.toISOString(),
};
})
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
function readBackupSnapshot(name: string): DatabaseSnapshot {
const file = backupPath(name);
if (!fs.existsSync(file)) {
throw ApiError.notFound('备份不存在(可能已被删除)');
}
const snapshot = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf8')) as DatabaseSnapshot;
assertValidSnapshot(snapshot);
return snapshot;
}
/** 从备份恢复(整库覆盖:先清空现有数据再写入备份内容) */
export async function restoreBackup(name: string): Promise<{ counts: Record<string, number> }> {
assertNotSwitching();
const snapshot = readBackupSnapshot(name);
return restoreSnapshot(prisma, env.databaseProvider, snapshot);
}
export function deleteBackup(name: string): void {
const file = backupPath(name);
if (!fs.existsSync(file)) {
throw ApiError.notFound('备份不存在(可能已被删除)');
}
fs.unlinkSync(file);
}
/* ================================================================ 导入 / 导出 */
/** 导出当前库的快照 JSON(未压缩,供下载与跨库迁移使用) */
export async function exportSnapshotJson(): Promise<string> {
assertNotSwitching();
return JSON.stringify(await exportSnapshot(prisma, env.databaseProvider));
}
/** 从 base64 快照导入(整库覆盖)。用于"快捷导入"。 */
export async function importSnapshotBase64(data: string): Promise<{ counts: Record<string, number> }> {
assertNotSwitching();
let snapshot: DatabaseSnapshot;
try {
const json = Buffer.from(data, 'base64').toString('utf8');
snapshot = JSON.parse(json) as DatabaseSnapshot;
} catch {
throw ApiError.badRequest('快照内容不是合法的 base64 JSON');
}
return restoreSnapshot(prisma, env.databaseProvider, snapshot);
}
/** SQLite 数据文件下载(仅 SQLite 模式提供;MySQL 无"单文件"概念) */
export function sqliteFilePathForDownload(): string {
if (env.databaseProvider !== 'sqlite' || !env.sqliteFilePath || !fs.existsSync(env.sqliteFilePath)) {
throw ApiError.badRequest('当前不是 SQLite 数据库或数据文件不存在');
}
return env.sqliteFilePath;
}
/* ================================================================ 状态 / 连接测试 */
export interface TableCount {
name: string;
count: number;
}
async function databaseVersion(provider: DatabaseProvider, client: typeof prisma): Promise<string> {
if (provider === 'mysql') {
const rows = (await client.$queryRawUnsafe('SELECT VERSION() AS version')) as { version?: string }[];
return rows[0]?.version ?? 'unknown';
}
const rows = (await client.$queryRawUnsafe('SELECT sqlite_version() AS version')) as {
version?: string;
}[];
return rows[0]?.version ?? 'unknown';
}
async function databaseSizeBytes(provider: DatabaseProvider, client: typeof prisma): Promise<number | null> {
if (provider === 'sqlite') {
return env.sqliteFilePath && fs.existsSync(env.sqliteFilePath) ? fs.statSync(env.sqliteFilePath).size : 0;
}
const rows = (await client.$queryRawUnsafe(
'SELECT COALESCE(SUM(data_length + index_length), 0) AS size FROM information_schema.tables WHERE table_schema = DATABASE()',
)) as { size?: bigint | number }[];
const size = rows[0]?.size;
return size === undefined ? null : Number(size);
}
export interface DatabaseStatus {
provider: DatabaseProvider;
connected: boolean;
latencyMs: number | null;
version: string | null;
sizeBytes: number | null;
/** 当前连接串的脱敏展示(MySQL 隐藏密码) */
databaseUrlMasked: string;
/** SQLite 数据文件绝对路径(MySQL 为空) */
sqliteFilePath: string;
dataDir: string;
backupDir: string;
tables: TableCount[];
backups: ReturnType<typeof listBackups>;
schedule: BackupSchedule;
/** 是否有正在进行的切换任务(期间禁止备份/导入/恢复) */
switchRunning: boolean;
}
/** 连接串脱敏:mysql://user:password@host → mysql://user:***@host */
function maskDatabaseUrl(provider: DatabaseProvider, url: string): string {
if (provider !== 'mysql') return url;
return url.replace(/(\/\/[^:/@]+:)[^@]*(@)/, '$1***$2');
}
export async function getDatabaseStatus(): Promise<DatabaseStatus> {
const startedAt = Date.now();
let connected = false;
let version: string | null = null;
try {
version = await databaseVersion(env.databaseProvider, prisma);
connected = true;
} catch {
connected = false;
}
const latencyMs = connected ? Date.now() - startedAt : null;
const delegates = prisma as unknown as Record<
(typeof SNAPSHOT_TABLES)[number],
{ count(): Promise<number> }
>;
const tables: TableCount[] = connected
? await Promise.all(
SNAPSHOT_TABLES.map(async (name): Promise<TableCount> => {
try {
return { name, count: await delegates[name].count() };
} catch {
return { name, count: -1 };
}
}),
)
: [];
return {
provider: env.databaseProvider,
connected,
latencyMs,
version,
sizeBytes: connected ? await databaseSizeBytes(env.databaseProvider, prisma).catch(() => null) : null,
databaseUrlMasked: maskDatabaseUrl(env.databaseProvider, env.databaseUrl),
sqliteFilePath: env.sqliteFilePath,
dataDir: dataDir(),
backupDir: backupDir(),
tables,
backups: listBackups(),
schedule: readSchedule(),
switchRunning: isSwitchRunning(),
};
}
export interface ConnectionTestResult {
ok: boolean;
provider: DatabaseProvider;
latencyMs: number | null;
version: string | null;
/** 新建 SQLite 库时目标文件还不存在,属于正常情况(切换时会自动建库) */
note?: string;
error?: string;
}
/** 测试任意目标库的连通性(发起切换前的第一步) */
export async function testConnection(target: TargetConnectionInput): Promise<ConnectionTestResult> {
const startedAt = Date.now();
let note: string | undefined;
let url = target.url.trim();
if (target.provider === 'sqlite') {
// 与 env.resolveSqliteUrl 同口径:相对路径以 serverRoot 为基准解析为绝对路径
const rawPath = url.slice('file:'.length).replace(/\//g, path.sep);
const absolute = path.isAbsolute(rawPath) ? rawPath : path.resolve(env.serverRoot, rawPath);
if (!fs.existsSync(absolute)) {
const parent = path.dirname(absolute);
if (!fs.existsSync(parent)) {
return { ok: false, provider: target.provider, latencyMs: null, version: null, error: `目录不存在:${parent}` };
}
note = '目标文件不存在,属新库(切换时会自动建表)';
}
url = `file:${absolute.replace(/\\/g, '/')}`;
}
let client: Awaited<ReturnType<typeof createPrismaClient>> | null = null;
try {
client = await createPrismaClient(target.provider, url);
const version = await databaseVersion(target.provider, client);
return { ok: true, provider: target.provider, latencyMs: Date.now() - startedAt, version, note };
} catch (error) {
return {
ok: false,
provider: target.provider,
latencyMs: null,
version: null,
error: error instanceof Error ? error.message : String(error),
};
} finally {
await client?.$disconnect().catch(() => undefined);
}
}
/* ================================================================ 一键切换 */
export interface SwitchStep {
name: string;
status: 'running' | 'done' | 'error';
detail?: string;
}
export interface SwitchJob {
id: string;
status: 'running' | 'done' | 'error';
target: { provider: DatabaseProvider; url: string };
steps: SwitchStep[];
error?: string;
/** 完成后必填:需要重启服务端才能让新数据库生效 */
restartRequired: boolean;
counts?: Record<string, number>;
backupName?: string;
startedAt: string;
finishedAt?: string;
}
const switchJobs = new Map<string, SwitchJob>();
function isSwitchRunning(): boolean {
for (const job of switchJobs.values()) {
if (job.status === 'running') return true;
}
return false;
}
function assertNotSwitching(): void {
if (isSwitchRunning()) {
throw ApiError.conflict('正在执行数据库切换,请等待完成后再进行备份/导入/恢复');
}
}
/** 防止路径穿越:任务 id 只由本服务生成(时间戳 + 随机数) */
function newJobId(): string {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
export function getSwitchJob(id: string): SwitchJob | null {
return switchJobs.get(id) ?? null;
}
function stepOf(job: SwitchJob, name: string): SwitchStep {
let step = job.steps.find((item) => item.name === name);
if (!step) {
step = { name, status: 'running' };
job.steps.push(step);
}
return step;
}
function finishStep(job: SwitchJob, name: string, detail?: string): void {
const step = stepOf(job, name);
step.status = 'done';
if (detail) step.detail = detail;
}
function appendDetail(job: SwitchJob, name: string, detail: string): void {
const step = stepOf(job, name);
step.detail = `${step.detail ? `${step.detail}\n` : ''}${detail}`.slice(-4000);
}
/** 校验目标库必须是空库(防误覆盖):有表即拒绝 */
async function assertTargetEmpty(target: TargetConnectionInput): Promise<string> {
let url = target.url.trim();
let sqliteAbsolute: string | null = null;
if (target.provider === 'sqlite') {
const rawPath = url.slice('file:'.length).replace(/\//g, path.sep);
const absolute = path.isAbsolute(rawPath) ? rawPath : path.resolve(env.serverRoot, rawPath);
sqliteAbsolute = absolute;
url = `file:${absolute.replace(/\\/g, '/')}`;
}
const client = await createPrismaClient(target.provider, url);
try {
const rows =
target.provider === 'sqlite'
? (await client.$queryRawUnsafe(
"SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
)) as { count?: bigint | number }[]
: (await client.$queryRawUnsafe(
'SELECT COUNT(*) AS count FROM information_schema.tables WHERE table_schema = DATABASE()',
)) as { count?: bigint | number }[];
const tableCount = Number(rows[0]?.count ?? 0);
if (tableCount > 0) {
throw ApiError.badRequest(
`目标数据库不是空库(已有 ${tableCount} 张表)。为防误覆盖,只允许迁移到空库;` +
'如需重用已有库,请先备份并清空它。',
);
}
return target.provider === 'sqlite' ? `目标文件:${sqliteAbsolute}` : '目标 MySQL 库为空';
} finally {
await client.$disconnect().catch(() => undefined);
}
}
const SCHEMA_FILE = path.join(env.serverRoot, 'prisma', 'schema.prisma');
function rewriteSchemaProvider(provider: DatabaseProvider): string {
const original = fs.readFileSync(SCHEMA_FILE, 'utf8');
const updated = original.replace(/provider\s*=\s*"(sqlite|mysql)"/, `provider = "${provider}"`);
if (updated === original) {
throw new Error('未能改写 schema.prisma 的 provider(未匹配到 provider 声明)');
}
fs.writeFileSync(SCHEMA_FILE, updated, 'utf8');
return original;
}
/** 子进程运行 Prisma CLI(generate / db push)。连接串通过环境变量显式传入,不依赖 .env。 */
function runPrismaCli(
job: SwitchJob,
stepName: string,
args: string[],
target: TargetConnectionInput,
): Promise<void> {
const candidates = [
path.join(env.serverRoot, 'node_modules', 'prisma', 'build', 'index.js'),
path.join(env.serverRoot, '..', 'node_modules', 'prisma', 'build', 'index.js'),
];
const cli = candidates.find((file) => fs.existsSync(file));
if (!cli) {
throw new Error('未找到 Prisma CLI(node_modules/prisma)。安装版请确认安装包完整。');
}
return runNodeTool(job, stepName, [cli, ...args], {
...process.env,
DATABASE_PROVIDER: target.provider,
DATABASE_URL: target.url.trim(),
});
}
/** 编译新生成的 Prisma 客户端(仅生产打包形态需要:dist 里跑的是编译产物) */
async function compileGeneratedClient(job: SwitchJob): Promise<void> {
// 开发模式(tsx 直接跑 src)不需要编译;打包形态(存在 dist/index.js)才需要
if (!fs.existsSync(path.join(env.serverRoot, 'dist', 'index.js'))) return;
const candidates = [
path.join(env.serverRoot, 'node_modules', 'typescript', 'lib', 'tsc.js'),
path.join(env.serverRoot, '..', 'node_modules', 'typescript', 'lib', 'tsc.js'),
];
const tsc = candidates.find((file) => fs.existsSync(file));
if (!tsc) {
throw new Error('未找到 TypeScript 编译器(node_modules/typescript)。安装包请确认完整。');
}
await runNodeTool(job, '编译新生成的 Prisma 客户端', [tsc, '-p', 'tsconfig.generate.json'], process.env);
}
/** 跨库迁移的数据写入走独立子进程:运行中的服务进程缓存的是旧方言的 Prisma Client */
function runApplySnapshotTool(job: SwitchJob, target: TargetConnectionInput, snapshotFile: string): Promise<Record<string, number>> {
const distTool = path.join(env.serverRoot, 'dist', 'tools', 'apply-snapshot.js');
const srcTool = path.join(env.serverRoot, 'src', 'tools', 'apply-snapshot.ts');
const tsxCli = path.join(env.serverRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs');
let args: string[];
if (fs.existsSync(distTool)) {
args = [distTool];
} else if (fs.existsSync(tsxCli) && fs.existsSync(srcTool)) {
args = [tsxCli, srcTool];
} else {
throw new Error('未找到快照导入工具(dist/tools/apply-snapshot.js),请先构建服务端');
}
args.push('--provider', target.provider, '--url', target.url.trim(), '--file', snapshotFile);
const capture: { text: string } = { text: '' };
return runNodeTool(job, '迁移数据到目标库', args, process.env, capture).then(() => {
const lines = capture.text.trim().split(/\r?\n/).filter(Boolean);
const last = lines[lines.length - 1] ?? '';
try {
const parsed = JSON.parse(last) as { ok?: boolean; counts?: Record<string, number>; error?: string };
if (!parsed.ok) throw new Error(parsed.error ?? '快照导入工具返回失败');
return parsed.counts ?? {};
} catch (error) {
throw new Error(
`无法解析快照导入工具的输出:${error instanceof Error ? error.message : String(error)}(输出尾部:${last.slice(0, 400)})`,
);
}
});
}
function runNodeTool(
job: SwitchJob,
stepName: string,
args: string[],
nodeEnv: NodeJS.ProcessEnv,
capture?: { text: string },
timeoutMs = 10 * 60_000,
): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, args, {
cwd: env.serverRoot,
env: nodeEnv,
windowsHide: true,
});
let output = '';
const collect = (chunk: Buffer | string): void => {
output += String(chunk);
if (output.length > 20_000) output = output.slice(-20_000);
if (capture) capture.text = output;
};
child.stdout?.on('data', collect);
child.stderr?.on('data', collect);
const timer = setTimeout(() => {
child.kill();
reject(new Error(`${stepName} 超时(${Math.round(timeoutMs / 60_000)} 分钟)`));
}, timeoutMs);
child.on('error', (error) => {
clearTimeout(timer);
reject(new Error(`${stepName} 启动失败:${error.message}`));
});
child.on('exit', (code) => {
clearTimeout(timer);
if (code === 0) {
appendDetail(job, stepName, output.trim().split(/\r?\n/).slice(-6).join('\n'));
resolve();
return;
}
const tail = output.trim().split(/\r?\n/).slice(-10).join('\n');
reject(new Error(`${stepName} 失败(exit ${code ?? 'signal'}):${tail || '无输出'}`));
});
});
}
/** 切换完成后改写 .env(DATABASE_PROVIDER / DATABASE_URL 两行) */
function updateEnvFiles(target: TargetConnectionInput): string {
const candidates = [path.join(env.serverRoot, '.env'), path.join(env.serverRoot, '..', '.env')];
const existing = candidates.filter((file) => fs.existsSync(file));
const files = existing.length > 0 ? existing : [candidates[0]];
const updated: string[] = [];
for (const file of files) {
let content = fs.readFileSync(file, 'utf8');
const providerLine = `DATABASE_PROVIDER=${target.provider}`;
const urlLine = `DATABASE_URL="${target.url.trim()}"`;
if (/^DATABASE_PROVIDER=.*$/m.test(content)) {
content = content.replace(/^DATABASE_PROVIDER=.*$/m, providerLine);
} else {
content = `${content.trimEnd()}\n${providerLine}\n`;
}
if (/^DATABASE_URL=.*$/m.test(content)) {
content = content.replace(/^DATABASE_URL=.*$/m, urlLine);
} else {
content = `${content.trimEnd()}\n${urlLine}\n`;
}
fs.writeFileSync(file, content, 'utf8');
updated.push(file);
}
return updated.join(' , ');
}
/**
* 发起一键切换(异步任务)。流程与失败语义见模块注释:
* 数据写入全部成功后才改写 .env;中途失败回滚 schema.prisma,现有库不受影响。
*/
export function startSwitch(target: TargetConnectionInput): SwitchJob {
if (isSwitchRunning()) {
throw ApiError.conflict('已有切换任务正在进行,请等待完成');
}
if (target.provider === env.databaseProvider && target.url.trim() === env.databaseUrl) {
throw ApiError.badRequest('目标数据库与当前使用的数据库相同');
}
const job: SwitchJob = {
id: newJobId(),
status: 'running',
target: { provider: target.provider, url: target.url.trim() },
steps: [],
restartRequired: false,
startedAt: new Date().toISOString(),
};
switchJobs.set(job.id, job);
// 不 await:HTTP 立即返回 jobId,前端轮询 GET /switch/jobs/:id
void runSwitch(job, target).catch(() => undefined);
return job;
}
async function runSwitch(job: SwitchJob, target: TargetConnectionInput): Promise<void> {
let schemaOriginal: string | null = null;
let snapshotFile: string | null = null;
try {
// 1. 先备份当前库(切换失败时的兜底)
stepOf(job, '备份当前数据库');
const backup = await createBackup('manual');
job.backupName = backup.name;
finishStep(job, '备份当前数据库', `备份文件 ${backup.name}`);
// 2. 导出快照到临时文件(供子进程写入目标库)
stepOf(job, '导出当前数据');
fs.mkdirSync(backupDir(), { recursive: true });
snapshotFile = path.join(backupDir(), `switch-${job.id}.json`);
fs.writeFileSync(snapshotFile, await exportSnapshotJson(), 'utf8');
finishStep(job, '导出当前数据');
// 3. 测试目标连接
stepOf(job, '测试目标库连接');
const test = await testConnection(target);
if (!test.ok) {
throw new Error(test.error ?? '目标库连接失败');
}
finishStep(job, '测试目标库连接', test.note ?? `连接正常(${test.latencyMs ?? '-'}ms,${test.version ?? '-'})`);
// 4. 目标库必须为空
stepOf(job, '检查目标库为空');
finishStep(job, '检查目标库为空', await assertTargetEmpty(target));
// 5. 改写 schema.prisma 的 provider(失败时回滚)
stepOf(job, '改写 schema.prisma');
schemaOriginal = rewriteSchemaProvider(target.provider);
finishStep(job, '改写 schema.prisma', `provider → ${target.provider}`);
// 6. 生成新方言的 Prisma 客户端
stepOf(job, '生成 Prisma 客户端');
await runPrismaCli(job, '生成 Prisma 客户端', ['generate'], target);
finishStep(job, '生成 Prisma 客户端');
// 7. 生产形态:把生成出来的 .ts 客户端编译进 dist(dev 由 tsx 直接跑 src,跳过)
await compileGeneratedClient(job);
// 8. 目标库建表(Prisma 7 的 db push 不再自动生成客户端,generate 是独立步骤 6)
stepOf(job, '在目标库建表');
await runPrismaCli(job, '在目标库建表', ['db', 'push', '--accept-data-loss'], target);
finishStep(job, '在目标库建表');
// 9. 迁移数据(独立子进程加载新方言客户端)
stepOf(job, '迁移数据到目标库');
const counts = await runApplySnapshotTool(job, target, snapshotFile);
job.counts = counts;
const total = Object.values(counts).reduce((sum, count) => sum + count, 0);
finishStep(job, '迁移数据到目标库', `共写入 ${total} 行`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// 失败发生在哪一步,就把那一步标成 error(UI 上能直接看到卡在哪)
const running = [...job.steps].reverse().find((item) => item.status === 'running');
if (running) {
running.status = 'error';
running.detail = message.slice(0, 1000);
}
job.error = message;
job.status = 'error';
job.finishedAt = new Date().toISOString();
// 回滚 schema(.env 尚未改写,现有库不受影响)
if (schemaOriginal !== null) {
try {
fs.writeFileSync(SCHEMA_FILE, schemaOriginal, 'utf8');
} catch (rollbackError) {
logger.error('回滚 schema.prisma 失败,请手工核对 provider 设置', rollbackError);
}
}
logger.error(`数据库切换失败(job=${job.id}):${message}`);
return;
} finally {
if (snapshotFile) fs.rmSync(snapshotFile, { force: true });
}
// 10. 全部成功后才切换 .env(失败已在上面的 catch 里返回)
try {
stepOf(job, '写入 .env 配置');
const files = updateEnvFiles(target);
finishStep(job, '写入 .env 配置', files);
job.status = 'done';
job.restartRequired = true;
job.finishedAt = new Date().toISOString();
logger.info(`数据库切换任务完成(job=${job.id})→ ${target.provider},重启服务端后生效`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
job.error = `数据已迁移到目标库,但写入 .env 失败:${message}(请手工修改 .env 的 DATABASE_PROVIDER / DATABASE_URL 后重启)`;
job.status = 'error';
job.finishedAt = new Date().toISOString();
}
}
+2
View File
@@ -2,6 +2,7 @@ import { authModule } from './auth/auth.module.js';
import { callsModule } from './calls/calls.module.js';
import { classesModule } from './classes/classes.module.js';
import { coursesModule } from './courses/courses.module.js';
import { databaseModule } from './database/database.module.js';
import { dashboardModule } from './dashboard/dashboard.module.js';
import { gradesModule } from './grades/grades.module.js';
import { homeworksModule } from './homeworks/homeworks.module.js';
@@ -34,4 +35,5 @@ export const apiModules: ApiModule[] = [
studentsModule,
teachersModule,
dashboardModule,
databaseModule,
].filter((module) => module.enabled !== false);
@@ -0,0 +1,59 @@
import fs from 'node:fs';
import process from 'node:process';
import { createPrismaClient, type DatabaseProvider } from '../lib/db.js';
import { assertValidSnapshot, restoreSnapshot, type DatabaseSnapshot } from '../lib/snapshot.js';
/**
* 快照导入工具(供跨库迁移的**子进程**调用,也可手工使用)。
*
* 为什么是子进程:切换数据库时 `prisma generate` 会重新生成 Prisma Client(SQL 方言变了),
* 而正在运行的服务进程里缓存的是**旧方言**的客户端模块 —— 只有新进程才能加载重新生成的客户端,
* 把快照写进新库。用法:
*
* node dist/tools/apply-snapshot.js --provider mysql --url "mysql://..." --file snapshot.json
*
* stdout 最后一行输出 JSON:{ ok, counts },父进程据此回填任务进度;失败时 exit 1 并输出原因。
* 注意 --file 是**未压缩**的 JSON(父进程负责解压到临时文件)。
*/
interface ToolArgs {
provider: DatabaseProvider;
url: string;
file: string;
}
function parseArgs(argv: readonly string[]): ToolArgs {
const read = (name: string): string => {
const index = argv.indexOf(`--${name}`);
const value = index >= 0 ? argv[index + 1] : undefined;
if (!value) throw new Error(`缺少参数 --${name}`);
return value;
};
const provider = read('provider');
if (provider !== 'sqlite' && provider !== 'mysql') {
throw new Error(`不支持的 provider:${provider}(只支持 sqlite / mysql)`);
}
return { provider, url: read('url'), file: read('file') };
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const raw = fs.readFileSync(args.file, 'utf8');
const snapshot = JSON.parse(raw) as DatabaseSnapshot;
assertValidSnapshot(snapshot);
const client = await createPrismaClient(args.provider, args.url);
try {
const { counts } = await restoreSnapshot(client, args.provider, snapshot);
process.stdout.write(`${JSON.stringify({ ok: true, counts })}\n`);
} finally {
await client.$disconnect().catch(() => undefined);
}
}
main().catch((error: unknown) => {
process.stdout.write(
`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`,
);
process.exit(1);
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/generated/**/*.ts"]
}