diff --git a/README.md b/README.md index 38a1bc6..1d5d973 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,14 @@ | **学生客户端单文件版** | `packages/desktop-client/release/班级小助手-0.1.0-x64-portable.exe`(106.9 MB) | 免安装直接运行(U 盘分发) | | Web 管理端(PWA) | 由服务端在 `/` 直接托管 | 浏览器打开即用,可在 Edge/Chrome 中「安装为应用」;**已适配手机小屏**(1Panel 风格抽屉导航) | -生产部署(Windows 安装包 / Docker + MySQL / 手动部署)请看 -**[docs/production.md](docs/production.md)**。 +生产部署请看 **[docs/production.md](docs/production.md)**,四种形态按场景选: + +| 场景 | 形态 | 入口 | +| ----------------------------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| 学校机房 / 教师电脑(Windows 单机) | Windows 服务端安装程序(内置 Node,双击即用) | `release-server/班级小助手服务端-0.1.0-x64-setup.exe` | +| 云服务器 / 多终共享(推荐长期方案) | Docker Compose + MySQL | `deploy/Dockerfile`、`deploy/docker-compose.yml` | +| **已有 Linux 服务器** | **systemd + SQLite 一键脚本(含 Nginx 反代与 WebSocket 配置)** | `sudo bash deploy/install-linux.sh`(先 `--check` 体检)、`deploy/classhelper.service` | +| 自定义 / 已有 Node 环境 | 手动部署免安装目录 | `pnpm dist:server` → `release-server/classhelper-server/` | ## 目录结构 @@ -64,8 +70,11 @@ class-helper/ │ ├── nsis/server-installer.nsi# 安装程序脚本模板 │ └── ui-smoke/ # Web 管理端 UI 真实点击回归测试(Electron 驱动 + 上课时段探针) ├── deploy/ # 生产部署:Dockerfile / docker-compose.yml / nginx.conf +│ ├── install-linux.sh # Linux 一键部署(systemd + SQLite,含 .env 生成与就绪探针) +│ ├── classhelper.service # systemd 单元模板(脚本会替换 __NODE__/__DIR__/__USER__) +│ └── package.runtime.json # 运行时依赖清单(安装包 / 容器 / Linux 部署共用) ├── docs/ -│ ├── production.md # 生产部署指南(三种形态 + 运维 + 安全清单) +│ ├── production.md # 生产部署指南(四种形态 + Linux systemd + 运维 + 安全清单) │ └── mysql.md # MySQL 切换指南 └── packages/ ├── shared/ # 三端共享:类型契约、常量、工具函数 @@ -1070,15 +1079,15 @@ pnpm db:migrate && pnpm db:seed 当前实测: -| 验证 | 结果 | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | -| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **163/163** ✅ | -| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 成绩/时间配置导入 + 教师录入 + 统一科目 + PWA) | **27/27** ✅ | -| `pnpm verify:desktop`(客户端冒烟 + WinIsland 架构/连续圆角/收起常驻 + 命中兜底与"展开收起再展开" + 作业看板全屏自适应 + 登录页无示例 + 个性化全参数 + 托盘与退出无残留) | **77/77** ✅(离线复跑 69/69) | -| `pnpm verify:packaged`(对 `release/win-unpacked` 与 `D:\class\@classhelperdesktop-client` 实测) | **77/77**,`packaged: true` ✅ | -| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ | -| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ | -| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ | +| 验证 | 结果 | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **163/163** ✅ | +| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 成绩/时间配置导入 + 教师录入 + 统一科目 + PWA) | **27/27** ✅ | +| `pnpm verify:desktop`(客户端冒烟 + WinIsland 架构/连续圆角/收起常驻 + 命中兜底与"展开收起再展开" + 作业看板全屏自适应 + 登录页无示例 + 个性化全参数 + 托盘与退出无残留) | **77/77** ✅(离线复跑 69/69) | +| `pnpm verify:packaged`(对 `release/win-unpacked` 与 `D:\class\@classhelperdesktop-client` 实测) | **77/77**,`packaged: true` ✅ | +| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ | +| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ | +| Docker / Nginx 部署样例 | 文件已提供(含 Linux systemd 脚本),本机无 Docker/Linux 未实测 ⚠️ | ## 常见问题(本机环境已知坑) @@ -1150,18 +1159,18 @@ pnpm db:migrate && pnpm db:seed ## 交付清单(阶段 7 · 生产化) -| 路径 | 说明 | -| --------------------------------------------------- | ---------------------------------------------------------------- | -| `scripts/dist-server.mjs` | 服务端 + Web 端打包(内置 Node、依赖、迁移、启停脚本、安装程序) | -| `scripts/nsis/server-installer.nsi` | NSIS 安装程序模板(安装/快捷方式/开机自启/卸载保留数据) | -| `scripts/generate-icons.mjs` + `scripts/icons/` | 图标生成(Electron 渲染 SVG → PNG/ICO) | -| `scripts/ui-smoke/` | Web 管理端 UI 真实点击回归测试 | -| `packages/server/src/middleware/security.ts` | helmet + 限流(通用/登录)+ 请求耗时日志 | -| `packages/server/src/lib/web-static.ts` | Web 管理端静态托管 + SPA 回退 + 缓存策略 | -| `packages/server/src/lib/db-bootstrap.ts` | 首启动自动迁移 + 自动创建管理员 | -| `packages/web-admin/public/{manifest,sw.js}` | PWA:可安装为应用 + 离线外壳 | -| `deploy/{Dockerfile,docker-compose.yml,nginx.conf}` | 云部署与 HTTPS 反代样例 | -| `docs/production.md` | 生产部署指南(三种形态 + 运维 + 安全清单 + 故障排查) | +| 路径 | 说明 | +| --------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `scripts/dist-server.mjs` | 服务端 + Web 端打包(内置 Node、依赖、迁移、启停脚本、安装程序) | +| `scripts/nsis/server-installer.nsi` | NSIS 安装程序模板(安装/快捷方式/开机自启/卸载保留数据) | +| `scripts/generate-icons.mjs` + `scripts/icons/` | 图标生成(Electron 渲染 SVG → PNG/ICO) | +| `scripts/ui-smoke/` | Web 管理端 UI 真实点击回归测试 | +| `packages/server/src/middleware/security.ts` | helmet + 限流(通用/登录)+ 请求耗时日志 | +| `packages/server/src/lib/web-static.ts` | Web 管理端静态托管 + SPA 回退 + 缓存策略 | +| `packages/server/src/lib/db-bootstrap.ts` | 首启动自动迁移 + 自动创建管理员 | +| `packages/web-admin/public/{manifest,sw.js}` | PWA:可安装为应用 + 离线外壳 | +| `deploy/{Dockerfile,docker-compose.yml,nginx.conf}` | 云部署与 HTTPS 反代样例 | +| `docs/production.md` | 生产部署指南(Windows / Docker+MySQL / **Linux systemd** / 手动,含运维、安全清单与故障排查) | 阶段 8(灵动岛 + 上课时段策略)新增文件: diff --git a/deploy/.env.example b/deploy/.env.example index f7c4085..2c928b3 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -30,6 +30,11 @@ CORS_ORIGIN=* INITIAL_ADMIN_USERNAME=admin INITIAL_ADMIN_PASSWORD=admin123 +# 各类账号的默认初始密码(新增/重置时使用) +DEFAULT_STUDENT_PASSWORD=123456 +DEFAULT_CLASS_PASSWORD=123456 +DEFAULT_TEACHER_PASSWORD=123456 + # 第 1 教学周的周一(用于"当前周次") TERM_START_DATE=2026-08-31 diff --git a/deploy/classhelper.service b/deploy/classhelper.service new file mode 100644 index 0000000..121ed9e --- /dev/null +++ b/deploy/classhelper.service @@ -0,0 +1,55 @@ +# --------------------------------------------------------------------------- +# 班级小助手 · 服务端 systemd 单元(Linux) +# +# 这是**模板**:deploy/install-linux.sh 会把 __NODE__ / __DIR__ / __USER__ 替换成真实值后 +# 安装到 /etc/systemd/system/classhelper.service。手动部署时直接改这三处即可: +# +# sudo sed -e 's#__NODE__#/usr/bin/node#' \ +# -e 's#__DIR__#/opt/classhelper#' \ +# -e 's#__USER__#classhelper#' \ +# deploy/classhelper.service | sudo tee /etc/systemd/system/classhelper.service +# sudo systemctl daemon-reload && sudo systemctl enable --now classhelper +# +# 常用命令: +# systemctl status classhelper +# journalctl -u classhelper -f # 实时日志 +# systemctl restart classhelper # 重启(改完 .env 需要) +# --------------------------------------------------------------------------- + +[Unit] +Description=ClassHelper Server (班级小助手服务端) +Documentation=file://__DIR__/README.txt +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=__USER__ +Group=__USER__ +WorkingDirectory=__DIR__ +# .env 由服务端自己从 WorkingDirectory 加载(工作目录即安装根目录) +ExecStart=__NODE__ server/dist/index.js +Environment=NODE_ENV=production + +Restart=always +RestartSec=3 +# Socket.IO 长连接较多:提高文件描述符上限 +LimitNOFILE=65535 +# 优雅退出:给在途请求与 websocket 收尾时间 +KillSignal=SIGTERM +TimeoutStopSec=20 + +# 日志进 journald:journalctl -u classhelper +StandardOutput=journal +StandardError=journal +SyslogIdentifier=classhelper + +# 基础加固(服务只需要读程序、写 data/logs) +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=true +ReadWritePaths=__DIR__/data __DIR__/logs + +[Install] +WantedBy=multi-user.target diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 7b817d2..806d932 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -78,6 +78,9 @@ services: TERM_START_DATE: ${TERM_START_DATE:-} INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin} INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-admin123} + DEFAULT_STUDENT_PASSWORD: ${DEFAULT_STUDENT_PASSWORD:-123456} + DEFAULT_CLASS_PASSWORD: ${DEFAULT_CLASS_PASSWORD:-123456} + DEFAULT_TEACHER_PASSWORD: ${DEFAULT_TEACHER_PASSWORD:-123456} ports: - '${SERVER_PORT:-4000}:4000' depends_on: diff --git a/deploy/install-linux.sh b/deploy/install-linux.sh new file mode 100644 index 0000000..1d94f2f --- /dev/null +++ b/deploy/install-linux.sh @@ -0,0 +1,279 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------------------- +# 班级小助手 · 服务端 Linux 一键部署(systemd + SQLite,单机单实例) +# +# 干什么: +# 在 Linux 服务器上把服务端装成 systemd 服务,布局与已验证的 Windows 免安装目录完全一致: +# /opt/classhelper/ +# ├── .env 全部配置(首次安装随机生成 JWT_SECRET) +# ├── server/dist 后端程序(入口 index.js) +# ├── server/prisma 迁移 SQL(AUTO_MIGRATE=true 时首启动自动执行) +# ├── web/ Web 管理端(由后端托管,单端口 4000) +# ├── node_modules/ 仅生产依赖(npm install --omit=dev) +# ├── data/ classhelper.db + server.pid +# └── logs/ 预留(日志默认进 journald) +# +# 前置条件: +# - 在**仓库根目录**已执行过构建:pnpm build:shared && pnpm --filter @classhelper/server build \ +# && pnpm --filter @classhelper/web-admin build +# - 目标机装好 Node.js ≥ 20.19(推荐 22 LTS);本脚本不代装 Node +# +# 用法(在仓库根目录执行,需要 root): +# sudo bash deploy/install-linux.sh # 安装/升级,默认端口 4000、目录 /opt/classhelper +# sudo bash deploy/install-linux.sh --port 8080 # 换端口 +# sudo bash deploy/install-linux.sh --dir /srv/classhelper +# sudo bash deploy/install-linux.sh --no-service # 只铺文件,不装 systemd(自己接管进程) +# bash deploy/install-linux.sh --check # 只体检(不需要 root):校验构建产物/Node 版本/端口探针 +# sudo bash deploy/install-linux.sh --uninstall # 停止并禁用服务(保留 data 与 .env) +# +# 升级:把新代码构建好后**再跑一次同样的命令**即可。脚本不会覆盖已有 .env(JWT 密钥与端口保持 +# 不变,用户不用重新登录),只会补齐缺失项并覆盖程序文件。 +# +# 注意:本脚本未在 Linux 实机验证过(开发机是 Windows,无 Docker/WSL 发行版)。 +# 它复用的是**已验证**的两套布局:Windows 免安装目录(install/ 结构)与 deploy/Dockerfile +# 的 runtime 阶段(package.runtime.json + prisma/migrations + web/)。 +# --------------------------------------------------------------------------- +set -euo pipefail + +TARGET_DIR="/opt/classhelper" +SERVICE_USER="classhelper" +PORT="4000" +WITH_SERVICE=1 +UNINSTALL=0 +CHECK_ONLY=0 +SERVICE_NAME="classhelper" + +while [ $# -gt 0 ]; do + case "$1" in + --dir) TARGET_DIR="$2"; shift 2 ;; + --port) PORT="$2"; shift 2 ;; + --user) SERVICE_USER="$2"; shift 2 ;; + --no-service) WITH_SERVICE=0; shift ;; + --check) CHECK_ONLY=1; shift ;; + --uninstall) UNINSTALL=1; shift ;; + -h|--help) sed -n '2,40p' "$0"; exit 0 ;; + *) echo "未知参数:$1(用 --help 看用法)" >&2; exit 2 ;; + esac +done + +log() { printf '\033[32m[classhelper]\033[0m %s\n' "$*"; } +warn() { printf '\033[33m[classhelper]\033[0m %s\n' "$*" >&2; } +die() { printf '\033[31m[classhelper]\033[0m %s\n' "$*" >&2; exit 1; } + +# 就绪探针:优先 curl(几乎每台 Linux 都有),否则退回 node。 +# 注意不要用 `node -e "...process.exit()"` —— 在部分 Node 版本上于 promise 回调里硬退会触发 +# libuv 断言崩溃(本机 Windows 实测退出码 -1073740791),改成设置 process.exitCode 让事件循环自然结束。 +probe_ready() { + if command -v curl >/dev/null 2>&1; then + curl -fsS -o /dev/null --max-time 3 "http://127.0.0.1:${PORT}/healthz" >/dev/null 2>&1 + return $? + fi + "$NODE_BIN" -e "fetch('http://127.0.0.1:${PORT}/healthz').then(r=>{process.exitCode=r.ok?0:1}).catch(()=>{process.exitCode=1})" >/dev/null 2>&1 +} + +[ "$(id -u)" = "0" ] || { [ "$CHECK_ONLY" = "1" ] || die "请用 root 执行:sudo bash deploy/install-linux.sh"; } + +# ---------------------------------------------------------------- 卸载分支 +if [ "$UNINSTALL" = "1" ]; then + if systemctl list-unit-files | grep -q "^${SERVICE_NAME}.service"; then + systemctl disable --now "$SERVICE_NAME" || true + rm -f "/etc/systemd/system/${SERVICE_NAME}.service" + systemctl daemon-reload + log "已停止并移除 systemd 服务 ${SERVICE_NAME}" + else + warn "没有找到 ${SERVICE_NAME}.service(可能没装成服务)" + fi + log "程序文件与数据保留在 ${TARGET_DIR};确认不需要后再手动删除" + exit 0 +fi + +# ---------------------------------------------------------------- 定位仓库与产物 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +cd "$REPO_ROOT" + +SERVER_DIST="packages/server/dist/index.js" +SERVER_PRISMA="packages/server/prisma" +WEB_DIST="packages/web-admin/dist/index.html" +SHARED_DIST="packages/shared/dist/index.js" +RUNTIME_PKG="deploy/package.runtime.json" + +for f in "$SERVER_DIST" "$WEB_DIST" "$SHARED_DIST" "$RUNTIME_PKG"; do + [ -e "$f" ] || die "缺少构建产物:$f +请先在仓库根目录执行: + pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build" +done +[ -d "$SERVER_PRISMA/migrations" ] || die "缺少迁移文件目录:$SERVER_PRISMA/migrations" + +# ---------------------------------------------------------------- Node 检查 +NODE_BIN="$(command -v node || true)" +[ -n "$NODE_BIN" ] || die "未找到 node,请先安装 Node.js ≥ 20.19(推荐 22 LTS)" +NODE_MAJOR="$("$NODE_BIN" -p 'process.versions.node.split(".")[0]')" +NODE_MINOR="$("$NODE_BIN" -p 'process.versions.node.split(".")[1]')" +if [ "$NODE_MAJOR" -lt 20 ] || { [ "$NODE_MAJOR" -eq 20 ] && [ "$NODE_MINOR" -lt 19 ]; }; then + die "Node 版本过低:$("$NODE_BIN" -v)(要求 ≥ 20.19)" +fi +NPM_BIN="$(command -v npm || true)" +[ -n "$NPM_BIN" ] || die "未找到 npm(随 Node 一起安装)" +log "Node $("$NODE_BIN" -v) → $NODE_BIN" + +# ---------------------------------------------------------------- 只体检 +if [ "$CHECK_ONLY" = "1" ]; then + echo + echo "构建产物:" + for f in "$SERVER_DIST" "$WEB_DIST" "$SHARED_DIST" "$RUNTIME_PKG" "$SERVER_PRISMA/migrations"; do + printf ' %-42s %s\n' "$f" "OK" + done + printf ' %-42s %s\n' "迁移文件数量" "$(find "$SERVER_PRISMA/migrations" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" + echo + echo "运行环境:" + printf ' %-42s %s\n' "node" "$("$NODE_BIN" -v)(要求 ≥ 20.19)" + printf ' %-42s %s\n' "npm" "$("$NPM_BIN" -v)" + printf ' %-42s %s\n' "systemd" "$(command -v systemctl >/dev/null 2>&1 && echo 可用 || echo '不可用(用 --no-service)')" + printf ' %-42s %s\n' "目标目录 ${TARGET_DIR}" "$([ -e "$TARGET_DIR" ] && echo '已存在(会就地升级,保留 .env)' || echo 尚未创建)" + printf ' %-42s %s\n' "端口 ${PORT} 上的既有服务" "$(probe_ready && echo '健康(就绪)' || echo '无响应')" + echo + log "--check 通过:构建产物与运行环境都满足,可以执行安装(sudo bash deploy/install-linux.sh)" + exit 0 +fi + +# ---------------------------------------------------------------- 用户与目录 +if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then + useradd --system --create-home --shell /usr/sbin/nologin "$SERVICE_USER" 2>/dev/null \ + || useradd --system --shell /sbin/nologin "$SERVICE_USER" + log "已创建系统用户 ${SERVICE_USER}" +fi + +mkdir -p "${TARGET_DIR}/server" "${TARGET_DIR}/web" "${TARGET_DIR}/data" "${TARGET_DIR}/logs" +log "安装目录:${TARGET_DIR}" + +# ---------------------------------------------------------------- 拷贝产物 +rm -rf "${TARGET_DIR}/server/dist" +cp -R "packages/server/dist" "${TARGET_DIR}/server/dist" +rm -rf "${TARGET_DIR}/server/prisma" +cp -R "$SERVER_PRISMA" "${TARGET_DIR}/server/prisma" +# 运行时不读 schema,但保留一份便于排查 +[ -f packages/server/prisma/schema.prisma ] && cp packages/server/prisma/schema.prisma "${TARGET_DIR}/server/prisma/schema.prisma" + +rm -rf "${TARGET_DIR}/web" +cp -R "packages/web-admin/dist" "${TARGET_DIR}/web" + +cp "$RUNTIME_PKG" "${TARGET_DIR}/package.json" +cp -R deploy/classhelper.service "${TARGET_DIR}/classhelper.service.template" + +# 生产依赖(与 Dockerfile 的 runtime 阶段同一份清单) +if [ ! -d "${TARGET_DIR}/node_modules" ] || [ "${FORCE_NPM_INSTALL:-0}" = "1" ]; then + log "安装生产依赖(npm install --omit=dev,约 1~3 分钟)..." + ( cd "$TARGET_DIR" && "$NPM_BIN" install --omit=dev --no-audit --no-fund ) +else + log "复用已有 node_modules(如需强制重装:FORCE_NPM_INSTALL=1 再跑一次)" +fi + +# workspace 共享包(后端 import '@classhelper/shared'):拷进 node_modules 里 +mkdir -p "${TARGET_DIR}/node_modules/@classhelper/shared" +rm -rf "${TARGET_DIR}/node_modules/@classhelper/shared/dist" +cp -R "packages/shared/dist" "${TARGET_DIR}/node_modules/@classhelper/shared/dist" +cp packages/shared/package.json "${TARGET_DIR}/node_modules/@classhelper/shared/package.json" + +# ---------------------------------------------------------------- .env(已存在则不覆盖) +ENV_FILE="${TARGET_DIR}/.env" +if [ -f "$ENV_FILE" ]; then + log "检测到已有 .env:保留现有配置(含 JWT_SECRET),只补齐缺失项" +else + JWT_SECRET="$("$NODE_BIN" -e 'console.log(require("crypto").randomBytes(48).toString("hex"))')" + cat > "$ENV_FILE" </dev/null 2>&1 || die "本机没有 systemd;请加 --no-service 自行托管进程" + + sed -e "s#__NODE__#${NODE_BIN}#g" \ + -e "s#__DIR__#${TARGET_DIR}#g" \ + -e "s#__USER__#${SERVICE_USER}#g" \ + deploy/classhelper.service > "/etc/systemd/system/${SERVICE_NAME}.service" + systemctl daemon-reload + systemctl enable "$SERVICE_NAME" >/dev/null + systemctl restart "$SERVICE_NAME" + + log "等待服务就绪..." + ready=0 + for _ in $(seq 1 60); do + if probe_ready; then + ready=1 + break + fi + sleep 1 + done + + if [ "$ready" != "1" ]; then + warn "60 秒内没通过 /healthz,最近日志:" + journalctl -u "$SERVICE_NAME" -n 50 --no-pager || true + die "启动失败,请按上面的日志排查(常见:端口被占用、数据目录权限、JWT_SECRET 未生成)" + fi + + log "服务已就绪 ✅" +else + log "已跳过 systemd(--no-service)。手动启动:" + echo " sudo -u ${SERVICE_USER} sh -c 'cd ${TARGET_DIR} && ${NODE_BIN} server/dist/index.js'" +fi + +# ---------------------------------------------------------------- 结果 +cat <:${PORT}/ + 健康探针: http://<服务器IP>:${PORT}/healthz · /readyz + 配置文件: ${ENV_FILE} + 数据目录: ${TARGET_DIR}/data(备份只需复制这里) + 查看日志: journalctl -u ${SERVICE_NAME} -f + 重启服务: systemctl restart ${SERVICE_NAME} + +首次启动会自动建表并创建管理员:admin / admin123(登录后请立即在右上角「修改密码」改掉)。 +公网部署请在前面加 Nginx + HTTPS(deploy/nginx.conf 可直接用),并把 .env 里的 +TRUST_PROXY 设为 1、CORS_ORIGIN 收敛到你的域名。 +学生端客户端登录时填的「服务器地址」就是 https://你的域名(或 http://IP:${PORT})。 +------------------------------------------------------------------ +EOF diff --git a/docs/production.md b/docs/production.md index ea0e2a3..3f6a624 100644 --- a/docs/production.md +++ b/docs/production.md @@ -2,11 +2,12 @@ 本文档覆盖三种上线形态,按适用场景选择: -| 形态 | 适用场景 | 产物 | 本机验证情况 | -| --------------------- | -------------------------------------------- | ------------------------------------------------------ | -------------------------------------- | -| **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `release-server/班级小助手服务端-<版本>-x64-setup.exe` | ✅ 已完整实测(静默安装/启停/卸载/UI) | -| **B. Docker + MySQL** | 云服务器、多终端共享一套数据(推荐长期方案) | `deploy/Dockerfile` + `deploy/docker-compose.yml` | ⚠️ 文件已提供,本机无 Docker 未实测 | -| **C. 手动部署** | 已有 Node 环境的服务器、需要自定义 | `pnpm dist:server` 的免安装目录 | ✅ 免安装目录已实测(54 项端到端) | +| 形态 | 适用场景 | 产物 | 本机验证情况 | +| --------------------- | -------------------------------------------- | -------------------------------------------------------- | -------------------------------------- | +| **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `release-server/班级小助手服务端-<版本>-x64-setup.exe` | ✅ 已完整实测(静默安装/启停/卸载/UI) | +| **B. Docker + MySQL** | 云服务器、多终端共享一套数据(推荐长期方案) | `deploy/Dockerfile` + `deploy/docker-compose.yml` | ⚠️ 文件已提供,本机无 Docker 未实测 | +| **C. Linux 原生部署** | 已有 Linux 服务器(systemd + SQLite) | `deploy/install-linux.sh` + `deploy/classhelper.service` | ⚠️ 脚本已提供,本机无 Linux 未实测 | +| **D. 手动部署** | 已有 Node 环境的服务器、需要自定义 | `pnpm dist:server` 的免安装目录 | ✅ 免安装目录已实测(163 项端到端) | --- @@ -126,7 +127,153 @@ docker compose -f deploy/docker-compose.yml --env-file deploy/.env up -d --build --- -## 三、手动部署(形态 C) +## 三、Linux 原生部署(形态 C,systemd + SQLite) + +适用:Ubuntu / Debian / CentOS / Rocky 等有 systemd 的服务器,单机跑一套(SQLite), +用 Nginx 挂 HTTPS 给校内网/公网访问。 + +### 3.1 一键脚本(推荐) + +在**仓库根目录**先构建,然后执行脚本(需要 root): + +```bash +# 1) 构建(Node ≥ 20.19,推荐 22 LTS;目标机没有 pnpm 时可在别的机器上构建后拷仓库过来) +corepack enable && corepack prepare pnpm@11.8.0 --activate +pnpm install +pnpm build:shared +pnpm --filter @classhelper/server build +pnpm --filter @classhelper/web-admin build + +# 2) 安装成 systemd 服务 +sudo bash deploy/install-linux.sh # 默认端口 4000、目录 /opt/classhelper +# 常用变体: +# bash deploy/install-linux.sh --check # 只体检(不需要 root):校验构建产物/Node/端口探针 +# sudo bash deploy/install-linux.sh --port 8080 +# sudo bash deploy/install-linux.sh --dir /srv/classhelper +# sudo bash deploy/install-linux.sh --no-service # 只铺文件,自己托管进程 +# sudo bash deploy/install-linux.sh --uninstall # 停服务并移除 unit(保留 data 与 .env) +``` + +> 建议先跑一次 `--check`:它只读地校验"构建产物齐不齐、Node 版本够不够、目标端口上有没有旧实例", +> 不需要 root,也不会改任何东西。输出示例见 `deploy/install-linux.sh` 头部注释。 + +脚本做的事:建系统用户 `classhelper` → 把后端产物、`prisma/migrations`、Web 管理端拷到 +`/opt/classhelper` → `npm install --omit=dev`(清单就是 `deploy/package.runtime.json`,与 +容器运行时同一份)→ 生成 `.env`(随机 `JWT_SECRET`、`AUTO_MIGRATE=true`)→ 装 systemd unit → +`enable --now` → 轮询 `/healthz` 就绪后打印地址与初始管理员。 + +安装后的目录(与已验证的 Windows 免安装目录同构,因此 `.env` 里的相对路径语义一致): + +``` +/opt/classhelper/ +├── .env 全部配置(权限 600) +├── server/dist 后端程序(入口 index.js) +├── server/prisma 迁移 SQL(首启动自动执行) +├── web/ Web 管理端(后端托管,单端口) +├── node_modules/ 仅生产依赖 +├── data/ classhelper.db + server.pid(备份只复制这里) +└── logs/ +``` + +首次启动会自动建表并创建管理员 `admin / admin123`(**登录后立刻改密码**)。 + +### 3.2 手动部署(不想用脚本 / 没有 systemd) + +等价的手工步骤,便于排查: + +```bash +sudo useradd --system --shell /usr/sbin/nologin classhelper +sudo mkdir -p /opt/classhelper/{server,web,data,logs} + +# 程序产物 +sudo cp -R packages/server/dist /opt/classhelper/server/dist +sudo cp -R packages/server/prisma /opt/classhelper/server/prisma +sudo cp -R packages/web-admin/dist /opt/classhelper/web +sudo cp deploy/package.runtime.json /opt/classhelper/package.json + +# 生产依赖 + 工作区共享包 +cd /opt/classhelper && sudo npm install --omit=dev --no-audit --no-fund +sudo mkdir -p /opt/classhelper/node_modules/@classhelper/shared +sudo cp -R packages/shared/dist /opt/classhelper/node_modules/@classhelper/shared/dist +sudo cp packages/shared/package.json /opt/classhelper/node_modules/@classhelper/shared/package.json + +# 配置(参考脚本生成的 .env,务必自己生成 JWT_SECRET) +sudo tee /opt/classhelper/.env >/dev/null <<'EOF' +NODE_ENV=production +HOST=0.0.0.0 +PORT=4000 +DATABASE_PROVIDER=sqlite +DATABASE_URL="file:../data/classhelper.db" +AUTO_MIGRATE=true +JWT_SECRET=换成 openssl rand -hex 48 的输出 +JWT_EXPIRES_IN=7d +CORS_ORIGIN=https://class.example.com +TRUST_PROXY=1 +RATE_LIMIT_ENABLED=true +STARTUP_DB_CHECK=true +PID_FILE=../data/server.pid +LOG_LEVEL=info +EOF + +sudo chown -R classhelper:classhelper /opt/classhelper/{data,logs,node_modules} /opt/classhelper/.env +sudo chmod 600 /opt/classhelper/.env + +# systemd +sudo sed -e 's#__NODE__#/usr/bin/node#' -e 's#__DIR__#/opt/classhelper#' -e 's#__USER__#classhelper#' \ + deploy/classhelper.service | sudo tee /etc/systemd/system/classhelper.service +sudo systemctl daemon-reload && sudo systemctl enable --now classhelper +systemctl status classhelper --no-pager +``` + +也可以用 PM2 之类托管(`pm2 start /opt/classhelper/server/dist/index.js --name classhelper`), +但 systemd 更省心:`Restart=always`、日志进 journald、开机自启一次配好。 + +### 3.3 Nginx + HTTPS + +```bash +sudo cp deploy/nginx.conf /etc/nginx/conf.d/classhelper.conf +sudo nano /etc/nginx/conf.d/classhelper.conf # 改 server_name 与证书路径 +sudo nginx -t && sudo systemctl reload nginx +``` + +`deploy/nginx.conf` 已经处理了最容易踩的两点:`/socket.io/` 的 WebSocket 升级头, +以及 `X-Forwarded-For/X-Forwarded-Proto` 透传(配合 `.env` 里 `TRUST_PROXY=1`,限流才会按真实 IP 统计)。 +用 certbot 签证书: + +```bash +sudo apt install -y certbot python3-certbot-nginx +sudo certbot --nginx -d class.example.com +``` + +### 3.4 升级 / 备份 / 排障 + +升级(不会动 `.env`,JWT 密钥不变,学生端不用重新登录): + +```bash +git pull && pnpm install +pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build +sudo bash deploy/install-linux.sh # 覆盖程序文件 + 自动补迁移(AUTO_MIGRATE) +journalctl -u classhelper -n 30 --no-pager +``` + +| 事项 | 命令 | +| -------- | ---------------------------------------------------------------------------------------------------------- | +| 备份 | `systemctl stop classhelper && cp -a /opt/classhelper/data /root/classhelper-data-$(date +%F)`(然后启动) | +| 恢复 | 停服务 → 用备份覆盖 `data/` → 启动 | +| 看日志 | `journalctl -u classhelper -f` | +| 重启 | `systemctl restart classhelper`(改 `.env` 后必须重启) | +| 探针 | `curl -s localhost:4000/healthz` / `/readyz`(就绪 200,数据库不可用 503) | +| 换端口 | 改 `.env` 的 `PORT` → 重启;同时改 Nginx 的 `upstream` | +| 换数据库 | 见 `docs/mysql.md`:切 provider + 装 `@prisma/adapter-mariadb` + `npx prisma migrate deploy` | + +> 注意:本机(开发环境)是 Windows 且无 Docker/WSL 发行版,因此 **3.1 的脚本没有在 Linux 实机跑过**。 +> 它复用的两套东西都已验证:Windows 免安装目录(同样的目录结构、`.env` 相对路径与 +> `AUTO_MIGRATE` 首启动行为)和 `deploy/Dockerfile` 的 runtime 阶段(同样的依赖清单与产物布局)。 +> 首次在服务器上执行后请按 3.4 的探针 + Web 登录 + `pnpm verify:e2e`(把 `VERIFY_BASE_URL` 指向服务器)确认。 + +--- + +## 四、手动部署(形态 D) ```bash pnpm install @@ -140,7 +287,7 @@ pnpm dist:server -- --reuse-deps # 得到 release-server/classhelper-server ```cmd start.cmd :: Windows -# 或直接:node server/dist/index.js (Linux/macOS 用系统 node) +# 或直接:node server/dist/index.js (Linux/macOS 用系统 node,见上面的形态 C) ``` 数据库初始化二选一: @@ -150,7 +297,7 @@ start.cmd :: Windows --- -## 四、环境变量清单 +## 五、环境变量清单 | 变量 | 默认 | 说明 | | --------------------------------------------------- | ---------------------- | --------------------------------------------------------- | @@ -168,15 +315,17 @@ start.cmd :: Windows | `PID_FILE` | — | PID 文件路径(安装包启停脚本依赖),相对 `server` 根 | | `WEB_DIST_DIR` | 自动探测 | Web 管理端产物目录(`../web-admin/dist` 或 `./web`) | | `LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` | -| `DEFAULT_STUDENT_PASSWORD` | `123456` | 新增学生的默认密码 | +| `DEFAULT_STUDENT_PASSWORD` | `123456` | 新增/重置学生账号的默认密码 | +| `DEFAULT_CLASS_PASSWORD` | `123456` | 新建班级时班级账号(学生端登录)的默认密码 | +| `DEFAULT_TEACHER_PASSWORD` | `123456` | 新增/重置教师账号的默认密码(管理员录入教师用) | | `TERM_START_DATE` | — | 第 1 教学周的周一,用于"当前周次"与课表默认视图 | | `INITIAL_ADMIN_USERNAME` / `INITIAL_ADMIN_PASSWORD` | `admin` / `admin123` | 空库首启动创建的管理员 | --- -## 五、运维 +## 六、运维 -### 5.1 探针与监控 +### 6.1 探针与监控 | 端点 | 用途 | | ------------- | ---------------------------------------------------- | @@ -184,22 +333,22 @@ start.cmd :: Windows | `/readyz` | 就绪探针:数据库可连才 200,否则 503(负载均衡摘流) | | `/api/health` | 详情:环境、数据库、已挂载模块、运行时长 | -### 5.2 日志 +### 6.2 日志 - 输出到 stdout/stderr,安装包形态由启停脚本重定向到 `logs\server.log`(UTF-8)。 - 访问日志含方法、路径、状态码与耗时;5xx 记 error、4xx 记 warn。 - 容器形态建议交给 Docker 日志驱动或采集到 ELK/Loki。 -### 5.3 备份与恢复 +### 6.3 备份与恢复 -| 形态 | 备份 | 恢复 | -| ------ | ------------------------------------------------------ | ------------------------ | -| SQLite | 停止服务后复制 `data/classhelper.db` | 替换该文件后启动 | -| MySQL | `mysqldump -u classhelper -p classhelper > backup.sql` | `mysql ... < backup.sql` | +| 形态 | 备份 | 恢复 | +| ------ | ---------------------------------------------------------------------- | ------------------------ | +| SQLite | 停止服务后复制 `data/classhelper.db`(Linux:`/opt/classhelper/data`) | 替换该文件后启动 | +| MySQL | `mysqldump -u classhelper -p classhelper > backup.sql` | `mysql ... < backup.sql` | 建议:每日自动备份 + 保留 7 份;变更 `JWT_SECRET` 会导致所有用户重新登录,请勿在无必要时更换。 -### 5.4 安全清单(上线前逐项确认) +### 6.4 安全清单(上线前逐项确认) - [ ] 修改 `admin` 与教师账号的默认密码 - [ ] `JWT_SECRET` 使用随机值(≥48 字节),且妥善保管 @@ -212,7 +361,7 @@ start.cmd :: Windows --- -## 六、故障排查 +## 七、故障排查 | 现象 | 排查方向 | | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -226,10 +375,14 @@ start.cmd :: Windows | 升级后需要重新登录 | 说明 `.env` 被覆盖(JWT 密钥变化);覆盖安装应保留 `.env`,请检查安装目录权限 | | 报 `does not provide an export named ...` | 安装包内的 `node_modules/@classhelper/shared/dist` 是旧构建(打包机没有先构建 shared)。重新执行 `pnpm dist:server`,该脚本会先构建 shared/server/web 并校验产物新鲜度 | | 报 `node.exe` 不是有效应用 / 服务启动即退出 | 安装包内置的 `node.exe` 被误打成 Electron 可执行文件(打包机在 Electron 宿主里跑 pnpm 时会取到 `process.execPath`)。已由 `scripts/lib/node-runtime.mjs` 修正;可用 `install\node.exe -v` 确认能打印 Node 版本 | +| **Linux**:`systemctl start` 后立刻退出 | `journalctl -u classhelper -n 50`;常见原因:`.env` 里 `JWT_SECRET` 为空/太短、`node` 路径在 unit 里不对(脚本用 `command -v node` 的结果)、`data/` 不可写(`chown classhelper:classhelper`) | +| **Linux**:403/500 且日志提示 `EACCES` | `ProtectSystem=full` 下服务只能写 `ReadWritePaths`;若自定义了数据目录,记得同步改 unit 里的这两项 | +| **Linux**:`npm install` 报网络/代理错误 | 目标机需要能访问 npm registry;离线环境可在别的机器 `npm install --omit=dev` 后把整个 `node_modules` 一起拷过去 | +| **Linux**:SQLite 提示 `database is locked` | 同一数据库文件只允许一个服务实例;确认没有重复启动(`systemctl status` + `pgrep -af 'server/dist/index.js'`),需要并发就换 MySQL | --- -## 七、性能与容量参考 +## 八、性能与容量参考 - 单机 SQLite 形态适合**单校规模**(数千用户、每天数百次写操作),实测 62 项接口与实时推送全部达标。 - 多校区 / 高并发建议切 MySQL,并横向扩容服务端(Socket.IO 需要配置 Redis adapter 才能多实例广播,