- deploy/install-linux.sh:Linux 一键部署(Node 版本检查 → 铺 /opt/classhelper → 生成 .env 与随机
JWT_SECRET → 安装 systemd 服务 → 就绪探针;支持 --check/--port/--dir/--no-service/--uninstall)
- deploy/classhelper.service:systemd 单元模板(__NODE__/__DIR__/__USER__ 由脚本替换后安装)
- deploy/{.env.example,docker-compose.yml}:补充 DEFAULT_STUDENT/CLASS/TEACHER_PASSWORD 默认密码环境变量
- docs/production.md:新增形态 C(Linux systemd + SQLite)、Nginx + HTTPS、升级/备份/排障、探针与安全清单
- README.md:生产部署四形态对照表、目录树与验证表更新
280 lines
13 KiB
Bash
280 lines
13 KiB
Bash
#!/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" <<EOF
|
||
# 班级小助手服务端(install-linux.sh 生成,请妥善保管)
|
||
# 修改后重启服务生效:systemctl restart ${SERVICE_NAME}
|
||
|
||
NODE_ENV=production
|
||
HOST=0.0.0.0
|
||
PORT=${PORT}
|
||
|
||
# 数据库:SQLite 文件放在安装根目录的 data 下(备份只需复制该目录)
|
||
DATABASE_PROVIDER=sqlite
|
||
DATABASE_URL="file:../data/classhelper.db"
|
||
# 空库首启动自动执行随包迁移并创建管理员
|
||
AUTO_MIGRATE=true
|
||
|
||
# 令牌签名密钥(随机生成;更换后所有用户需重新登录)
|
||
JWT_SECRET=${JWT_SECRET}
|
||
JWT_EXPIRES_IN=7d
|
||
|
||
# 允许的跨域来源:走域名/反代时建议改成具体来源,例如 https://class.example.com
|
||
CORS_ORIGIN=*
|
||
# 前面有 Nginx 等反向代理时设为 1,限流才会按真实客户端 IP 统计
|
||
TRUST_PROXY=1
|
||
|
||
RATE_LIMIT_ENABLED=true
|
||
STARTUP_DB_CHECK=true
|
||
PID_FILE=../data/server.pid
|
||
LOG_LEVEL=info
|
||
|
||
BCRYPT_ROUNDS=10
|
||
DEFAULT_STUDENT_PASSWORD=123456
|
||
DEFAULT_CLASS_PASSWORD=123456
|
||
DEFAULT_TEACHER_PASSWORD=123456
|
||
TERM_START_DATE=
|
||
EOF
|
||
log "已生成 ${ENV_FILE}(含随机 JWT_SECRET)"
|
||
fi
|
||
|
||
# data/logs 必须可写;程序文件只读即可
|
||
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${TARGET_DIR}/data" "${TARGET_DIR}/logs"
|
||
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${TARGET_DIR}/node_modules"
|
||
chown "${SERVICE_USER}:${SERVICE_USER}" "$ENV_FILE"
|
||
chown -R root:root "${TARGET_DIR}/server" "${TARGET_DIR}/web"
|
||
chmod 600 "$ENV_FILE"
|
||
|
||
# ---------------------------------------------------------------- systemd
|
||
if [ "$WITH_SERVICE" = "1" ]; then
|
||
command -v systemctl >/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 <<EOF
|
||
|
||
------------------------------------------------------------------
|
||
安装完成
|
||
访问管理端:http://<服务器IP>:${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
|