Files
ClassHelper/packages/server/src/config/env.ts
T

164 lines
7.3 KiB
TypeScript
Raw Normal View History

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_STUDENT_PASSWORD: z.string().min(6).default('123456'),
/** 新建班级时的默认班级密码(学生端班级账号登录用),管理员可随时重置 */
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 后重试。',
);
}
}
/** 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,
/** 新增学生时未指定密码时的初始密码 */
defaultStudentPassword: raw.DEFAULT_STUDENT_PASSWORD,
/** 新建班级时的默认班级密码(班级账号登录) */
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;