Files
ClassHelper/packages/server/src/app.ts
T
LoveChengke 9a38f86cd3 feat(server,web,desktop,shared,deploy,docs): Linux 一键安装与 classhelper 运维命令、更新检查、图表去 echarts,版本统一 1.1.0
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 验收表里的实测记录。
2026-10-05 16:39:38 +08:00

205 lines
8.5 KiB
TypeScript
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.
import express, { type Express, type Request, type Response } from 'express';
import compression from 'compression';
import cors from 'cors';
import { API_PREFIX } from '@classhelper/shared';
import { env } from './config/env.js';
import { pingDatabase } from './lib/db.js';
import { sendOk } from './lib/http.js';
import { logger } from './lib/logger.js';
import { APP_VERSION } from './lib/version.js';
import { mountWebAdmin, resolveWebDistDir } from './lib/web-static.js';
import { errorHandler, notFoundHandler } from './middleware/error.js';
import { apiRateLimiter, loginRateLimiter, requestLogger, securityHeaders } from './middleware/security.js';
import { apiModules } from './modules/registry.js';
/** 存活探针:进程活着即 200(供容器 livenessProbe / 守护进程使用) */
function livenessHandler(_req: Request, res: Response): void {
res.status(200).json({
success: true,
data: { status: 'alive', uptimeSeconds: Math.round(process.uptime()) },
message: 'ok',
});
}
/** 就绪探针:数据库可连才 200,否则 503(供负载均衡摘流 / 安装程序自检) */
async function readinessHandler(_req: Request, res: Response): Promise<void> {
const connected = await pingDatabase();
if (!connected) {
res
.status(503)
.json({ success: false, data: { database: false }, message: '数据库不可用', code: 'NOT_READY' });
return;
}
res.status(200).json({ success: true, data: { database: true }, message: 'ready' });
}
async function healthHandler(_req: Request, res: Response): Promise<void> {
sendOk(
res,
{
status: 'ok',
// 版本号取自 package.json(见 lib/version.ts):更新检查要拿它跟 GitHub 上的 Release 比
version: APP_VERSION,
env: env.nodeEnv,
uptimeSeconds: Math.round(process.uptime()),
database: {
provider: env.databaseProvider,
connected: await pingDatabase(),
},
modules: apiModules.map((module) => module.name),
webAdmin: env.webDistDir ? 'configured' : 'auto',
},
'服务运行正常',
);
}
/**
* 根路径。
* - 已托管 Web 管理端且客户端接受 HTML:交给静态资源与 SPA 回退处理(即打开管理端首页)
* - 否则返回服务信息页(浏览器)或 JSON(API 客户端)
*/
function createRootHandler(webDistDir: string | null) {
return (req: Request, res: Response, next: express.NextFunction): void => {
if (webDistDir && req.accepts(['html', 'json']) === 'html') {
next();
return;
}
if (req.accepts(['html', 'json']) === 'json') {
sendOk(
res,
{
service: 'class-helper-server',
version: APP_VERSION,
environment: env.nodeEnv,
apiPrefix: API_PREFIX,
socket: '/socket.io',
webAdmin: webDistDir ? '/' : 'http://127.0.0.1:5173',
health: `${API_PREFIX}/health`,
liveness: '/healthz',
readiness: '/readyz',
modules: apiModules.map((module) => `${API_PREFIX}${module.basePath}`),
},
'服务运行正常,API 前缀为 /api',
);
return;
}
res.type('html').send(renderLandingPage(Boolean(webDistDir)));
};
}
function renderLandingPage(webAdminServed: boolean): string {
const moduleRows = apiModules
.map((module) => `<li><code>${API_PREFIX}${module.basePath}</code> <span>${module.name}</span></li>`)
.join('');
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>班级小助手 · 后端服务</title>
<style>
body { margin: 0; padding: 40px; font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif; background: #f5f7fa; color: #303133; }
.card { max-width: 760px; margin: 0 auto; background: #fff; border: 1px solid #e4e7ed; border-radius: 12px; padding: 28px 32px; }
h1 { margin: 0 0 6px; font-size: 20px; }
p.sub { margin: 0 0 8px; color: #909399; font-size: 13px; }
.ok { display: inline-block; padding: 2px 10px; border-radius: 999px; background: #f0f9eb; color: #67c23a; font-size: 12px; }
ul { columns: 2; padding-left: 18px; margin: 12px 0 0; }
li { margin: 6px 0; font-size: 13px; }
li span { color: #c0c4cc; }
code { background: #f5f7fa; padding: 1px 6px; border-radius: 4px; font-size: 12px; }
a { color: #409eff; text-decoration: none; }
.hint { margin-top: 22px; padding-top: 16px; border-top: 1px dashed #e4e7ed; font-size: 13px; color: #606266; line-height: 1.9; }
.btn { display: inline-block; margin-top: 4px; padding: 8px 16px; background: #409eff; color: #fff !important; border-radius: 6px; font-size: 13px; }
</style>
</head>
<body>
<div class="card">
<h1>班级小助手 · 后端服务 <span class="ok">running</span></h1>
<p class="sub">版本 ${APP_VERSION} · 运行环境 ${env.nodeEnv} · 数据库 ${env.databaseProvider}</p>
<p class="sub">
健康检查 <a href="${API_PREFIX}/health">${API_PREFIX}/health</a> ·
存活 /healthz · 就绪 /readyz
</p>
${webAdminServed ? '<a class="btn" href="/">打开 Web 管理端</a>' : '<p class="sub">Web 管理端未随本服务托管,请单独启动(pnpm dev:web → http://127.0.0.1:5173)。</p>'}
<strong>已挂载模块(${apiModules.length} 个)</strong>
<ul>${moduleRows}</ul>
<div class="hint">
WebSocket 端点:<code>ws://${env.host === '0.0.0.0' ? '本机地址' : env.host}:${env.port}/socket.io</code><br />
本服务只提供 REST API 与 WebSocket;学生端请使用「班级小助手」桌面客户端。
</div>
</div>
</body>
</html>`;
}
/** 组装 Express 应用:安全中间件 -> 健康检查 -> 模块路由 -> Web 静态托管 -> 404 -> 统一错误处理 */
export function createApp(): Express {
const app = express();
app.disable('x-powered-by');
// 反向代理场景下正确识别客户端 IP(限流、日志依赖)
app.set('trust proxy', env.trustProxy);
const webDistDir = resolveWebDistDir();
app.use(securityHeaders());
app.use(compression());
app.use(cors({ origin: env.corsOrigins, credentials: true }));
/**
* 请求体上限:表格导入是把文件以 **base64** 放进 JSON 的,base64 约为原文件的 4/3,
* 再算上 JSON 包装,8MB 的表格文件约需 11MB 请求体。
* 这里必须留够余量,否则「导入模板写的 8MB 上限」永远到不了(文件到 1.5MB 就被 body-parser 拦掉),
* 而且用户看到的是一句 500「服务器内部错误:request entity too large」——
* 与 `table-import.service.ts` 的 MAX_IMPORT_BYTES(8MB) 校验完全对不上。
*/
app.use(express.json({ limit: '12mb' }));
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
app.use(requestLogger());
// 探针(不参与限流)
app.get('/healthz', livenessHandler);
app.get('/readyz', (req, res) => {
void readinessHandler(req, res);
});
app.get(`${API_PREFIX}/health`, (req, res) => {
void healthHandler(req, res);
});
app.get('/health', (req, res) => {
void healthHandler(req, res);
});
// 根路径信息页(Web 管理端托管时让位给前端首页)
app.get('/', createRootHandler(webDistDir));
// 浏览器会自动请求 favicon;有前端产物时交给静态托管处理
if (!webDistDir) {
app.get('/favicon.ico', (_req, res) => {
res.status(204).end();
});
}
// 限流:先挂通用限流,再挂更严格的登录限流(后挂者决定响应头里的策略,便于观察暴力破解防护)
app.use(API_PREFIX, apiRateLimiter());
// 登录限流必须覆盖**两个**登录入口:`/auth/login`(教师/管理员)与 `/auth/class-login`(学生端
// 班级账号)。后者的密码默认就是 123456,漏掉它等于形同虚设 —— 这里用数组形式挂载,
// 两个路径共享同一个计数器(同一 IP 对两种登录的失败次数合并计算)。
app.use([`${API_PREFIX}/auth/login`, `${API_PREFIX}/auth/class-login`], loginRateLimiter());
// 模块化装配:注册表里的每个模块挂载到 /api<basePath>
for (const module of apiModules) {
const mountPath = `${API_PREFIX}${module.basePath}`;
app.use(mountPath, module.router);
logger.info(`已挂载模块 ${module.name.padEnd(14)} -> ${mountPath}`);
}
// Web 管理端静态资源 + SPA 回退(必须放在 API 之后)
mountWebAdmin(app);
app.use(notFoundHandler);
app.use(errorHandler);
return app;
}