#!/bin/bash # ============================================================ # OpenDance Studio Web 服务端 —— Linux 原生站点部署脚本 # # 部署形态(不使用 Docker): # nginx ── 静态托管管理端 + 反向代理 API # └──▶ systemd 托管的 Node 进程(NestJS,仅监听 127.0.0.1) # # 数据库仍是部署方提供的**现有 MySQL / MariaDB 实例**,本脚本不安装、不管理数据库。 # # 用法(在解压后的发布目录内,需要 root): # cp deploy/.env.example deploy/.env && vi deploy/.env # sudo bash deploy/install.sh # # 可用环境变量覆盖: # APP_DIR 安装目录(默认 /opt/opendance) # SERVICE_USER 运行服务的系统用户(默认 opendance) # STORAGE_DIR 上传文件目录(默认 /var/lib/opendance/storage) # SERVER_NAME nginx server_name(默认 _) # SKIP_NGINX=1 只装 API 服务,不配置 nginx # ============================================================ set -euo pipefail # 失败时打印出错行号,避免只看到半截输出 trap 'printf "\033[1;31m[install]\033[0m 第 %d 行执行失败(退出码 %d)\n" "$LINENO" "$?" >&2' ERR SRC_DIR="$(cd "$(dirname "$0")/.." && pwd)" cd "$SRC_DIR" APP_DIR="${APP_DIR:-/opt/opendance}" SERVICE_USER="${SERVICE_USER:-opendance}" STORAGE_DIR="${STORAGE_DIR:-/var/lib/opendance/storage}" SERVER_NAME="${SERVER_NAME:-_}" API_HOST="127.0.0.1" API_PORT="${PORT:-3000}" log() { printf '\033[1;34m[install]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*"; } die() { printf '\033[1;31m[error]\033[0m %s\n' "$*" >&2; exit 1; } # 以服务用户身份执行命令。 # 本脚本已要求 root,因此**不使用 sudo**(部分服务器根本没装 sudo, # 且 root 降权本来也不需要它)。优先 runuser,其次 setpriv,最后 su。 as_service_user() { if command -v runuser >/dev/null 2>&1; then runuser -u "$SERVICE_USER" -- "$@" elif command -v setpriv >/dev/null 2>&1; then setpriv --reuid="$SERVICE_USER" --regid="$SERVICE_USER" --init-groups -- "$@" elif command -v su >/dev/null 2>&1; then su -s /bin/bash "$SERVICE_USER" -c "$(printf '%q ' "$@")" else die "无法以 $SERVICE_USER 身份执行命令:runuser / setpriv / su 均不可用" fi } # 安装**含 devDependencies** 的依赖。 # 必须显式处理:脚本开头用 `set -a; . deploy/.env` 载入了 .env,其中的 # NODE_ENV=production 会被导出到本脚本环境;npm 见到它会自动跳过 # devDependencies,于是 tsc / vite / nest 等构建工具全部缺失 # (用户实测报错:`sh: 1: tsc: not found`,且只装了 23 个包)。 npm_install_with_dev() { if [ -f package-lock.json ]; then as_service_user env -u NODE_ENV npm ci --include=dev --no-audit --no-fund --loglevel=error else as_service_user env -u NODE_ENV npm install --include=dev --no-audit --no-fund --loglevel=error fi } # HTTP GET,输出响应体。curl → wget → node(node 必有,且 22+ 自带 fetch) http_get() { if command -v curl >/dev/null 2>&1; then curl -fsS -m 5 "$1" elif command -v wget >/dev/null 2>&1; then wget -q -O - -T 5 "$1" else "$NODE_BIN" -e 'fetch(process.argv[1]).then(r=>r.text()).then(t=>process.stdout.write(t)).catch(()=>process.exit(1))' "$1" fi } # 健康检查(只看成功与否) http_ok() { if command -v curl >/dev/null 2>&1; then curl -fsS -m 3 "$1" >/dev/null 2>&1 elif command -v wget >/dev/null 2>&1; then wget -q -O /dev/null -T 3 "$1" else "$NODE_BIN" -e 'fetch(process.argv[1]).then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))' "$1" fi } # ---------- 0. 前置检查 ---------- [ "$(id -u)" -eq 0 ] || die "需要 root 权限(安装 systemd 服务与 nginx 站点)。 请以 root 身份执行,例如: su -c 'bash deploy/install.sh' 或 sudo bash deploy/install.sh" # 缺少 deploy/.env 时,若安装目录里已有上次安装留下的配置,直接复用。 # 否则用户每次重新解压发布包都得重填一遍数据库口令(本次就是这么被绊住的)。 if [ ! -f deploy/.env ]; then if [ -f "$APP_DIR/deploy/.env" ]; then log "未找到 deploy/.env,复用上次安装的配置:$APP_DIR/deploy/.env" cp "$APP_DIR/deploy/.env" deploy/.env else die "缺少 deploy/.env,请先: cp deploy/.env.example deploy/.env && vi deploy/.env 需要填写的内容(数据库连接信息由部署方提供): DB_HOST 现有 MySQL / MariaDB 地址 DB_PORT 端口,默认 3306 DB_USER 数据库账号 DB_PASS 数据库口令(注意不要加引号) DB_NAME 数据库名" fi fi set -a # shellcheck disable=SC1091 . ./deploy/.env set +a API_PORT="${PORT:-3000}" [ -n "${DB_HOST:-}" ] || die "deploy/.env 中 DB_HOST 未设置" [ -n "${DB_USER:-}" ] || die "deploy/.env 中 DB_USER 未设置" [ -n "${DB_PASS:-}" ] || die "deploy/.env 中 DB_PASS 未设置" [ -n "${DB_NAME:-}" ] || die "deploy/.env 中 DB_NAME 未设置" command -v node >/dev/null 2>&1 || die "未找到 node。请安装 Node.js 22 或更高版本。" NODE_BIN="$(command -v node)" NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')" [ "$NODE_MAJOR" -ge 22 ] 2>/dev/null || die "Node.js 版本过低(当前 $(node -v)),需要 22+" command -v npm >/dev/null 2>&1 || die "未找到 npm" log "Node.js:$("$NODE_BIN" -v)($NODE_BIN)" log "安装目录:$APP_DIR" log "运行用户:$SERVICE_USER" log "上传目录:$STORAGE_DIR" log "目标数据库:${DB_HOST}:${DB_PORT:-3306}/${DB_NAME}(现有实例,本脚本不会创建或管理数据库)" # ---------- 1. 系统用户与目录 ---------- if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then log "创建系统用户 $SERVICE_USER" useradd --system --shell /usr/sbin/nologin --home-dir "$APP_DIR" --create-home "$SERVICE_USER" 2>/dev/null \ || useradd -r -s /sbin/nologin -d "$APP_DIR" -m "$SERVICE_USER" fi # ---- 目录安全检查 ---- # 若安装目录与源目录相同或互相包含,后面的 rm -rf / cp 会删掉源文件或无限递归。 SRC_REAL="$(readlink -f "$SRC_DIR")" mkdir -p "$APP_DIR" APP_REAL="$(readlink -f "$APP_DIR")" if [ "$SRC_REAL" = "$APP_REAL" ]; then die "安装目录与源目录相同($APP_REAL)。 本脚本会先清空安装目录下的 web-server/ admin/ 再复制,直接在这里执行会删掉源文件。 请把发布包解压到别处,或用 APP_DIR=... 指定另一个安装目录: APP_DIR=/opt/opendance bash $0 (root 身份下执行)" fi case "$APP_REAL/" in "$SRC_REAL"/*) die "安装目录位于源目录内部($APP_REAL ⊂ $SRC_REAL),复制会无限递归。 请改用源目录之外的 APP_DIR。" ;; esac case "$SRC_REAL/" in "$APP_REAL"/*) die "源目录位于安装目录内部($SRC_REAL ⊂ $APP_REAL)。 安装时会先清空该子目录,导致源文件被删除。请把发布包解压到 $APP_REAL 之外。" ;; esac # ---- 复制(排除 node_modules / .git / storage 等大目录)---- # 说明:在 git 检出目录直接执行时,web-server/ 里可能已有 200 MB+ 的 # node_modules(数万个小文件)。原实现用 cp -r 全量复制,会长时间无输出, # 看起来像卡死。这里统一排除,运行期依赖稍后由 npm ci 重新安装。 # 复制排除规则 —— 必须区分「仅顶层」与「任意层级」: # # 仅顶层 (TOP_*):运行时产物与本地配置,只会出现在 web-server 根下。 # 必须锚定!未锚定的 'storage' 会匹配任意层级,把源码目录 # src/storage/ 与编译产物 dist/storage/ 一起排除掉,导致安装后 # 启动即 MODULE_NOT_FOUND。本项目已在 .gitignore 与 package-release.sh # 上各踩过一次,这里是第三处。 # 任意层级 (ANY_*):依赖与缓存目录,任何位置都不应该被复制。 # TOP_DIRS="storage data tmp dist-release" ANY_DIRS="node_modules .git coverage .gradle" TOP_FILES=".env .env.local" copy_tree() { local src="$1" dst="$2" mkdir -p "$dst" if command -v rsync >/dev/null 2>&1; then # rsync 模式前导 '/' 表示锚定到传输根目录 local args=(-a --delete) local d for d in $TOP_DIRS; do args+=(--exclude="/$d/"); done for d in $ANY_DIRS; do args+=(--exclude="$d/"); done local f for f in $TOP_FILES; do args+=(--exclude="/$f"); done rsync "${args[@]}" "$src/" "$dst/" else # tar 的 --exclude 按完整成员名匹配: # './storage' 只匹配顶层 # './x' + '*/x' 才覆盖任意层级(如 ./admin/node_modules) # 因此 ANY_DIRS 给两个模式,TOP_* 只给 './' 一个模式——绝不能给 '*/storage'。 local tar_excludes=() local d f for d in $TOP_DIRS; do tar_excludes+=(--exclude="./$d"); done for d in $ANY_DIRS; do tar_excludes+=(--exclude="./$d" --exclude="*/$d"); done for f in $TOP_FILES; do tar_excludes+=(--exclude="./$f"); done ( cd "$src" && tar "${tar_excludes[@]}" -cf - . ) | ( cd "$dst" && tar -xf - ) fi } log "同步程序文件到 $APP_DIR" for item in web-server contracts; do [ -e "$SRC_DIR/$item" ] || die "缺少 $item/ 目录(既不在发布包里,也不在源码根目录)" printf ' 复制 %-12s …' "$item" rm -rf "${APP_DIR:?}/$item" copy_tree "$SRC_DIR/$item" "$APP_DIR/$item" printf ' %s 个文件\n' "$(find "$APP_DIR/$item" -type f | wc -l)" done # ---- 管理端产物:两种目录布局都要支持 ---- # 发布包: /admin/ (已构建,直接用) # 源码检出:/web-server/admin/dist/ (已构建,直接用) # 否则在 $APP_DIR/web-server/admin 现场构建 # (在安装目录内构建,不污染源码树,也不要求源码目录可写) ADMIN_SRC="" if [ -d "$SRC_DIR/admin" ]; then ADMIN_SRC="$SRC_DIR/admin" elif [ -d "$SRC_DIR/web-server/admin/dist" ]; then ADMIN_SRC="$SRC_DIR/web-server/admin/dist" elif [ -d "$APP_DIR/web-server/admin" ]; then warn "未找到管理端构建产物,正在 ${APP_DIR}/web-server/admin 构建(需要 devDependencies)" if ( cd "$APP_DIR/web-server/admin" && npm_install_with_dev \ && as_service_user env -u NODE_ENV npm run build ); then ADMIN_SRC="$APP_DIR/web-server/admin/dist" # 构建完即可丢弃管理端的 node_modules,避免长期占用磁盘 rm -rf "$APP_DIR/web-server/admin/node_modules" else die "管理端构建失败。可改用发布包(内含已构建的 admin/): ./scripts/package-release.sh 然后在目标机解压部署" fi fi [ -n "$ADMIN_SRC" ] || die "找不到管理端:既没有 admin/ 目录,也没有 web-server/admin/" printf ' 复制 %-12s …' "admin" rm -rf "${APP_DIR:?}/admin" copy_tree "$ADMIN_SRC" "$APP_DIR/admin" printf ' %s 个文件(来源:%s)\n' "$(find "$APP_DIR/admin" -type f | wc -l)" "$ADMIN_SRC" mkdir -p "$APP_DIR/deploy" cp -r "$SRC_DIR/deploy/." "$APP_DIR/deploy/" if [ -f "$SRC_DIR/THIRD_PARTY.md" ]; then cp "$SRC_DIR/THIRD_PARTY.md" "$APP_DIR/"; fi if [ -f "$SRC_DIR/README.txt" ]; then cp "$SRC_DIR/README.txt" "$APP_DIR/"; fi # ---- 复制结果硬校验 ---- # 排除规则一旦写错,会在启动时才以 MODULE_NOT_FOUND 的形式暴露, # 极难定位。这里就地检查关键的编译产物,把问题拦在部署阶段。 for required in \ "$APP_DIR/web-server/dist/main.js" \ "$APP_DIR/web-server/dist/storage/storage.module.js" \ "$APP_DIR/web-server/dist/app.module.js" \ "$APP_DIR/admin/index.html" ; do [ -f "$required" ] || die "复制结果缺少关键文件:$required 这通常意味着复制时的排除规则误伤了源码目录,请检查 deploy/install.sh 中的 TOP_DIRS / ANY_DIRS 设置(注意 'storage' 必须锚定到顶层)。" done log "复制结果校验通过" mkdir -p "$STORAGE_DIR" log "设置目录属主($SERVICE_USER)" chown -R "$SERVICE_USER:$SERVICE_USER" "$APP_DIR" "$STORAGE_DIR" chmod 750 "$STORAGE_DIR" # ---------- 2. 安装运行期依赖(必要时构建) ---------- cd "$APP_DIR/web-server" log "安装运行期依赖(仅生产依赖,可能需要 1-3 分钟…)" if [ -f package-lock.json ]; then as_service_user npm ci --omit=dev --no-audit --no-fund --loglevel=error else as_service_user npm install --omit=dev --no-audit --no-fund --loglevel=error fi if [ ! -f dist/main.js ]; then warn "未找到构建产物 dist/main.js,改为在服务器上构建(需要 devDependencies)" npm_install_with_dev as_service_user env -u NODE_ENV npm run build fi [ -f dist/main.js ] || die "构建产物缺失:$APP_DIR/web-server/dist/main.js" [ -f "$APP_DIR/admin/index.html" ] || die "管理端构建产物缺失:$APP_DIR/admin/index.html" # ---------- 3. 数据库迁移 ---------- log "执行 TypeORM 迁移(不使用 DB_SYNCHRONIZE)" set -a; . "$APP_DIR/deploy/.env"; set +a cd "$APP_DIR/web-server" as_service_user env \ "PATH=$(dirname "$NODE_BIN"):/usr/local/bin:/usr/bin:/bin" \ "DB_HOST=${DB_HOST}" "DB_PORT=${DB_PORT:-3306}" "DB_USER=${DB_USER}" \ "DB_PASS=${DB_PASS}" "DB_NAME=${DB_NAME}" "DB_LOGGING=${DB_LOGGING:-false}" \ "$NODE_BIN" scripts/migrate.mjs run # ---------- 4. 安装为系统服务(按可用的初始化系统降级) ---------- # systemd → OpenRC → SysVinit → 纯后台进程(nohup + PID 文件) # “运行在 Linux 上”不应等于“必须有 systemd”:容器 / LXC / 精简系统都很常见。 INIT_SYSTEM="" if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then INIT_SYSTEM=systemd elif command -v rc-service >/dev/null 2>&1 && command -v rc-update >/dev/null 2>&1; then INIT_SYSTEM=openrc elif [ -d /etc/init.d ] && { command -v update-rc.d >/dev/null 2>&1 || command -v chkconfig >/dev/null 2>&1; }; then INIT_SYSTEM=sysvinit else INIT_SYSTEM=none fi log "初始化系统:$INIT_SYSTEM" case "$INIT_SYSTEM" in systemd) log "安装 systemd 服务" SERVICE_FILE=/etc/systemd/system/opendance-api.service sed -e "s|__SERVICE_USER__|$SERVICE_USER|g" \ -e "s|__APP_DIR__|$APP_DIR|g" \ -e "s|__NODE_BIN__|$NODE_BIN|g" \ -e "s|__STORAGE_PARENT__|$(dirname "$STORAGE_DIR")|g" \ "$APP_DIR/deploy/systemd/opendance-api.service" > "$SERVICE_FILE" chmod 644 "$SERVICE_FILE" systemctl daemon-reload systemctl enable opendance-api >/dev/null systemctl restart opendance-api ;; openrc) log "安装 OpenRC 服务" sed -e "s|__SERVICE_USER__|$SERVICE_USER|g" \ -e "s|__APP_DIR__|$APP_DIR|g" \ -e "s|__NODE_BIN__|$NODE_BIN|g" \ "$APP_DIR/deploy/openrc/opendance-api" > /etc/init.d/opendance-api chmod 755 /etc/init.d/opendance-api rc-update add opendance-api default >/dev/null 2>&1 || warn "rc-update add 失败,请手动加入默认运行级" rc-service opendance-api restart 2>/dev/null || rc-service opendance-api start ;; sysvinit) log "安装 SysVinit 服务" sed -e "s|__SERVICE_USER__|$SERVICE_USER|g" \ -e "s|__APP_DIR__|$APP_DIR|g" \ "$APP_DIR/deploy/initd/opendance-api" > /etc/init.d/opendance-api chmod 755 /etc/init.d/opendance-api if command -v update-rc.d >/dev/null 2>&1; then update-rc.d opendance-api defaults >/dev/null 2>&1 || warn "update-rc.d 失败,请手动建立启动链接" elif command -v chkconfig >/dev/null 2>&1; then chkconfig --add opendance-api >/dev/null 2>&1 || true fi /etc/init.d/opendance-api restart ;; none) warn "未检测到 systemd / OpenRC / SysVinit,改用后台进程方式运行(nohup + PID 文件)" chmod +x "$APP_DIR/deploy/ctl.sh" APP_DIR="$APP_DIR" SERVICE_USER="$SERVICE_USER" bash "$APP_DIR/deploy/ctl.sh" restart # 尝试用 cron 的 @reboot 实现开机自启(没有 cron 就只提示) if command -v crontab >/dev/null 2>&1; then CRON_LINE="@reboot /bin/bash $APP_DIR/deploy/ctl.sh start >/dev/null 2>&1" if crontab -l 2>/dev/null | grep -qF "$APP_DIR/deploy/ctl.sh start"; then log "开机自启已存在(crontab @reboot)" else ( crontab -l 2>/dev/null; echo "$CRON_LINE" ) | crontab - log "已写入开机自启:crontab @reboot" fi else warn "未找到 crontab,无法配置开机自启。重启后请手动执行:" warn " bash $APP_DIR/deploy/ctl.sh start" fi ;; esac log "等待 API 就绪…" READY=0 for _ in $(seq 1 30); do if http_ok "http://${API_HOST}:${API_PORT}/health"; then READY=1; break; fi sleep 1 done if [ "$READY" -ne 1 ]; then warn "API 未在 30 秒内就绪,最近日志:" journalctl -u opendance-api -n 40 --no-pager || true die "API 启动失败" fi log "API 已就绪:$(http_get "http://${API_HOST}:${API_PORT}/health" | head -c 200)" # ---------- 5. nginx ---------- if [ "${SKIP_NGINX:-0}" = "1" ]; then warn "SKIP_NGINX=1,跳过 nginx 配置。API 仅在 ${API_HOST}:${API_PORT} 提供。" else if ! command -v nginx >/dev/null 2>&1; then warn "未安装 nginx,跳过站点配置。" warn " 安装后重新执行本脚本,或手动把 deploy/nginx/opendance.conf 放到 nginx 配置目录。" else log "配置 nginx 站点" NGINX_AVAILABLE=/etc/nginx/sites-available NGINX_ENABLED=/etc/nginx/sites-enabled # RHEL 系发行版没有 sites-available 约定 if [ ! -d "$NGINX_AVAILABLE" ]; then NGINX_AVAILABLE=/etc/nginx/conf.d NGINX_ENABLED=/etc/nginx/conf.d fi DEST="$NGINX_AVAILABLE/opendance.conf" sed -e "s|__APP_DIR__|$APP_DIR|g" \ -e "s|__SERVER_NAME__|$SERVER_NAME|g" \ -e "s|__API_HOST__|$API_HOST|g" \ -e "s|__API_PORT__|$API_PORT|g" \ "$APP_DIR/deploy/nginx/opendance.conf" > "$DEST" if [ "$NGINX_ENABLED" != "$NGINX_AVAILABLE" ]; then ln -sf "$DEST" "$NGINX_ENABLED/opendance.conf" if [ -e "$NGINX_ENABLED/default" ]; then rm -f "$NGINX_ENABLED/default" log "已移除 nginx 默认站点(避免抢占 80 端口)" fi fi if nginx -t; then systemctl reload nginx 2>/dev/null || systemctl restart nginx 2>/dev/null || nginx -s reload log "nginx 已重载" else die "nginx 配置校验失败,请检查 $DEST" fi fi fi # ---------- 6. 完成 ---------- HOST_HINT="${DEPLOY_HOST:-<服务器IP>}" # 按实际使用的初始化系统给出对应的运维命令(用变量拼装,避免嵌套 heredoc 出错) case "$INIT_SYSTEM" in systemd) CMD_HINT=" systemctl status opendance-api # 服务状态 journalctl -u opendance-api -f # 实时日志 systemctl restart opendance-api # 重启" ;; openrc) CMD_HINT=" rc-service opendance-api status # 服务状态 rc-service opendance-api restart # 重启 tail -f /var/log/opendance-api.log # 实时日志" ;; sysvinit) CMD_HINT=" /etc/init.d/opendance-api status # 服务状态 /etc/init.d/opendance-api restart # 重启 tail -f /var/log/opendance-api.log # 实时日志" ;; *) CMD_HINT=" bash ${APP_DIR}/deploy/ctl.sh status # 服务状态 bash ${APP_DIR}/deploy/ctl.sh restart # 重启 bash ${APP_DIR}/deploy/ctl.sh logs # 实时日志" ;; esac cat <