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 验收表里的实测记录。
205 lines
8.5 KiB
TypeScript
205 lines
8.5 KiB
TypeScript
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;
|
||
}
|