文档站(`docs/` 就是 docfx 项目,发布在 https://lovechengke.github.io/ClassHelper/): - 四章 + 常见问题 + 完整参考,共 44 页:快速上手(8 页)/ 应用帮助(10 页)/ 管理运维(6 页)/ 开发文档(11 页)。信息架构照 ClassIsland 文档站来: 顶栏分章、左侧章节导航、右侧本页目录、上一页 / 下一页、站内搜索、深色主题跟随系统; - 自定义模板覆盖(`templates/classhelper/`):把 docfx modern 模板那套英文 UI 文案 (In this article / Improve this doc / Next…)换成中文,补 GitHub 入口与版本徽标, 并加一层只调排版与配色的 `public/main.css`(不动 DOM 结构); - 站点顶栏的版本徽标取自根 `package.json`(`docs/build.mjs` 用 `--metadata` 注入), 不在文档里写第二份 —— 否则一定漂; - 404 页单独生成:GitHub Pages 的项目页挂在 `/<仓库名>/` 子路径下,而浏览器是按**被请求的路径** 解析页面里的相对链接的,所以 `<base>` 必须钉到站点根,由 `build.mjs`(本地 `./`)与 workflow(CI 传 `/<仓库名>/`)分别注入; - 构建前清空 `_site`:docfx 只覆盖不清理,删掉或改名的页面会以旧文件留在目录里, 而 Pages 上传的是整个目录。 版本迭代(**每一次 releases,不是每一次提交**): - 新增 `scripts/version.mjs`。这个仓库里同一个版本号散在 **10 个文件**里(5 份 `package.json`、 `deploy/package.runtime.json`、两个 compose 的镜像 tag、插件 `manifest.yml`、以及插件 C# 里的 `PluginVersion` 常量),手工改必然漏一处,而漏掉的那处**不会报错**,只在某个不常走的分支上 表现为"版本号对不上"。现在收口成两条命令:`pnpm version:check`(逐处比对,不一致 exit 1) 与 `pnpm version:bump patch|minor|major`(一次改写全部 10 处,任何一处匹配不到或匹配到多处 就直接失败退出,不做尽力而为的部分改写); - `AGENTS.md` 新增 §4.4「版本迭代(每一次 releases,不是每一次提交)」:写清版本号落在哪 10 处、 完整发布命令序列(体检 → 验收 → `dist:all` → 哈希清单 → 推 master → `gh release create` → Linux 包由 CI 合并哈希行),以及"已发布的版本号不要复用"的理由。 其它: - `.github/workflows/docs.yml`:推 master(`docs/**` / `package.json` / workflow 自身有变)即构建并 部署到 Pages;构建带 `--check`,把 docfx 的警告当错误 —— 最常见的警告是"某个 `.md` 没被任何 `toc.yml` 引用",那种页面有内容但没有侧边栏,属于必须修掉的疏漏; - docs 目录重组后的引用同步:`production` / `mysql` / `linux-deploy` / `winisland-design-tokens` 移到新位置后,AGENTS.md、`deploy/`、`scripts/`、客户端源码注释里的路径引用一并更新; - 文档站只发布 `docs/` 这一棵树,页面里指向树外的相对链接(`../AGENTS.md` 等)改成 GitHub 绝对 URL, 否则上线全是 404; - 图标派生清单补上文档站模板里的 `logo.png` 与 `favicon.png`(`pnpm icons` 一并生成, 仍然只有一个图形源 `build/classhelper.{png,ico}`); - `docs/_site` 进 `.gitignore` / `.prettierignore` 与 ESLint ignores; - 根脚本补 `version:check` / `version:bump` / `docs:build` / `docs:serve` / `docs:check`。
1294 lines
55 KiB
Bash
1294 lines
55 KiB
Bash
#!/usr/bin/env bash
|
||
# ---------------------------------------------------------------------------
|
||
# 班级小助手 · 服务端 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 按检测到的防火墙放行端口
|
||
# --yes 全部用默认值,不再交互(CI / 验收用)
|
||
#
|
||
# 幂等:重复执行 = 覆盖安装。已有 config.env(含 JWT_SECRET)与 data/ 一律保留,不重置密钥。
|
||
# 日志:/var/log/classhelper/install.log
|
||
# ---------------------------------------------------------------------------
|
||
set -euo pipefail
|
||
|
||
# ---------------------------------------------------------------- 集中变量
|
||
|
||
PRODUCT="班级小助手"
|
||
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
|
||
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'
|
||
班级小助手 · 服务端 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 ;;
|
||
--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
|
||
}
|
||
|
||
# ---------------------------------------------------------------- 用户与目录
|
||
|
||
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
|
||
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
|
||
# ---------------------------------------------------------------------------
|
||
# 班级小助手服务端配置(由 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}
|
||
|
||
# 新建班级 / 重置教师账号时的默认密码(学生端用「班级码 + 班级密码」登录)
|
||
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
|
||
systemctl enable "$SERVICE_NAME" >/dev/null
|
||
log "已安装 /etc/systemd/system/${SERVICE_NAME}.service 并设为开机自启"
|
||
}
|
||
|
||
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
|
||
# 班级小助手 · 由 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
|
||
# 班级小助手 · 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. 学生端「服务器地址」填 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 "$@"
|