Files
ClassHelper/deploy/install.sh
T

1311 lines
57 KiB
Bash
Raw Normal View History

#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# ClassHelper · 服务端 Linux 一键安装器
#
# 支持:Ubuntu 20.04+ / Debian 11+ / CentOS 7+ / Rocky Linux / AlmaLinux 8+
# (自动识别 apt / yum / dnf;直接安装形态要求 systemd)
#
# 两种形态:
# native(默认)—— 装成 systemd 服务,自带 Node 运行时,SQLite 或 MySQL
# docker —— 用随包的 Dockerfile 构建镜像 + compose 起容器(需要 docker)
#
# 用法(一行安装,推荐给最终用户):
# curl -fsSL -o /tmp/classhelper-install.sh \
# https://raw.githubusercontent.com/LoveChengke/ClassHelper/master/deploy/install.sh \
# && sudo bash /tmp/classhelper-install.sh
#
# 脚本会自动从 GitHub Release 取**最新版**服务端安装包,并取发布页上同名的 .sha256 做校验,
# 所以整条链路只需要这一行。要加参数就接在最后:`sudo bash /tmp/classhelper-install.sh --check`
# --check 只体检、不安装(不需要 root) --yes 全部默认、不交互
# --version 1.1.2 装指定版本 --port 8080 自定义端口
# --admin-password-stdin 从 stdin 读初始管理员密码
#
# ⚠ 别写成 `bash -c "$(curl …)" --check`:`bash -c '代码' 第一个参数` 里那个参数会变成 **$0**、
# 不是 $1,脚本根本收不到它(实测踩到:想着体检、结果走进交互式安装)。先落盘再执行没这个坑。
# 内网/离线:把 tar.gz 与 install.sh 一起拷进去,再
# sudo bash install.sh --package /path/to/classhelper-server-linux-x64-<版本>.tar.gz
#
# 本地已有的脚本用法:
# sudo bash install.sh # 交互式(TUI 菜单 + 问答)
# sudo bash install.sh --check # 只体检(不需要 root,不改动任何东西)
# sudo bash install.sh --uninstall # 卸载(会问保留什么)
#
# 自动化 / 验收常用参数(给了参数就不问对应的问题):
# --mode native|docker 安装形态
# --dir /opt/classhelper 安装目录
# --port 4000 服务端口
# --database sqlite|mysql 数据库
# --mysql-host/-port/-db/-user/-password-stdin
# --admin-password-stdin 从 stdin 读一行作为初始管理员密码(不进 argv / history)
# --package <url|file://路径> 服务端安装包(默认从 GitHub Release 取最新版)
# --sha256 <哈希> 安装包校验值(给了就强制校验)
# --node-source bundled|system|tarball Node 运行时来源(默认 bundled)
# --node-mirror <url> Node 下载镜像(默认 https://npmmirror.com/mirrors/node)
# --node-tarball <url|路径> 直接用指定的 Node 压缩包(内网离线场景)
# --with-nginx 顺带放一份 Nginx 反代配置(不自动申请证书)
# --open-firewall 按检测到的防火墙放行端口
# --no-autostart 不设开机自启(默认设为自启;装完仍可 systemctl enable 改回来)
# --yes 全部用默认值,不再交互(CI / 验收用)
#
# 幂等:重复执行 = 覆盖安装。已有 config.env(含 JWT_SECRET)与 data/ 一律保留,不重置密钥。
# 日志:/var/log/classhelper/install.log
# ---------------------------------------------------------------------------
set -euo pipefail
# ---------------------------------------------------------------- 集中变量
PRODUCT="ClassHelper"
SERVICE_NAME="classhelper"
INSTALL_DIR="/opt/classhelper"
CONFIG_DIR="/etc/classhelper"
CONFIG_FILE="${CONFIG_DIR}/config.env"
LOG_DIR="/var/log/classhelper"
BACKUP_DIR="/var/backups/classhelper"
BIN_DIR="/usr/local/bin"
SERVICE_USER="classhelper"
SERVICE_GROUP="classhelper"
PORT="4000"
MODE="native"
DB_KIND="sqlite"
MYSQL_HOST="127.0.0.1"
MYSQL_PORT="3306"
MYSQL_DB="classhelper"
MYSQL_USER="classhelper"
MYSQL_PASSWORD=""
# 服务端安装包(默认取 GitHub Release 上的 Linux 资产)
REPO_SLUG="LoveChengke/ClassHelper"
GITHUB_REPO_URL="https://github.com/${REPO_SLUG}"
GITHUB_RELEASES_PAGE="${GITHUB_REPO_URL}/releases"
PACKAGE_URL=""
PACKAGE_SHA256=""
VERSION=""
STAGE_DIR=""
# Node 运行时
NODE_MAJOR="22"
NODE_SOURCE="bundled"
NODE_MIRROR="https://npmmirror.com/mirrors/node"
NODE_TARBALL=""
NODE_DIR_NAME="runtime/node"
WITH_NGINX=0
OPEN_FIREWALL=0
# 是否设为开机自启(systemd enable)。需求:「支持选择开机自启动」——
# 安装器只负责**默认值**,装完之后随时可以用 `systemctl disable/enable classhelper` 改
# (Windows 侧对应 Web 管理端「系统设置」里的开关,见 PUT /api/system/autostart)。
AUTOSTART=1
ASSUME_YES=0
CHECK_ONLY=0
UNINSTALL=0
ADMIN_PASSWORD=""
ADMIN_PASSWORD_FROM_STDIN=0
INSTALL_DOCKER=0
FORCE=0
LOG_FILE="${LOG_DIR}/install.log"
# 脚本自身路径。两种"一行脚本安装"方式下这里会不一样,必须都能活:
# bash -c "$(curl -fsSL .../install.sh)" → 没有脚本文件(BASH_SOURCE 为空)
# curl -fsSL .../install.sh | bash → 脚本内容是 stdin,同样没有文件
# SCRIPT_DIR 只用来找与脚本同目录的模板(classhelper.service / nginx.conf),
# 从远端执行时找不到也不要紧 —— 安装包里带了同样的模板(见 place_program_files)。
SELF_PATH="${BASH_SOURCE[0]:-}"
if [ -n "$SELF_PATH" ] && [ -f "$SELF_PATH" ]; then
SCRIPT_PATH="$(cd "$(dirname "$SELF_PATH")" && pwd)/$(basename "$SELF_PATH")"
SCRIPT_DIR="$(dirname "$SCRIPT_PATH")"
else
SCRIPT_PATH=""
SCRIPT_DIR=""
fi
# ---------------------------------------------------------------- 输出
if [ -t 1 ] && [ "${TERM:-dumb}" != "dumb" ] && [ -z "${NO_COLOR:-}" ]; then
C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'; C_DIM=$'\033[2m'
C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_RED=$'\033[31m'; C_CYAN=$'\033[36m'; C_MAGENTA=$'\033[35m'
else
C_RESET=''; C_BOLD=''; C_DIM=''; C_GREEN=''; C_YELLOW=''; C_RED=''; C_CYAN=''; C_MAGENTA=''
fi
log() { printf '%s[classhelper]%s %s\n' "$C_GREEN" "$C_RESET" "$*"; }
info() { printf '%s[classhelper]%s %s\n' "$C_CYAN" "$C_RESET" "$*"; }
step() { printf '\n%s▶ %s%s\n' "$C_MAGENTA" "$*" "$C_RESET"; }
warn() { printf '%s[classhelper]%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; }
# 真正的 stderr 先留一份到 fd 9。main() 里会用 `exec > >(tee -a 安装日志)` 接管 stdout/stderr,
# 而脚本退出时那个进程替换的子进程**可能来不及把最后几行写出去** —— 后果是致命错误被彻底吞掉,
# 用户只看到脚本默默退回 shell、一条提示都没有(2026-10-05 实测踩到,排查成本很高)。
# 所以 die / on_error 绕开 tee:直接写 fd 9 + 直接追加日志文件。
exec 9>&2
fail_print() { # $1=要同时显示到终端与日志的错误文本
printf '%s\n' "$1" >&9 2>/dev/null || printf '%s\n' "$1" >&2
# 日志目录还没建起来时(比如卡在 root 检查)不要去追加,否则 bash 会额外吐一句
# "install.log: No such file or directory",把真正的原因淹掉
if [ -n "${LOG_FILE:-}" ] && [ -d "$(dirname "$LOG_FILE")" ]; then
printf '[%s] %s\n' "$(date '+%F %T')" "$1" >>"$LOG_FILE" 2>/dev/null || true
fi
return 0
}
die() { trap - ERR; fail_print "$(printf '%s[classhelper]%s %s' "$C_RED" "$C_RESET" "$*")"; exit 1; }
on_error() {
local code=$?
fail_print "$(printf '\n%s[classhelper]%s 安装中断(第 %s 行,退出码 %s)' "$C_RED" "$C_RESET" "$1" "$code")"
[ -f "${LOG_FILE:-}" ] && fail_print "完整日志:${LOG_FILE}"
exit "$code"
}
trap 'on_error "$LINENO"' ERR
banner() {
printf '\n%s' "$C_CYAN"
cat <<'ASCII'
██████╗██╗ █████╗ ███████╗███████╗██╗ ██╗███████╗██╗ ██████╗ ███████╗██████╗
██╔════╝██║ ██╔══██╗██╔════╝██╔════╝██║ ██║██╔════╝██║ ██╔══██╗██╔════╝██╔══██╗
██║ ██║ ███████║███████╗███████╗███████║█████╗ ██║ ██████╔╝█████╗ ██████╔╝
██║ ██║ ██╔══██║╚════██║╚════██║██╔══██║██╔══╝ ██║ ██╔═══╝ ██╔══╝ ██╔══██╗
╚██████╗███████╗██║ ██║███████║███████║██║ ██║███████╗███████╗██║ ███████╗██║ ██║
╚═════╝╚══════╝╚═╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚══════╝╚═╝ ╚═╝
ASCII
printf '%s %s · 服务端 Linux 安装器%s\n' "$C_BOLD" "$PRODUCT" "$C_RESET"
printf '%s 一键安装 + classhelper 运维命令(status / password / upgrade / backup …)%s\n\n' "$C_DIM" "$C_RESET"
}
# 用法:能读到脚本文件时直接打印文件头部注释(唯一真身,不会漂);从远端执行时没有文件可读,
# 退化成下面这份精简参数表。
usage() {
if [ -n "$SCRIPT_PATH" ] && [ -r "$SCRIPT_PATH" ]; then
awk 'NR == 1 { next }
/^# -{10,}/ { seen++; if (seen == 2) exit; next }
seen == 1 { sub(/^# ?/, ""); print }' "$SCRIPT_PATH"
return 0
fi
cat <<'USAGE'
ClassHelper · 服务端 Linux 安装器
(本次是从远端直接执行的,没有本地脚本文件可读,这里只列常用参数;
完整说明见 https://github.com/LoveChengke/ClassHelper/blob/master/docs/management/linux-deploy.md )
--mode native|docker 安装形态(默认 native:systemd + 内置 Node)
--dir /opt/classhelper 安装目录 --port 4000 服务端口
--database sqlite|mysql 数据库(默认 sqlite)
--mysql-host/-port/-db/-user/-password-stdin MySQL 连接信息
--admin-password-stdin 从 stdin 读一行作为初始管理员密码
--package <url|file://路径> 服务端安装包(默认从 GitHub Release 取最新版)
--sha256 <哈希> 安装包校验值(不给时自动取发布页上的同名 .sha256)
--version <X.Y.Z> 指定安装的版本
--node-source bundled|system|tarball Node 运行时来源(默认 bundled)
--node-mirror <url> / --node-tarball <url|路径> Node 下载镜像 / 离线包
--with-nginx 顺带放一份 Nginx 反代配置
--open-firewall 按检测到的防火墙放行端口
--install-docker Docker 形态缺少 docker 时自动安装
--yes 全部用默认值,不再交互(CI / 验收用)
--force 覆盖已有配置;无 systemd 时只铺文件不装服务
--check 只体检(不需要 root,不改动系统)
--uninstall 卸载
USAGE
}
# ---------------------------------------------------------------- 参数解析
read_stdin_line() { IFS= read -r "$1" || true; }
parse_args() {
while [ $# -gt 0 ]; do
case "$1" in
--mode) MODE="${2:-}"; shift 2 ;;
--dir) INSTALL_DIR="${2:-}"; shift 2 ;;
--port) PORT="${2:-}"; shift 2 ;;
--user) SERVICE_USER="${2:-}"; SERVICE_GROUP="${2:-}"; shift 2 ;;
--database) DB_KIND="${2:-}"; shift 2 ;;
--mysql-host) MYSQL_HOST="${2:-}"; shift 2 ;;
--mysql-port) MYSQL_PORT="${2:-}"; shift 2 ;;
--mysql-db) MYSQL_DB="${2:-}"; shift 2 ;;
--mysql-user) MYSQL_USER="${2:-}"; shift 2 ;;
--mysql-password) MYSQL_PASSWORD="${2:-}"; shift 2 ;;
--mysql-password-stdin) read_stdin_line MYSQL_PASSWORD; shift ;;
--admin-password-stdin) ADMIN_PASSWORD_FROM_STDIN=1; shift ;;
--package) PACKAGE_URL="${2:-}"; shift 2 ;;
--sha256) PACKAGE_SHA256="${2:-}"; shift 2 ;;
--version) VERSION="${2:-}"; shift 2 ;;
--node-source) NODE_SOURCE="${2:-}"; shift 2 ;;
--node-mirror) NODE_MIRROR="${2:-}"; shift 2 ;;
--node-tarball) NODE_TARBALL="${2:-}"; NODE_SOURCE="tarball"; shift 2 ;;
--with-nginx) WITH_NGINX=1; shift ;;
--open-firewall) OPEN_FIREWALL=1; shift ;;
--autostart) AUTOSTART=1; shift ;;
--no-autostart) AUTOSTART=0; shift ;;
--install-docker) INSTALL_DOCKER=1; shift ;;
--yes|-y) ASSUME_YES=1; shift ;;
--force) FORCE=1; shift ;;
--check) CHECK_ONLY=1; shift ;;
--uninstall|--remove) UNINSTALL=1; shift ;;
-h|--help) usage; exit 0 ;;
*) die "未知参数:$1(--help 看用法)" ;;
esac
done
case "$MODE" in native|docker) ;; *) die "--mode 只能是 native 或 docker" ;; esac
case "$DB_KIND" in sqlite|mysql) ;; *) die "--database 只能是 sqlite 或 mysql" ;; esac
case "$NODE_SOURCE" in bundled|system|tarball) ;; *) die "--node-source 只能是 bundled / system / tarball" ;; esac
case "$INSTALL_DIR" in
/) die "安装目录不能是 /" ;;
/usr|/usr/*|/etc|/etc/*|/bin|/bin/*|/sbin|/sbin/*|/lib|/lib/*|/boot|/boot/*) die "安装目录不能是系统目录:${INSTALL_DIR}" ;;
/*) ;;
*) die "安装目录必须是绝对路径:${INSTALL_DIR}" ;;
esac
}
# ---------------------------------------------------------------- 环境探测
OS_ID=""; OS_VER=""; OS_NAME=""; PKG=""
detect_os() {
# /etc/os-release 里也有 VERSION(Ubuntu 是 "24.04.1 LTS (Noble Numbat)"),直接 source 会盖掉
# 安装包版本号:.installed.json 会记成发行版名,而且不带 --package 时 resolve_package_url
# 会拿它当 Release tag 去拼下载地址(必然 404)。
local keep_version="${VERSION:-}"
if [ -r /etc/os-release ]; then
# shellcheck disable=SC1091
. /etc/os-release
OS_ID="${ID:-unknown}"
OS_VER="${VERSION_ID:-}"
OS_NAME="${PRETTY_NAME:-${OS_ID} ${OS_VER}}"
else
OS_NAME="$(uname -s) $(uname -r)"
fi
VERSION="$keep_version"
if command -v apt-get >/dev/null 2>&1; then PKG="apt"
elif command -v dnf >/dev/null 2>&1; then PKG="dnf"
elif command -v yum >/dev/null 2>&1; then PKG="yum"
else PKG=""
fi
}
has_systemd() { [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; }
pkg_install() {
[ "$#" -gt 0 ] || return 0
case "$PKG" in
apt) DEBIAN_FRONTEND=noninteractive apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends "$@" ;;
dnf) dnf install -y -q "$@" ;;
yum) yum install -y -q "$@" ;;
*) warn "未识别包管理器,请手工安装:$*"; return 1 ;;
esac
}
ensure_dependencies() {
step "检查并补齐依赖"
local missing=() tool
for tool in curl tar gzip; do
command -v "$tool" >/dev/null 2>&1 || missing+=("$tool")
done
command -v sha256sum >/dev/null 2>&1 || missing+=("coreutils")
if [ "${#missing[@]}" -gt 0 ]; then
info "缺少:${missing[*]}(用 ${PKG:-未识别的包管理器} 安装)"
local pkgs=()
for tool in "${missing[@]}"; do
case "$tool" in
curl) pkgs+=("curl") ;;
tar) pkgs+=("tar") ;;
gzip) pkgs+=("gzip") ;;
coreutils) pkgs+=("coreutils") ;;
esac
done
pkg_install "${pkgs[@]}" || die "依赖安装失败,请手工安装:${missing[*]}"
fi
command -v curl >/dev/null 2>&1 || die "缺少 curl,无法继续"
log "依赖就绪(${PKG:-未知包管理器})"
}
port_in_use() {
local port="$1"
if command -v ss >/dev/null 2>&1; then
ss -ltn 2>/dev/null | awk -v p=":${port}\$" '$4 ~ p {found=1} END {exit !found}'
elif command -v netstat >/dev/null 2>&1; then
netstat -ltn 2>/dev/null | awk -v p=":${port}\$" '$4 ~ p {found=1} END {exit !found}'
else
return 1
fi
}
is_installed() { [ -f "${INSTALL_DIR}/.installed.json" ]; }
existing_version() {
if [ -f "${INSTALL_DIR}/VERSION" ]; then tr -d '[:space:]' <"${INSTALL_DIR}/VERSION"; else printf '未知'; fi
}
config_value() { # $1=KEY:从已有配置里读一项(覆盖安装时沿用旧值)
local key="$1"
[ -f "$CONFIG_FILE" ] || return 0
sed -n "s/^[[:space:]]*${key}=//p" "$CONFIG_FILE" | tail -n 1 | tr -d '"'"'"
}
# ---------------------------------------------------------------- 交互问答
# 交互输入的统一出口。
#
# 为什么不能直接用 `read`:一行脚本安装的两种写法里,stdin 未必是终端 ——
# curl -fsSL .../install.sh | bash → **stdin 就是脚本内容本身**,read 会把脚本的下一行吃掉,
# 表现为"问题全被跳过 + 后续脚本乱套"
# bash -c "$(curl -fsSL .../install.sh)" → stdin 仍是终端,正常
# 所以统一优先读 /dev/tty,退回 stdin;两者都不行(CI、定时任务)才按"非交互"处理。
PROMPT_FD=""
open_prompt_fd() {
if [ -n "$PROMPT_FD" ]; then return 0; fi
if [ "${CLASSHELPER_NO_TTY:-0}" = "1" ]; then return 1; fi
# 必须用 `<>`(读写)打开,不能只用 `<`(只读):下面 read_prompt 要往 fd 3 **写**提示语,
# 而往只读 fd 写会 EBADF("printf: write error: Bad file descriptor"),在 set -e 下
# 直接把安装脚本打死 —— 现象是"菜单打印完就默默退回 shell",错误还被 tee 吞掉,极难查。
# 2026-10-05 在 Ubuntu 24.04 上实测踩到并定位。
if exec 3<>/dev/tty 2>/dev/null; then PROMPT_FD=3; return 0; fi
return 1
}
# $1=提示语 $2=目标变量名 $3=1 表示隐藏输入(密码)
#
# 注意:**这个函数永远返回 0**。它一旦返回非 0,在 `set -e` 下会把整个安装脚本当场终止,
# 而调用处(`read_prompt "…" choice`)是独立命令、不会去接这个返回值 —— 实测后果是
# "菜单打印完就默默退回 shell,一条错误信息都没有",极难排查。
# 读不到终端时就把变量留空,由调用方按默认值走。
read_prompt() {
local prompt="$1" var="$2" hidden="${3:-0}" value=""
printf -v "$var" ''
if open_prompt_fd; then
# 提示语写不进去时**不能让它把脚本打死**:退化成"这一项用默认值",并且把这件事说出来
if printf '%s' "$prompt" >&3 2>/dev/null; then
if [ "$hidden" = "1" ]; then IFS= read -r -s value <&3 || true; else IFS= read -r value <&3 || true; fi
printf '\n' >&3 2>/dev/null || true
else
fail_print "$(printf '%s[classhelper]%s 写不进终端(fd 3 不可写),这一项按默认值处理' "$C_YELLOW" "$C_RESET")"
fi
fi
# 刻意**不**退回 stdin:`curl … | bash` 时 stdin 就是脚本内容本身,
# 从 stdin 读会把脚本的下一行当成用户输入吃掉,表现为"脚本后半段莫名其妙乱套"。
printf -v "$var" '%s' "$value"
return 0
}
# 能不能问用户:--yes 不问;没有终端可读也不问(这时一律用默认值)
can_prompt() {
[ "$ASSUME_YES" = "1" ] && return 1
open_prompt_fd
}
ask() { # $1=提示 $2=默认值 $3=目标变量
local prompt="$1" default="$2" var="$3" answer=""
if ! can_prompt; then
printf -v "$var" '%s' "$default"
return 0
fi
if [ -n "$default" ]; then
read_prompt "$(printf '%s[?]%s %s [%s]: ' "$C_CYAN" "$C_RESET" "$prompt" "$default")" answer
else
read_prompt "$(printf '%s[?]%s %s: ' "$C_CYAN" "$C_RESET" "$prompt")" answer
fi
printf -v "$var" '%s' "${answer:-$default}"
}
ask_secret() { # $1=提示 $2=目标变量(不回显)
local prompt="$1" var="$2" value=""
printf -v "$var" ''
if ! can_prompt; then return 0; fi
read_prompt "$(printf '%s[?]%s %s' "$C_CYAN" "$C_RESET" "$prompt")" value 1
printf -v "$var" '%s' "$value"
}
ask_yes_no() { # $1=提示 $2=默认 y/n;返回 0=是
local prompt="$1" default="$2" answer=""
if ! can_prompt; then
[ "$default" = "y" ]
return
fi
read_prompt "$(printf '%s[?]%s %s [%s]: ' "$C_CYAN" "$C_RESET" "$prompt" "$default")" answer
case "${answer:-$default}" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac
}
choose_mode_interactive() {
if [ "$ASSUME_YES" = "1" ]; then return 0; fi
# 交互式安装却读不到终端时**明确报错**,而不是默默按默认值装下去:
# 后者会让用户以为自己在选,其实一个都没问到(实测踩到过)。
if ! can_prompt; then
die "读不到终端(/dev/tty 打不开),没法交互式选择安装形态。
· 确实想非交互安装:加 --yes(默认 native + SQLite + ${INSTALL_DIR} + 端口 ${PORT})
· 或者先把脚本存成文件,再在同一终端里执行:
curl -fsSL -o /tmp/classhelper-install.sh https://raw.githubusercontent.com/LoveChengke/ClassHelper/master/deploy/install.sh
sudo bash /tmp/classhelper-install.sh
· 只想先体检(不需要终端、也不需要 root):加 --check"
fi
printf '%s请选择:%s\n' "$C_BOLD" "$C_RESET"
printf ' 1) 直接安装(systemd 服务 + 内置 Node 运行时,默认 SQLite)%s ← 推荐%s\n' "$C_GREEN" "$C_RESET"
printf ' 2) 直接安装 + MySQL\n'
printf ' 3) Docker 部署(构建镜像 + compose 起容器)\n'
printf ' 4) 只体检(--check,不改动系统)\n'
printf ' 5) 卸载\n'
local choice=""
read_prompt "$(printf '%s[?]%s 输入序号 [1]: ' "$C_CYAN" "$C_RESET")" choice
case "${choice:-1}" in
1) MODE="native"; DB_KIND="sqlite" ;;
2) MODE="native"; DB_KIND="mysql" ;;
3) MODE="docker" ;;
4) CHECK_ONLY=1 ;;
5) UNINSTALL=1 ;;
*) die "无效的选择:${choice}" ;;
esac
printf '\n'
}
collect_answers() {
if [ "$CHECK_ONLY" = "1" ] || [ "$UNINSTALL" = "1" ] || [ "$ASSUME_YES" = "1" ]; then return 0; fi
ask "安装目录" "$INSTALL_DIR" INSTALL_DIR
ask "服务端口" "$PORT" PORT
if [ "$MODE" = "native" ] && [ "$DB_KIND" = "sqlite" ]; then
if ask_yes_no "改用 MySQL(默认 SQLite:单机够用,备份就是拷文件)?" "n"; then
DB_KIND="mysql"
fi
fi
if [ "$DB_KIND" = "mysql" ]; then
ask "MySQL 主机" "$MYSQL_HOST" MYSQL_HOST
ask "MySQL 端口" "$MYSQL_PORT" MYSQL_PORT
ask "MySQL 库名" "$MYSQL_DB" MYSQL_DB
ask "MySQL 用户名" "$MYSQL_USER" MYSQL_USER
ask_secret "MySQL 密码(不回显):" MYSQL_PASSWORD
fi
if [ "$NODE_SOURCE" = "bundled" ]; then
if ask_yes_no "本机已装 Node ≥20.19 时改用系统 Node(不额外下载内置运行时)?" "n"; then
NODE_SOURCE="system"
fi
fi
if [ "$ADMIN_PASSWORD_FROM_STDIN" = "0" ] && [ -z "$ADMIN_PASSWORD" ]; then
ask_secret "初始管理员(admin)密码(留空则用服务端默认 admin123;公网部署请务必设置):" ADMIN_PASSWORD
fi
if ask_yes_no "顺带放一份 Nginx 反向代理配置到 ${CONFIG_DIR}/nginx.conf?" "n"; then WITH_NGINX=1; fi
if ask_yes_no "按检测到的防火墙放行端口 ${PORT}?" "n"; then OPEN_FIREWALL=1; fi
if ask_yes_no "设为开机自启(systemctl enable ${SERVICE_NAME})?" "y"; then AUTOSTART=1; else AUTOSTART=0; fi
}
# ---------------------------------------------------------------- 用户与目录
ensure_user() {
if id -u "$SERVICE_USER" >/dev/null 2>&1; then
log "系统用户 ${SERVICE_USER} 已存在,跳过创建"
return 0
fi
if ! getent group "$SERVICE_GROUP" >/dev/null 2>&1; then
groupadd --system "$SERVICE_GROUP"
fi
useradd --system -g "$SERVICE_GROUP" -d "$INSTALL_DIR" -s /sbin/nologin -c "ClassHelper Server" "$SERVICE_USER" 2>/dev/null \
|| useradd --system -g "$SERVICE_GROUP" -d "$INSTALL_DIR" -s /usr/sbin/nologin -c "ClassHelper Server" "$SERVICE_USER"
log "已创建系统用户 ${SERVICE_USER}"
}
ensure_dirs() {
step "准备目录"
mkdir -p "$INSTALL_DIR" "${INSTALL_DIR}/data" "${INSTALL_DIR}/logs" \
"${INSTALL_DIR}/${NODE_DIR_NAME%/*}" "$CONFIG_DIR" "$LOG_DIR" "$BACKUP_DIR"
chmod 700 "$CONFIG_DIR" "$LOG_DIR" "$BACKUP_DIR"
# 配置目录归服务用户所有(Web 端「一键切换数据库」要改写 config.env)
chown "$SERVICE_USER:$SERVICE_GROUP" "$CONFIG_DIR"
chmod 755 "$INSTALL_DIR"
chown -R "$SERVICE_USER:$SERVICE_GROUP" "${INSTALL_DIR}/data" "${INSTALL_DIR}/logs"
log "安装目录 ${INSTALL_DIR}|配置 ${CONFIG_DIR}|日志 ${LOG_DIR}|备份 ${BACKUP_DIR}"
}
# ---------------------------------------------------------------- 安装包
asset_arch() {
case "$(uname -m)" in
x86_64|amd64) printf 'x64' ;;
aarch64|arm64) printf 'arm64' ;;
*) uname -m ;;
esac
}
github_latest_tag() {
local json
json="$(curl -fsSL --max-time 20 -H 'User-Agent: ClassHelper-Installer' \
"https://api.github.com/repos/${REPO_SLUG}/releases/latest" 2>/dev/null)" || return 1
printf '%s' "$json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1
}
resolve_package_url() {
local tag="$VERSION" ver
if [ -z "$tag" ]; then
tag="$(github_latest_tag)" || true
if [ -z "$tag" ]; then
die "取不到 GitHub 最新版本(本机可能没有外网)。请用 --package 指定安装包,例如:
sudo bash install.sh --package /tmp/classhelper-server-linux-x64-1.1.2.tar.gz"
fi
fi
# tag 一律补上 v 前缀:Release 的 tag 是 v1.3.0,而 --version 的用法是 `--version 1.3.0`(不带 v)——
# 直接拿 VERSION 去拼就成了 .../download/1.3.0/...,GitHub 按 tag 精确匹配,必然 404
# (2026-10-11 实测:`install.sh --version 1.3.0` 下载 404,而 `classhelper upgrade --version 1.3.0` 正常
# —— 因为 classhelper 的 resolve_download 一直有这句,install.sh 漏了)。
# 传 `v1.3.0`、`1.3.0`、或者不传(走 latest)三种写法都落在这里。
tag="v${tag#v}"
ver="${tag#v}"
# 注意这里用 GITHUB_REPO_URL 而不是 GITHUB_RELEASES_PAGE:后者本身已经以 /releases 结尾,
# 再拼一次就变成 .../releases/releases/download/... (必然 404,实测踩到)。
printf '%s/releases/download/%s/classhelper-server-linux-%s-%s.tar.gz' \
"$GITHUB_REPO_URL" "$tag" "$(asset_arch)" "$ver"
}
# 下载 + 校验 + 解压;结果放进全局 STAGE_DIR(不用命令替换取,否则会把日志一起捕获)
fetch_package() {
step "获取服务端安装包"
local url="${PACKAGE_URL}"
if [ -z "$url" ]; then url="$(resolve_package_url)"; fi
local tmp; tmp="$(mktemp -d)"
local pkg="${tmp}/classhelper-server.tar.gz"
info "下载:${url}"
case "$url" in
file://*|/*|./*|../*) cp -f "${url#file://}" "$pkg" ;;
*) curl -fSL --max-time 600 -H 'User-Agent: ClassHelper-Installer' -o "$pkg" "$url" ;;
esac
[ -s "$pkg" ] || die "安装包为空:${url}"
# 没给 --sha256 时,把发布页上同名的 .sha256 取回来自己校验。
# 这一步是一行脚本安装下的**唯一**校验机会:用户不会自己去核哈希,
# 而少了它,"校验"就只是把算出来的哈希记进日志而已。
if [ -z "$PACKAGE_SHA256" ]; then
local sum_file="${tmp}/expected.sha256"
case "$url" in
file://*|/*|./*|../*) [ -f "${url#file://}.sha256" ] && cp -f "${url#file://}.sha256" "$sum_file" ;;
*) curl -fsSL --max-time 60 -H 'User-Agent: ClassHelper-Installer' -o "$sum_file" "${url}.sha256" 2>/dev/null ;;
esac
if [ -s "$sum_file" ]; then
PACKAGE_SHA256="$(awk '{print $1; exit}' "$sum_file")"
[ -n "$PACKAGE_SHA256" ] && info "已从发布页取到 sha256,安装包会做校验"
fi
fi
local got; got="$(sha256sum "$pkg" | awk '{print $1}')"
if [ -n "$PACKAGE_SHA256" ]; then
[ "$got" = "$PACKAGE_SHA256" ] || die "sha256 校验失败:期望 ${PACKAGE_SHA256},实际 ${got}
安装包可能在传输中损坏或被篡改,已中止(什么也没改)。可用 --sha256 <正确的哈希> 重试。"
log "sha256 校验通过"
else
PACKAGE_SHA256="$got"
warn "取不到发布页的 .sha256,本次只记录实际哈希、不做校验:${got}"
warn "(内网分发时请自带 --sha256;或手工核对上面这串哈希)"
fi
STAGE_DIR="${tmp}/pkg"
mkdir -p "$STAGE_DIR"
tar -xzf "$pkg" -C "$STAGE_DIR" --strip-components=1 || die "解压失败:安装包格式不对"
[ -f "${STAGE_DIR}/server/dist/index.js" ] || die "安装包内容不对:缺少 server/dist/index.js"
if [ -z "$VERSION" ] && [ -f "${STAGE_DIR}/VERSION" ]; then
VERSION="$(tr -d '[:space:]' <"${STAGE_DIR}/VERSION")"
fi
[ -n "$VERSION" ] || VERSION="0.0.0"
log "安装包就绪:v${VERSION}"
}
place_program_files() {
local item
step "铺开程序文件"
for item in server web node_modules tools bin systemd logrotate.d profile.d \
package.json VERSION README.txt Dockerfile nginx.conf classhelper.service.template .cross-built; do
[ -e "${STAGE_DIR}/${item}" ] || continue
rm -rf "${INSTALL_DIR:?}/${item}"
cp -a "${STAGE_DIR}/${item}" "${INSTALL_DIR}/${item}"
done
chmod +x "${INSTALL_DIR}/bin/classhelper"
chown -R root:root "${INSTALL_DIR}/server" "${INSTALL_DIR}/web" "${INSTALL_DIR}/node_modules" \
"${INSTALL_DIR}/tools" "${INSTALL_DIR}/bin" "${INSTALL_DIR}/systemd" 2>/dev/null || true
log "程序文件已就位(server / web / node_modules / tools / bin)"
}
# ---------------------------------------------------------------- Node 运行时
node_runtime_version() { # $1=可执行文件;可用且 ≥20.19 时输出版本号
[ -x "$1" ] || return 1
local ver major minor
ver="$("$1" -v 2>/dev/null)" || return 1
major="$(printf '%s' "$ver" | sed 's/^v\([0-9]*\).*/\1/')"
minor="$(printf '%s' "$ver" | sed 's/^v[0-9]*\.\([0-9]*\).*/\1/')"
if [ "${major:-0}" -gt 20 ] || { [ "${major:-0}" -eq 20 ] && [ "${minor:-0}" -ge 19 ]; }; then
printf '%s' "$ver"
return 0
fi
return 1
}
# glibc 版本(形如 2.28);判定不了就输出空串
glibc_version() {
local out
out="$(getconf GNU_LIBC_VERSION 2>/dev/null || true)"
if [ -n "$out" ]; then printf '%s' "${out##* }"; return 0; fi
out="$(ldd --version 2>/dev/null | head -n 1 || true)"
printf '%s' "$(printf '%s' "$out" | sed -n 's/.* \([0-9][0-9]*\.[0-9][0-9]*\)$/\1/p')"
}
# Node 官方 linux-x64 二进制要求 glibc ≥ 2.28(CentOS 7 是 2.17,直接跑不起来)
glibc_too_old() {
local glibc="${1:-}"
[ -n "$glibc" ] || return 1
local major minor
major="${glibc%%.*}"
minor="$(printf '%s' "$glibc" | sed 's/^[0-9]*\.\([0-9]*\).*/\1/')"
[ "${major:-0}" -lt 2 ] || { [ "${major:-0}" -eq 2 ] && [ "${minor:-0}" -lt 28 ]; }
}
setup_node() {
step "准备 Node 运行时"
local target="${INSTALL_DIR}/${NODE_DIR_NAME}"
local existing=""
if [ -x "${target}/bin/node" ]; then existing="$(node_runtime_version "${target}/bin/node" || true)"; fi
if [ -n "$existing" ]; then
log "已有内置 Node ${existing},跳过下载"
return 0
fi
if [ "$NODE_SOURCE" = "system" ]; then
local sys_node ver=""
sys_node="$(command -v node || true)"
if [ -n "$sys_node" ]; then ver="$(node_runtime_version "$sys_node" || true)"; fi
if [ -z "$ver" ]; then
die "系统 Node 不可用或版本过低(要求 ≥20.19)。去掉 --node-source system 即可用内置运行时"
fi
rm -rf "$target"; mkdir -p "${target}/bin"
ln -sfn "$sys_node" "${target}/bin/node"
log "使用系统 Node ${ver}(${sys_node})"
return 0
fi
local work; work="$(mktemp -d)"
local tarball="" sha="" filename=""
if [ -n "$NODE_TARBALL" ]; then
filename="$(basename "${NODE_TARBALL%%\?*}")"
case "$NODE_TARBALL" in
http*|file://*|/*) tarball="${work}/${filename}" ;;
*) tarball="$NODE_TARBALL" ;;
esac
if [ "$tarball" != "$NODE_TARBALL" ]; then
info "下载 Node:${NODE_TARBALL}"
case "$NODE_TARBALL" in
file://*|/*) cp -f "${NODE_TARBALL#file://}" "$tarball" ;;
*) curl -fSL --max-time 600 -o "$tarball" "$NODE_TARBALL" ;;
esac
fi
else
local dist="${NODE_MIRROR%/}/latest-v${NODE_MAJOR}.x"
local glibc; glibc="$(glibc_version)"
if glibc_too_old "$glibc"; then
# CentOS 7 这类老系统 glibc 2.17:Node 官方 x64 二进制(要求 ≥2.28)装了也起不来,
# 历史上会表现成"装完服务立刻退出、日志里只有一行 Illegal instruction / GLIBC not found"。
warn "本机 glibc ${glibc} < 2.28:Node 官方 linux-$(asset_arch) 二进制无法运行"
warn "改用 unofficial-builds 的 glibc-217 构建(功能一致,只是编译基线更低)"
dist="https://unofficial-builds.nodejs.org/download/release/latest-v${NODE_MAJOR}.x"
fi
info "读取 Node 版本清单:${dist}/SHASUMS256.txt"
local sums="${work}/SHASUMS256.txt"
curl -fsSL --max-time 60 -o "$sums" "${dist}/SHASUMS256.txt" \
|| die "下载 Node 校验清单失败(可用 --node-mirror 换镜像,或 --node-source system)"
# 优先 .tar.gz:不需要 xz 解压工具
filename="$(grep -oE "node-v[0-9.]+-linux-$(asset_arch)(-glibc-217)?\.tar\.gz" "$sums" | head -n 1)"
if [ -z "$filename" ]; then
filename="$(grep -oE "node-v[0-9.]+-linux-$(asset_arch)(-glibc-217)?\.tar\.xz" "$sums" | head -n 1)"
fi
[ -n "$filename" ] || die "校验清单里没找到 linux-$(asset_arch) 的 Node 包"
sha="$(grep -E "[ *]${filename}\$" "$sums" | awk '{print $1}' | head -n 1)"
tarball="${work}/${filename}"
info "下载 Node:${dist}/${filename}"
curl -fSL --max-time 600 -o "$tarball" "${dist}/${filename}" || die "下载 Node 失败"
fi
if [ -n "$sha" ]; then
local got; got="$(sha256sum "$tarball" | awk '{print $1}')"
[ "$got" = "$sha" ] || die "Node 压缩包 sha256 校验失败:期望 ${sha},实际 ${got}"
log "Node 压缩包校验通过(${filename})"
else
warn "未校验 Node 压缩包哈希(自定义 --node-tarball 时拿不到校验值)"
fi
local extract="${work}/extract"
mkdir -p "$extract"
case "$tarball" in
*.tar.xz) tar -xJf "$tarball" -C "$extract" || die "解压 Node 失败(需要 xz:apt/yum install xz)" ;;
*) tar -xzf "$tarball" -C "$extract" || die "解压 Node 失败" ;;
esac
local inner; inner="$(find "$extract" -maxdepth 1 -mindepth 1 -type d | head -n 1)"
[ -n "$inner" ] || die "Node 压缩包结构异常"
rm -rf "$target"; mkdir -p "$target"
cp -a "${inner}/." "$target/"
if [ -z "$(node_runtime_version "${target}/bin/node" || true)" ]; then
die "内置 Node 不可用或版本过低"
fi
log "内置 Node $("${target}/bin/node" -v) → ${target}"
rm -rf "$work"
}
# ---------------------------------------------------------------- 配置文件
gen_secret() {
local len="${1:-48}"
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex "$len"
elif [ -x "${INSTALL_DIR}/${NODE_DIR_NAME}/bin/node" ]; then
"${INSTALL_DIR}/${NODE_DIR_NAME}/bin/node" -e "process.stdout.write(require('node:crypto').randomBytes(${len}).toString('hex'))"
else
head -c "$len" /dev/urandom | od -An -tx1 | tr -d ' \n'
fi
}
# URL 里这些字符会破坏连接串解析,做个最小百分号编码
url_encode() {
printf '%s' "$1" | sed -e 's/%/%25/g' -e 's/@/%40/g' -e 's/:/%3A/g' -e 's|/|%2F|g' \
-e 's/?/%3F/g' -e 's/#/%23/g' -e 's/ /%20/g'
}
database_url() {
if [ "$MODE" = "docker" ] && [ "$DB_KIND" = "sqlite" ]; then
# 容器里 serverRoot 是 /app/server,绝对路径最稳
printf 'file:/app/data/classhelper.db'
elif [ "$DB_KIND" = "mysql" ]; then
printf 'mysql://%s:%s@%s:%s/%s' "$MYSQL_USER" "$(url_encode "$MYSQL_PASSWORD")" "$MYSQL_HOST" "$MYSQL_PORT" "$MYSQL_DB"
else
printf 'file:../data/classhelper.db'
fi
}
write_config() {
step "写入配置 ${CONFIG_FILE}"
if [ -f "$CONFIG_FILE" ] && [ "$FORCE" != "1" ]; then
log "检测到已有配置:保留(含 JWT_SECRET 与数据库设置),只补齐缺失项"
local key value
for key in NODE_ENV HOST AUTO_MIGRATE TRUST_PROXY; do
case "$key" in
NODE_ENV) value="production" ;;
HOST) value="0.0.0.0" ;;
AUTO_MIGRATE) value="true" ;;
TRUST_PROXY) value="1" ;;
esac
if ! grep -qE "^[[:space:]]*${key}=" "$CONFIG_FILE"; then
printf '\n%s=%s\n' "$key" "$value" >>"$CONFIG_FILE"
fi
done
if ! grep -qE '^[[:space:]]*JWT_SECRET=.' "$CONFIG_FILE"; then
printf '\nJWT_SECRET=%s\n' "$(gen_secret)" >>"$CONFIG_FILE"
fi
chmod 600 "$CONFIG_FILE"
chown "$SERVICE_USER:$SERVICE_GROUP" "$CONFIG_FILE"
return 0
fi
local jwt; jwt="$(gen_secret)"
local admin_line=""
if [ -n "$ADMIN_PASSWORD" ]; then
# 明文落盘,与 .env 里其它密钥同级;文件权限 600,属主是服务用户
admin_line="INITIAL_ADMIN_PASSWORD=${ADMIN_PASSWORD}"
fi
cat >"$CONFIG_FILE" <<EOF
# ---------------------------------------------------------------------------
# ClassHelper 服务端配置(由 install.sh 生成于 $(date '+%F %T'))
#
# 这是**唯一的配置真身**:安装目录里的 .env 是指向本文件的软链,
# systemd 通过 EnvironmentFile 读本文件;改完执行 classhelper restart 生效。
#
# systemd / dotenv / shell source 三种读法都兼容;值含空白时请加双引号。
# 敏感项建议用 classhelper config set(交互输入,不进 shell history)。
# ---------------------------------------------------------------------------
NODE_ENV=production
HOST=0.0.0.0
PORT=${PORT}
# 数据库:sqlite(文件在安装目录 data/)或 mysql(连接串里含密码,注意保密)
DATABASE_PROVIDER=${DB_KIND}
DATABASE_URL="$(database_url)"
# 空库首次启动时自动执行随包迁移并创建管理员(SQLite 形态)
AUTO_MIGRATE=true
# 登录令牌签名密钥(随机生成,请勿泄露;轮换用 classhelper key rotate)
JWT_SECRET=${jwt}
JWT_EXPIRES_IN=7d
# 跨域来源:走域名 / 反代时建议改成具体来源,例如 https://class.example.com
CORS_ORIGIN=*
# 前面有 Nginx 等反向代理时设为 1,限流才会按真实客户端 IP 统计
TRUST_PROXY=1
RATE_LIMIT_ENABLED=true
STARTUP_DB_CHECK=true
LOG_LEVEL=info
BCRYPT_ROUNDS=10
# 首次初始化(空库)时创建的管理员
INITIAL_ADMIN_USERNAME=admin
${admin_line}
# 新建班级 / 重置教师账号时的默认密码(ClassHelper 班级端用「班级码 + 班级密码」登录)
DEFAULT_CLASS_PASSWORD=123456
DEFAULT_TEACHER_PASSWORD=123456
# 第 1 教学周的周一(留空则按系统时间推算);格式 YYYY-MM-DD
TERM_START_DATE=
EOF
chmod 600 "$CONFIG_FILE"
chown "$SERVICE_USER:$SERVICE_GROUP" "$CONFIG_FILE"
log "配置已写入(权限 600,含随机 JWT_SECRET)"
}
link_env_file() {
# Web 端「数据库管理 → 一键切换」改写的是 <安装目录>/.env,而 systemd 注入的是 config.env;
# 用软链把两者收成一份真身,否则切换看起来成功、重启后又连回旧库(详见 docs/management/linux-deploy.md)
local link="${INSTALL_DIR}/.env"
if [ -e "$link" ] && [ ! -L "$link" ]; then
warn "安装目录里存在真实文件 .env,已改名保留为 .env.bak.$(date +%s) 再建立软链"
mv "$link" "${link}.bak.$(date +%s)"
fi
ln -sfn "$CONFIG_FILE" "$link"
log "已建立 ${link} → ${CONFIG_FILE}"
}
# ---------------------------------------------------------------- systemd
install_systemd_unit() {
step "安装 systemd 服务单元"
local node_bin="${INSTALL_DIR}/${NODE_DIR_NAME}/bin/node"
local template="${INSTALL_DIR}/classhelper.service.template"
if [ -f "$template" ]; then
sed -e "s#__NODE__#${node_bin}#g" \
-e "s#__DIR__#${INSTALL_DIR}#g" \
-e "s#__USER__#${SERVICE_USER}#g" \
-e "s#__CONFIG__#${CONFIG_FILE}#g" \
-e "s#__CONFIGDIR__#${CONFIG_DIR}#g" \
"$template" >"/etc/systemd/system/${SERVICE_NAME}.service"
else
cat >"/etc/systemd/system/${SERVICE_NAME}.service" <<EOF
[Unit]
Description=ClassHelper Server (${PRODUCT}服务端)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=${SERVICE_USER}
Group=${SERVICE_GROUP}
WorkingDirectory=${INSTALL_DIR}
EnvironmentFile=${CONFIG_FILE}
ExecStart=${node_bin} server/dist/index.js
Restart=always
RestartSec=3
LimitNOFILE=65535
KillSignal=SIGTERM
TimeoutStopSec=20
StandardOutput=journal
StandardError=journal
SyslogIdentifier=${SERVICE_NAME}
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
NoNewPrivileges=true
ReadWritePaths=${INSTALL_DIR} ${CONFIG_DIR}
[Install]
WantedBy=multi-user.target
EOF
fi
chmod 644 "/etc/systemd/system/${SERVICE_NAME}.service"
systemctl daemon-reload
if [ "${AUTOSTART}" = "1" ]; then
systemctl enable "$SERVICE_NAME" >/dev/null
log "已安装 /etc/systemd/system/${SERVICE_NAME}.service 并设为开机自启"
else
log "已安装 /etc/systemd/system/${SERVICE_NAME}.service(**未**设为开机自启;需要时执行 systemctl enable ${SERVICE_NAME})"
fi
}
install_aux_files() {
step "安装运维命令与辅助文件"
ln -sfn "${INSTALL_DIR}/bin/classhelper" "${BIN_DIR}/classhelper"
if [ -d /usr/share/bash-completion/completions ]; then
"${INSTALL_DIR}/bin/classhelper" completion bash >/usr/share/bash-completion/completions/classhelper 2>/dev/null || true
chmod 644 /usr/share/bash-completion/completions/classhelper 2>/dev/null || true
fi
if [ -f "${INSTALL_DIR}/logrotate.d/classhelper" ]; then
install -m 644 "${INSTALL_DIR}/logrotate.d/classhelper" /etc/logrotate.d/classhelper
fi
if [ -f "${INSTALL_DIR}/profile.d/classhelper.sh" ]; then
sed -e "s#__DIR__#${INSTALL_DIR}#g" \
-e "s#__CONFIG__#${CONFIG_FILE}#g" \
-e "s#__LOGDIR__#${LOG_DIR}#g" \
-e "s#__BACKUPDIR__#${BACKUP_DIR}#g" \
"${INSTALL_DIR}/profile.d/classhelper.sh" >/etc/profile.d/classhelper.sh
chmod 644 /etc/profile.d/classhelper.sh
log "已写入 /etc/profile.d/classhelper.sh"
fi
log "已安装 ${BIN_DIR}/classhelper(软链 → ${INSTALL_DIR}/bin/classhelper)"
}
write_meta() { # $1=来源
cat >"${INSTALL_DIR}/.installed.json" <<EOF
{
"version": "${VERSION}",
"mode": "${MODE}",
"database": "${DB_KIND}",
"installDir": "${INSTALL_DIR}",
"configFile": "${CONFIG_FILE}",
"serviceUser": "${SERVICE_USER}",
"logDir": "${LOG_DIR}",
"backupDir": "${BACKUP_DIR}",
"port": "${PORT}",
"installedAt": "$(date -Iseconds)",
"sourceUrl": "$1",
"sha256": "${PACKAGE_SHA256}",
"nodeSource": "${NODE_SOURCE}"
}
EOF
chmod 600 "${INSTALL_DIR}/.installed.json"
}
# ---------------------------------------------------------------- Docker 形态
docker_available() { command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; }
install_docker_engine() {
if docker_available; then return 0; fi
if [ "$INSTALL_DOCKER" != "1" ] && [ "$ASSUME_YES" != "1" ]; then
if ! ask_yes_no "未检测到可用的 Docker,是否用官方脚本安装 docker-ce?" "n"; then
die "Docker 形态需要 docker;也可以改用 --mode native"
fi
fi
info "通过 get.docker.com 安装 docker(官方脚本)"
curl -fsSL https://get.docker.com -o /tmp/get-docker.sh || die "下载 Docker 安装脚本失败"
sh /tmp/get-docker.sh >>"$LOG_FILE" 2>&1 || die "Docker 安装失败,详见 ${LOG_FILE}"
systemctl enable --now docker >/dev/null 2>&1 || true
docker_available || die "Docker 装好了但连不上(docker info 失败)"
log "Docker 已就绪:$(docker --version)"
}
write_compose() {
local compose="${CONFIG_DIR}/compose.yml"
cat >"$compose" <<EOF
# ClassHelper · 由 install.sh 生成的 compose 文件(${MODE} 形态)
# 配置见 ${CONFIG_FILE}(改完执行 classhelper restart 重建容器)
# 数据在 Docker 卷 classhelper-data 里;备份用 classhelper backup
#
# 镜像用**稳定标签** classhelper-server:local:版本号写在这里的话,升级替换完程序文件后
# 这个标签就过期了,compose 会一直沿用旧镜像(表现为"升级成功但没有变化")。
# classhelper 自己按 VERSION 决定要不要重建镜像,再 --force-recreate。
services:
server:
image: classhelper-server:local
container_name: ${SERVICE_NAME}-server
restart: unless-stopped
env_file:
- ${CONFIG_FILE}
ports:
- "${PORT}:${PORT}"
volumes:
- classhelper-data:/app/data
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
classhelper-data:
name: classhelper-data
EOF
chmod 600 "$compose"
log "已生成 ${compose}"
}
docker_build_image() {
step "构建镜像 classhelper-server:local(版本 ${VERSION})"
[ -f "${INSTALL_DIR}/Dockerfile" ] || die "安装包缺少 Dockerfile(Docker 形态需要它)"
# 构建上下文就是安装目录(node_modules 已是 linux 的真实目录,构建时无需联网装依赖)
if ! docker build -t classhelper-server:local -t "classhelper-server:${VERSION}" \
-f "${INSTALL_DIR}/Dockerfile" "$INSTALL_DIR" >>"$LOG_FILE" 2>&1; then
tail -30 "$LOG_FILE" >&2 || true
die "镜像构建失败,详见 ${LOG_FILE}"
fi
# 记下这个镜像对应的版本,classhelper 靠它判断要不要重建(避免每次 restart 都重新构建)
printf '%s' "$VERSION" >"${INSTALL_DIR}/.docker-image-version"
log "镜像已构建:classhelper-server:local(另打 tag classhelper-server:${VERSION})"
}
# ---------------------------------------------------------------- 附加功能
setup_nginx() {
step "准备 Nginx 反向代理配置"
local target="${CONFIG_DIR}/nginx.conf"
if [ -f "${INSTALL_DIR}/nginx.conf" ]; then
install -m 600 "${INSTALL_DIR}/nginx.conf" "$target"
else
cat >"$target" <<EOF
# ClassHelper · Nginx 反向代理(替换 server_name 与证书路径后启用)
server {
listen 80;
server_name class.example.com;
client_max_body_size 12m; # 表格导入最大 8MB,base64 后约 11MB
location / {
proxy_pass http://127.0.0.1:${PORT};
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
# Socket.IO 长连接
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
}
EOF
fi
log "反向代理配置:${target}(改好后复制到 /etc/nginx/conf.d/ 并 nginx -s reload)"
}
setup_firewall() {
step "配置防火墙"
if command -v firewall-cmd >/dev/null 2>&1 && systemctl is-active --quiet firewalld 2>/dev/null; then
firewall-cmd --permanent --add-port="${PORT}/tcp" >/dev/null && firewall-cmd --reload >/dev/null
log "firewalld 已放行 ${PORT}/tcp"
elif command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then
ufw allow "${PORT}/tcp" >/dev/null
log "ufw 已放行 ${PORT}/tcp"
else
warn "未检测到启用中的 firewalld / ufw,跳过(云服务器还要在安全组放行 ${PORT})"
fi
}
# ---------------------------------------------------------------- 体检
check_only() {
detect_os
printf '%s体检报告%s(不改动任何东西)\n\n' "$C_BOLD" "$C_RESET"
local tools=""
for tool in curl tar gzip sha256sum; do
if command -v "$tool" >/dev/null 2>&1; then tools="${tools}${tool} "; else tools="${tools}${tool}(缺) "; fi
done
local sys_node="-"; local node_bin; node_bin="$(command -v node 2>/dev/null || true)"
if [ -n "$node_bin" ]; then sys_node="$("$node_bin" -v)"; fi
printf ' %-24s %s\n' "系统" "${OS_NAME:-未知}"
printf ' %-24s %s\n' "包管理器" "${PKG:-未识别(apt/yum/dnf 都没有?)}"
printf ' %-24s %s\n' "架构" "$(uname -m) → $(asset_arch)"
printf ' %-24s %s\n' "glibc" "$(glibc_version)(<2.28 时自动改用 glibc-217 版 Node)"
printf ' %-24s %s\n' "systemd" "$(has_systemd && echo 可用 || echo 不可用)"
printf ' %-24s %s\n' "docker" "$(docker_available && docker --version || echo 不可用)"
printf ' %-24s %s\n' "基础工具" "${tools}"
printf ' %-24s %s\n' "系统 Node" "${sys_node}(安装器默认用内置运行时,不依赖它)"
printf ' %-24s %s\n' "内存" "$(free -h 2>/dev/null | awk 'NR==2 {print $2}' || echo 未知)"
printf ' %-24s %s\n' "磁盘 $(dirname "$INSTALL_DIR")" "$(df -h "$(dirname "$INSTALL_DIR")" 2>/dev/null | awk 'NR==2 {print $4" 可用"}' || echo 未知)"
printf ' %-24s %s\n' "端口 ${PORT}" "$(port_in_use "$PORT" && echo '已被占用 ⚠' || echo 空闲)"
printf ' %-24s %s\n' "安装目录 ${INSTALL_DIR}" "$(is_installed && echo "已安装 v$(existing_version)(重跑=覆盖安装)" || echo 尚未安装)"
printf ' %-24s %s\n' "配置 ${CONFIG_FILE}" "$([ -f "$CONFIG_FILE" ] && echo 已存在(会被保留) || echo 尚未创建)"
printf '\n'
local problems=0
command -v curl >/dev/null 2>&1 || { warn "缺少 curl"; problems=1; }
has_systemd || { warn "没有 systemd:直接安装形态不可用(可用 --mode docker)"; problems=1; }
[ "$PKG" != "" ] || { warn "未识别包管理器:自动装依赖会失败"; problems=1; }
if [ "$problems" = "1" ]; then return 1; fi
log "体检通过:可以执行 sudo bash install.sh"
}
# ---------------------------------------------------------------- 卸载
do_uninstall() {
if [ -x "${INSTALL_DIR}/bin/classhelper" ]; then
exec "${INSTALL_DIR}/bin/classhelper" uninstall "$@"
fi
step "卸载(找不到 classhelper,执行内置清理)"
if [ "$MODE" = "docker" ]; then
if [ -f "${CONFIG_DIR}/compose.yml" ]; then
docker compose -f "${CONFIG_DIR}/compose.yml" down --remove-orphans 2>/dev/null || true
fi
else
systemctl disable --now "$SERVICE_NAME" >/dev/null 2>&1 || true
rm -f "/etc/systemd/system/${SERVICE_NAME}.service" /etc/systemd/system/classhelper-backup.service /etc/systemd/system/classhelper-backup.timer
fi
rm -f /etc/profile.d/classhelper.sh "${BIN_DIR}/classhelper" /etc/logrotate.d/classhelper
systemctl daemon-reload 2>/dev/null || true
log "服务已移除;程序与数据仍在 ${INSTALL_DIR}(确认不需要后手工删除)"
}
# ---------------------------------------------------------------- 结果输出
print_result() {
local ip; ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
ip="${ip:-127.0.0.1}"
printf '\n%s────────────────────────────────────────────────────────────%s\n' "$C_GREEN" "$C_RESET"
printf '%s安装完成%s %s服务端 v%s(%s / %s)\n\n' "$C_BOLD" "$C_RESET" "$PRODUCT" "$VERSION" "$MODE" "$DB_KIND"
printf ' 管理端地址 http://%s:%s/\n' "$ip" "$PORT"
printf ' 健康探针 http://%s:%s/healthz\n' "$ip" "$PORT"
printf ' 配置文件 %s(权限 600)\n' "$CONFIG_FILE"
printf ' 数据目录 %s/data\n' "$INSTALL_DIR"
printf ' 安装日志 %s\n\n' "$LOG_FILE"
if [ -n "$ADMIN_PASSWORD" ]; then
printf ' 管理员账号 admin(密码为你刚才设置的那个)\n'
else
printf ' 管理员账号 admin / admin123 %s← 默认密码,公网部署请立刻修改%s\n' "$C_YELLOW" "$C_RESET"
fi
printf '\n%s常用命令%s\n' "$C_BOLD" "$C_RESET"
printf ' classhelper status 查看状态 / 版本 / 端口\n'
printf ' classhelper doctor 完整体检\n'
printf ' classhelper password admin 改管理员密码(交互输入,不回显)\n'
printf ' classhelper backup 备份配置 + 数据库\n'
printf ' classhelper backup schedule daily 开启每日自动备份\n'
printf ' classhelper upgrade --check 检查新版本\n'
printf ' classhelper logs -f 实时日志\n'
printf ' classhelper help 全部命令\n'
printf '\n%s接下来建议%s\n' "$C_BOLD" "$C_RESET"
printf ' 1. 改掉默认密码:%sclasshelper password admin%s\n' "$C_CYAN" "$C_RESET"
printf ' 2. 公网访问请上 HTTPS:改好 %s/nginx.conf 的域名与证书,并把 CORS_ORIGIN\n' "$CONFIG_DIR"
printf ' 收敛到你的域名(classhelper config set CORS_ORIGIN https://your.domain)\n'
printf ' 3. ClassHelper 班级端「服务器地址」填 http://%s:%s 或你的域名\n' "$ip" "$PORT"
printf '%s────────────────────────────────────────────────────────────%s\n' "$C_GREEN" "$C_RESET"
}
# ---------------------------------------------------------------- 主流程
main() {
parse_args "$@"
# `--check` 与菜单里的"4) 只体检"是同一条路:都不需要 root、都不改动系统。
# 菜单必须出现在 root 检查**之前** —— 否则非 root 用户连菜单都看不到,只能靠 --check。
if [ "$CHECK_ONLY" = "1" ]; then
banner
check_only
exit $?
fi
banner
detect_os
info "系统:${OS_NAME:-未知}|包管理器:${PKG:-未知}|架构:$(uname -m)"
choose_mode_interactive
# 菜单里选了 4:到这里才分流。
# (原来这段判断写在菜单**之前**,从菜单选 4 时 CHECK_ONLY 设了也没人看 —— 实测踩到:
# 用户选"只体检",脚本却直接开始安装。)
if [ "$CHECK_ONLY" = "1" ]; then
check_only
exit $?
fi
if [ "$(id -u)" != "0" ]; then
if [ -n "$SCRIPT_PATH" ]; then
die "请用 root 执行:sudo bash ${SCRIPT_PATH}(只体检不需要 root:加 --check)"
fi
die "请用 root 执行:在刚才那条命令前面加 sudo(只体检不需要 root:末尾加 --check)"
fi
mkdir -p "$LOG_DIR" && chmod 700 "$LOG_DIR"
{
printf '\n===== %s 安装开始 =====\n' "$(date '+%F %T')"
printf '参数:%s\n' "$*"
} >>"$LOG_FILE"
# 后续输出同时进日志(tee 不占 stdin,交互问答照常)
exec > >(tee -a "$LOG_FILE") 2>&1
collect_answers
# 把最终生效的选择打出来:非交互(--yes)或问答被跳过时,这一行是唯一能看出"到底按什么装的"地方
info "本次安装:形态 ${MODE}|目录 ${INSTALL_DIR}|端口 ${PORT}|数据库 ${DB_KIND}|Node ${NODE_SOURCE}"
if [ "$UNINSTALL" = "1" ]; then
do_uninstall --yes
exit 0
fi
if [ "$ADMIN_PASSWORD_FROM_STDIN" = "1" ]; then read_stdin_line ADMIN_PASSWORD; fi
if [ "$MODE" = "native" ] && ! has_systemd && [ "$FORCE" != "1" ]; then
die "本机没有 systemd:改用 --mode docker,或加 --force 只铺文件(不装服务)"
fi
# 覆盖安装时沿用已有端口与数据库形态,避免"重跑一次配置被改回默认"
if is_installed && [ -f "$CONFIG_FILE" ] && [ "$FORCE" != "1" ]; then
local old_port old_db
old_port="$(config_value PORT)"
old_db="$(config_value DATABASE_PROVIDER)"
[ -n "$old_port" ] && PORT="$old_port"
[ -n "$old_db" ] && DB_KIND="$old_db"
info "检测到已安装(v$(existing_version)):按覆盖安装处理,配置与数据保留(端口 ${PORT} / ${DB_KIND})"
fi
if port_in_use "$PORT"; then
if [ "$MODE" = "native" ] && is_installed; then
info "端口 ${PORT} 已被占用(可能是本服务在跑,稍后会被重启接管)"
else
warn "端口 ${PORT} 已被占用,安装后可能起不来"
ask "换一个端口" "$PORT" PORT
fi
fi
ensure_dependencies
if [ "$MODE" = "docker" ]; then install_docker_engine; fi
ensure_user
ensure_dirs
fetch_package
place_program_files
# 临时解压目录(含安装包副本,几百 MB)用完即删,避免把小盘服务器塞满
rm -rf "$(dirname "$STAGE_DIR")"
if [ "$MODE" = "native" ]; then setup_node; fi
write_config
link_env_file
write_meta "${PACKAGE_URL:-github-release}"
if [ "$MODE" = "docker" ]; then
write_compose
docker_build_image
step "启动容器"
if ! docker compose -f "${CONFIG_DIR}/compose.yml" --env-file "$CONFIG_FILE" up -d >>"$LOG_FILE" 2>&1; then
tail -30 "$LOG_FILE" >&2 || true
die "容器启动失败,详见 ${LOG_FILE}"
fi
elif [ "$FORCE" = "1" ] && ! has_systemd; then
warn "没有 systemd(--force):请手工启动"
printf ' sudo -u %s %s/%s/bin/node %s/server/dist/index.js\n' "$SERVICE_USER" "$INSTALL_DIR" "$NODE_DIR_NAME" "$INSTALL_DIR"
else
install_systemd_unit
step "启动服务"
systemctl restart "$SERVICE_NAME"
fi
install_aux_files
if [ "$WITH_NGINX" = "1" ]; then setup_nginx; fi
if [ "$OPEN_FIREWALL" = "1" ]; then setup_firewall; fi
step "等待服务就绪"
local ready=0 i=0
while [ "$i" -lt 90 ]; do
if curl -fsS --noproxy '*' --max-time 3 -o /dev/null "http://127.0.0.1:${PORT}/healthz" 2>/dev/null; then
ready=1
break
fi
i=$((i + 1))
sleep 1
done
if [ "$ready" != "1" ]; then
warn "90 秒内没通过 /healthz,最近日志:"
if [ "$MODE" = "docker" ]; then
docker compose -f "${CONFIG_DIR}/compose.yml" logs --tail 50 2>&1 || true
else
journalctl -u "$SERVICE_NAME" -n 50 --no-pager 2>&1 || true
fi
die "安装未完成:服务没起来。常见原因:端口被占用、MySQL 连接串错误、数据目录权限。日志:${LOG_FILE}"
fi
log "服务已就绪 ✅"
print_result
printf '\n%s提示:新开一个终端(或 source /etc/profile)后,classhelper 的环境变量也会生效。%s\n' "$C_DIM" "$C_RESET"
sleep 0.3 # 给 tee 一点时间把最后几行刷进日志
}
main "$@"