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:
1 parent
6e4c7c1050
commit
5545ef6f55
46 files changed
+3020
-152
No files matched your search
@@ -10,12 +10,17 @@ const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
*/
|
||||
export const serverRoot = path.resolve(currentDir, '..', '..');
|
||||
|
||||
// 显式按包根目录加载 .env,避免受启动时 cwd 影响
|
||||
// 显式按包根目录加载 .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),
|
||||
@@ -25,6 +30,30 @@ const envSchema = z.object({
|
||||
DEFAULT_STUDENT_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);
|
||||
@@ -46,16 +75,57 @@ function resolveSqliteUrl(url: string): string {
|
||||
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: raw.NODE_ENV === 'production',
|
||||
isProduction,
|
||||
isDevelopment: raw.NODE_ENV === 'development',
|
||||
port: raw.PORT,
|
||||
host: raw.HOST,
|
||||
databaseProvider: raw.DATABASE_PROVIDER,
|
||||
/** 已规范化为绝对路径(SQLite)/ 原始连接串(MySQL) */
|
||||
databaseUrl: isSqlite ? resolveSqliteUrl(raw.DATABASE_URL) : raw.DATABASE_URL,
|
||||
databaseUrl: resolvedDatabaseUrl,
|
||||
/** SQLite 数据文件绝对路径(MySQL 时为空) */
|
||||
sqliteFilePath,
|
||||
rawDatabaseUrl: raw.DATABASE_URL,
|
||||
jwtSecret: raw.JWT_SECRET,
|
||||
jwtExpiresIn: raw.JWT_EXPIRES_IN,
|
||||
@@ -71,6 +141,15 @@ export const env = {
|
||||
defaultStudentPassword: raw.DEFAULT_STUDENT_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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user