- 服务端新增 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 条),属时间相关用例,
与本次改动无关。
129 lines
4.3 KiB
TypeScript
129 lines
4.3 KiB
TypeScript
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import { createServer } from 'node:http';
|
||
import { API_PREFIX } from '@classhelper/shared';
|
||
import { createApp } from './app.js';
|
||
import { env } from './config/env.js';
|
||
import { disconnectPrisma, pingDatabase } from './lib/db.js';
|
||
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> {
|
||
// SQLite:确保数据文件所在目录存在,否则 libsql 会以 SQLITE_CANTOPEN(14) 失败
|
||
if (env.sqliteFilePath) {
|
||
try {
|
||
fs.mkdirSync(path.dirname(env.sqliteFilePath), { recursive: true });
|
||
} catch (error) {
|
||
logger.warn(`创建数据目录失败:${path.dirname(env.sqliteFilePath)}`, error);
|
||
}
|
||
}
|
||
|
||
if (env.autoMigrate) {
|
||
try {
|
||
const result = await ensureSchema();
|
||
logger.info(`数据库初始化:${result.detail}`);
|
||
const admin = await ensureInitialAdmin();
|
||
if (admin.created) logger.info('可使用该账号登录 Web 管理端,并立即修改密码');
|
||
} catch (error) {
|
||
logger.error('数据库初始化失败', error);
|
||
}
|
||
}
|
||
|
||
if (!env.startupDbCheck) return;
|
||
|
||
if (await pingDatabase()) {
|
||
logger.info('数据库连接正常');
|
||
return;
|
||
}
|
||
|
||
logger.error(
|
||
'数据库连接失败:请检查 DATABASE_URL,并确认已执行迁移(pnpm db:migrate / prisma migrate deploy)',
|
||
);
|
||
if (env.isProduction) {
|
||
logger.error('生产环境启动中止:数据库不可用时不应对外提供服务');
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
await prepareDatabase();
|
||
|
||
const app = createApp();
|
||
const httpServer = createServer(app);
|
||
const io = initRealtime(httpServer);
|
||
|
||
/** 写入 PID 文件(安装包的 start.cmd / stop.cmd 依赖它精确控制进程) */
|
||
function writePidFile(): void {
|
||
if (!env.pidFile) return;
|
||
try {
|
||
fs.mkdirSync(path.dirname(env.pidFile), { recursive: true });
|
||
fs.writeFileSync(env.pidFile, String(process.pid), 'utf8');
|
||
} catch (error) {
|
||
logger.warn('写入 PID 文件失败', error);
|
||
}
|
||
}
|
||
|
||
function removePidFile(): void {
|
||
if (!env.pidFile) return;
|
||
try {
|
||
if (fs.existsSync(env.pidFile)) fs.unlinkSync(env.pidFile);
|
||
} catch {
|
||
// 忽略清理失败
|
||
}
|
||
}
|
||
|
||
httpServer.listen(env.port, env.host, () => {
|
||
writePidFile();
|
||
const displayHost = env.host === '0.0.0.0' ? '127.0.0.1' : env.host;
|
||
const webDir = resolveWebDistDir();
|
||
logger.info(`班级小助手后端已启动:http://${displayHost}:${env.port}${API_PREFIX}`);
|
||
logger.info(`运行环境:${env.nodeEnv} · 监听:${env.host}:${env.port} · 数据库:${env.databaseProvider}`);
|
||
if (webDir) {
|
||
logger.info(`Web 管理端:http://${displayHost}:${env.port}/(静态目录 ${webDir})`);
|
||
}
|
||
logger.info(`探针:/healthz(存活) · /readyz(就绪) · ${API_PREFIX}/health(详情)`);
|
||
logger.info(`WebSocket:ws://${displayHost}:${env.port}/socket.io`);
|
||
// 数据库管理模块的定时备份调度(配置与"到点时间"持久化在 data/database-settings.json)
|
||
startBackupScheduler();
|
||
});
|
||
|
||
let shuttingDown = false;
|
||
|
||
async function shutdown(signal: string): Promise<void> {
|
||
if (shuttingDown) return;
|
||
shuttingDown = true;
|
||
logger.info(`收到 ${signal},正在优雅关闭...`);
|
||
|
||
// 停止接收新连接,并给在途请求 5 秒收尾时间
|
||
const forceExit = setTimeout(() => {
|
||
logger.warn('关闭超时,强制退出');
|
||
process.exit(1);
|
||
}, 5000);
|
||
forceExit.unref();
|
||
|
||
await new Promise<void>((resolve) => io.close(() => resolve()));
|
||
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
|
||
await disconnectPrisma();
|
||
removePidFile();
|
||
clearTimeout(forceExit);
|
||
logger.info('已关闭所有连接');
|
||
process.exit(0);
|
||
}
|
||
|
||
process.on('SIGINT', () => {
|
||
void shutdown('SIGINT');
|
||
});
|
||
process.on('SIGTERM', () => {
|
||
void shutdown('SIGTERM');
|
||
});
|
||
process.on('unhandledRejection', (reason) => {
|
||
logger.error('未处理的 Promise 拒绝', reason);
|
||
});
|
||
process.on('uncaughtException', (error) => {
|
||
logger.error('未捕获异常,进程即将退出', error);
|
||
void shutdown('uncaughtException');
|
||
});
|