feat: 生产化改造(服务端安装程序/PWA/Docker+HTTPS)并修复侧边栏点击无反应、SPA 静态托管 dotfiles 两个缺陷

- 修复 web-admin 与 desktop-client 侧边栏点击无反应:el-menu 的 index 是路径,不能当路由名使用
- 修复 Web 产物目录含点号目录(如 .cache)时 res.sendFile 因 dotfiles:ignore 返回 500
- 后端生产模式:静态托管 Web 端 + SPA 回退、helmet、限流(通用/登录)、trust proxy、
  /healthz 与 /readyz、请求耗时日志、生产环境变量强校验、首启动自动迁移与创建管理员、PID 文件
- Web 管理端 PWA:manifest + service worker + 图标,可在浏览器安装为应用
- 新增服务端 + Web 管理端 Windows 安装程序(内置 Node 运行时、启停与服务脚本、开机自启、卸载保留数据)
- 新增 Docker/Compose/Nginx 部署样例与 docs/production.md
- 新增 UI 回归测试:Web 端 Electron 驱动真实点击 7 项、客户端冒烟 11 项(含侧边栏点击与离线回退)
This commit is contained in:
LoveChengke committed 2026-09-12 00:20:21 +08:00
1 parent 6e4c7c1050
commit 5545ef6f55
46 files changed
+3020 -152

No files matched your search

+86 -7
View File
@@ -1,24 +1,90 @@
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';
/** 数据库自检与可选的首次初始化 */
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);
if (!(await pingDatabase())) {
logger.warn('数据库连接失败:请先执行 pnpm db:migrate 与 pnpm db:seed');
/** 写入 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);
}
}
httpServer.listen(env.port, () => {
logger.info(`班级小助手后端已启动:http://127.0.0.1:${env.port}${API_PREFIX}`);
logger.info(`健康检查:http://127.0.0.1:${env.port}${API_PREFIX}/health`);
logger.info(`WebSocket:ws://127.0.0.1:${env.port}/socket.io`);
logger.info(`运行环境:${env.nodeEnv},数据库:${env.databaseProvider}`);
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`);
});
let shuttingDown = false;
@@ -28,9 +94,18 @@ async function shutdown(signal: string): Promise<void> {
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);
}
@@ -44,3 +119,7 @@ process.on('SIGTERM', () => {
process.on('unhandledRejection', (reason) => {
logger.error('未处理的 Promise 拒绝', reason);
});
process.on('uncaughtException', (error) => {
logger.error('未捕获异常,进程即将退出', error);
void shutdown('uncaughtException');
});