Files
ClassHelper/packages/server/src/config/env.ts
T
LoveChengke 845fbdd863 feat(teachers,classes,schedules,web): 教师录入(仅管理员)+ 班主任可设置/更改 + 授课科目全校统一 + 登录页去示例
① 支持录入教师(与学生录入对齐),权限只有管理员
- 新增 Web「教师管理」页(`Views/TeachersView.vue`):单个录入(姓名/用户名/初始密码/角色)、
  导入名单(xlsx/csv:用户名、姓名、初始密码、角色)、编辑(改名/改角色)、重置密码、删除
- 服务端 `teachers` 模块补齐:GET / POST / PATCH /:id / POST /:id/reset-password / DELETE /:id,
  全部 `requireRole('ADMIN')`;表格导入新增 kind=teachers(`IMPORT_FIELDS.teachers`、`commitTeachers`、
  教师名单模板),教师调用预览/提交一律 403
- 删除护栏:还是班主任(`Class.teacherId`)或持有课程/班级分配的账号拒绝删除(409,提示先更换班主任)——
  教师是数据创建者,级联删除会连带删掉班级数据
- 前端路由加 `meta.roles`:`/classes`、`/students`、`/teachers` 仅 ADMIN,教师直接输网址会被挡回仪表盘
  (菜单隐藏只是"看不见",这次补上真正的守卫);新增环境变量 `DEFAULT_TEACHER_PASSWORD`

② 创建班级仅管理员 + 设置 / 更改班主任
- 新建班级时可直接指定班主任(不填则创建者本人),班级详情顶部「更换班主任」下拉随时改:
  新增 `PATCH /api/classes/:id/head-teacher`(仅管理员,校验目标账号是教师/管理员,广播 class:updated)
- 原班主任保留在协作教师名单里,避免换人后丢掉历史协作关系

③ 授课科目全校统一(用户澄清:"统一"指的是授课科目固定,不是各班课表相同)
- `SUBJECT_CATALOG` 改为用户给定的 18 科:语文/数学/英语/政治/历史/物理/化学/地理/生物/信息技术/
  体育/美术/音乐/班会/早读/晚读/通用技术/听力
- 课表「新增课表」与成绩「单条/批量录入」的科目下拉直接列出统一目录(该班还没有的进
  「统一科目(选中后自动建课)」分组),选中后自动为该班建同名课程再落库 —— 不必按班级一个个录科目
- 各班课表仍然各自独立(各班的课表本来就不一样)

④ 登录页去掉所有示例内容
- Web 登录页删除「演示账号(点击填充)」标签列表与"种子数据见 README"提示;客户端班级码占位符
  由「例如 G101(由老师在班级管理中提供)」改为「请输入班级码」
- `verify:web` 与 `verify:desktop` 各自断言登录页正文/占位符不含 `演示/示例/admin123/teacher123/G101`

⑤ 验证脚本不再依赖演示数据(并修掉自身残留)
- 清空服务端作业/通知后,联网自检改为**自建**一条作业/通知并在收尾删除(`seedSmokeContent`);
  权限用例需要"其它教师的班级/科任班级"时现建临时班级并在收尾删除
- e2e 新增清理:实时推送用例造的通知/作业、上课时段用例造的两条通知、教师名单/临时班级
  (此前这几条会留在库里);班级详情相关断言不再依赖种子班级布局
- ui-smoke 新增 7 项:登录页无示例、教师端访问 /teachers 被挡回、统一科目下拉 + 自动建课 + 收尾删除
  课表与课程、管理员真人录入教师 → 列表出现 → 删除
- 冒烟里"岛就绪"与"截图留档"改为轮询等待(偶发丢一张留档图会误判"形态尺寸递增")

实测:verify:e2e 163/163(安装实例)、verify:web 27/27、verify:desktop 77/77(离线复跑 69/69)、
verify:packaged 对 release/win-unpacked 与 D:\class\@classhelperdesktop-client 均 77/77 且 packaged=true;
typecheck / lint / format:check 全绿。服务端安装实例与客户端安装目录已就地更新。
2026-09-13 10:34:46 +08:00

165 lines
7.3 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_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;