Files
ClassHelper/packages/server/src/config/env.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

178 lines
8.0 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 path from 'node:path';
import { fileURLToPath } from 'node:url';
import { config as loadEnv } from 'dotenv';
import { z } from 'zod';
const currentDir = path.dirname(fileURLToPath(import.meta.url));
/**
* packages/server 根目录。
* 源码运行时为 src/config -> ../..;编译后为 dist/config -> ../..,两者一致。
*/
export const serverRoot = path.resolve(currentDir, '..', '..');
// 显式按包根目录加载 .env,避免受启动时 cwd 影响。
// 打包安装后配置文件放在安装根目录(server 的上一级),因此按顺序尝试两处;
// dotenv 默认不覆盖已存在的变量,所以先匹配到的文件优先生效。
loadEnv({ path: path.join(serverRoot, '.env') });
loadEnv({ path: path.join(serverRoot, '..', '.env') });
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(4000),
/** 监听地址:生产环境建议 0.0.0.0(容器/内网访问),本地开发可用 127.0.0.1 */
HOST: z.string().default('0.0.0.0'),
DATABASE_PROVIDER: z.enum(['sqlite', 'mysql']).default('sqlite'),
DATABASE_URL: z.string().min(1).default('file:./prisma/dev.db'),
JWT_SECRET: z.string().min(16),
JWT_EXPIRES_IN: z.string().default('7d'),
CORS_ORIGIN: z.string().default('*'),
BCRYPT_ROUNDS: z.coerce.number().int().min(4).max(15).default(10),
/** 新建班级时的默认班级密码(学生端班级账号登录用),管理员可随时重置 */
DEFAULT_CLASS_PASSWORD: z.string().min(6).default('123456'),
/** 新建/重置教师账号时的默认密码(管理员录入教师用) */
DEFAULT_TEACHER_PASSWORD: z.string().min(6).default('123456'),
TERM_START_DATE: z.string().default(''),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
/** 反向代理层数(nginx / 容器场景一般填 1),用于正确识别客户端 IP 与协议 */
TRUST_PROXY: z.string().default('false'),
/** Web 管理端构建产物目录;留空则自动探测 ../web-admin/dist 或 ./web */
WEB_DIST_DIR: z.string().default(''),
/** 是否启用接口限流(压测时可置 false) */
RATE_LIMIT_ENABLED: z
.string()
.default('true')
.transform((value) => value !== 'false' && value !== '0'),
/** 启动时是否校验数据库连通性 */
STARTUP_DB_CHECK: z
.string()
.default('true')
.transform((value) => value !== 'false' && value !== '0'),
/** 自动初始化:数据库为空时是否自动执行迁移(安装程序首启动用) */
AUTO_MIGRATE: z
.string()
.default('false')
.transform((value) => value === 'true' || value === '1'),
/** 进程 PID 文件(相对路径以 server 根目录为基准),供安装包的启停脚本使用 */
PID_FILE: z.string().default(''),
/** 首次初始化(空库)时创建的管理员账号 */
INITIAL_ADMIN_USERNAME: z.string().min(3).default('admin'),
INITIAL_ADMIN_PASSWORD: z.string().min(6).default('admin123'),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
const issues = parsed.error.issues
.map((issue) => ` - ${issue.path.join('.') || '(root)'}: ${issue.message}`)
.join('\n');
throw new Error(`环境变量校验失败,请检查 packages/server/.env(可从 .env.example 复制):\n${issues}`);
}
const raw = parsed.data;
/** 把 file:./prisma/dev.db 解析为绝对路径,保证任何 cwd 下都指向同一个数据库文件 */
function resolveSqliteUrl(url: string): string {
if (!url.startsWith('file:')) return url;
const filePath = url.slice('file:'.length);
const absolute = path.isAbsolute(filePath) ? filePath : path.resolve(serverRoot, filePath);
return `file:${absolute.replace(/\\/g, '/')}`;
}
/** 从 file: URL 中取出真实的文件系统路径(供启动前建目录等使用) */
function sqliteFilePathOf(url: string): string {
const withoutPrefix = url.startsWith('file:') ? url.slice('file:'.length) : url;
return withoutPrefix.replace(/\\/g, path.sep).replace(/\//g, path.sep);
}
const isSqlite = raw.DATABASE_PROVIDER === 'sqlite';
const isProduction = raw.NODE_ENV === 'production';
const resolvedDatabaseUrl = isSqlite ? resolveSqliteUrl(raw.DATABASE_URL) : raw.DATABASE_URL;
/** SQLite 数据文件的绝对路径(MySQL 时为空字符串),供启动前创建目录使用 */
const sqliteFilePath = isSqlite ? sqliteFilePathOf(resolvedDatabaseUrl) : '';
/** 生产环境配置自检:不安全的默认值直接拒绝启动,避免把开发配置带上生产 */
const INSECURE_SECRET_MARKERS = ['change-me', 'changeme', 'secret', 'classhelper-dev'];
if (isProduction) {
const problems: string[] = [];
if (raw.JWT_SECRET.length < 32) problems.push('JWT_SECRET 长度必须 ≥ 32 位');
if (INSECURE_SECRET_MARKERS.some((marker) => raw.JWT_SECRET.toLowerCase().includes(marker))) {
problems.push('JWT_SECRET 仍是示例/开发用值,请重新生成');
}
if (isSqlite && raw.DATABASE_URL.includes('dev.db')) {
problems.push('生产环境仍在使用 dev.db,请指定正式的数据文件或改用 MySQL');
}
if (problems.length > 0) {
throw new Error(
`生产环境(NODE_ENV=production)配置检查未通过:\n${problems.map((item) => ` - ${item}`).join('\n')}\n` +
'请修改 packages/server/.env 后重试。',
);
}
/**
* CORS 默认值告警(**不拦截启动**)。
*
* `CORS_ORIGIN=*` 与 `credentials: true` 同时开启时,cors 中间件会反射请求方 Origin 并带上
* `Access-Control-Allow-Credentials: true`。本项目的鉴权走 `Authorization` 头(不是 cookie),
* 第三方页面拿不到用户的 token,因此实际风险有限;但"允许任意站点带凭据访问"是没有必要的暴露面,
* 生产部署应填成实际域名。这里只告警:真要卡死会挡住"教室机 file:// 页面直连后端"这类合法部署
* (那类请求的 Origin 是 `file://`,无法提前枚举),代价不值得。
*/
if (raw.CORS_ORIGIN.trim() === '*') {
console.warn(
'⚠ CORS_ORIGIN=* 且已开启 credentials:生产环境建议改成具体来源(逗号分隔),' +
'例如 CORS_ORIGIN=https://your-domain.example(教室机 file:// 客户端不受影响)。',
);
}
}
/** trust proxy 支持 false / 数字跳数 / loopback 等字符串形式 */
function resolveTrustProxy(value: string): boolean | number | string {
if (value === 'false' || value === '') return false;
if (value === 'true') return true;
if (/^\d+$/.test(value)) return Number(value);
return value;
}
export const env = {
nodeEnv: raw.NODE_ENV,
isProduction,
isDevelopment: raw.NODE_ENV === 'development',
port: raw.PORT,
host: raw.HOST,
databaseProvider: raw.DATABASE_PROVIDER,
/** 已规范化为绝对路径(SQLite)/ 原始连接串(MySQL) */
databaseUrl: resolvedDatabaseUrl,
/** SQLite 数据文件绝对路径(MySQL 时为空) */
sqliteFilePath,
rawDatabaseUrl: raw.DATABASE_URL,
jwtSecret: raw.JWT_SECRET,
jwtExpiresIn: raw.JWT_EXPIRES_IN,
/** true 表示允许所有来源,否则为白名单数组 */
corsOrigins:
raw.CORS_ORIGIN === '*'
? true
: raw.CORS_ORIGIN.split(',')
.map((item) => item.trim())
.filter(Boolean),
bcryptRounds: raw.BCRYPT_ROUNDS,
/** 新建班级时的默认班级密码(班级账号登录) */
defaultClassPassword: raw.DEFAULT_CLASS_PASSWORD,
/** 新建/重置教师账号时的初始密码 */
defaultTeacherPassword: raw.DEFAULT_TEACHER_PASSWORD,
termStartDate: raw.TERM_START_DATE,
logLevel: raw.LOG_LEVEL,
trustProxy: resolveTrustProxy(raw.TRUST_PROXY),
webDistDir: raw.WEB_DIST_DIR,
rateLimitEnabled: raw.RATE_LIMIT_ENABLED,
startupDbCheck: raw.STARTUP_DB_CHECK,
autoMigrate: raw.AUTO_MIGRATE,
/** PID 文件的绝对路径(未配置时为空字符串) */
pidFile: raw.PID_FILE ? path.resolve(serverRoot, raw.PID_FILE) : '',
initialAdminUsername: raw.INITIAL_ADMIN_USERNAME,
initialAdminPassword: raw.INITIAL_ADMIN_PASSWORD,
serverRoot,
} as const;
export type AppEnv = typeof env;