Files
opendance-web/deploy/install.sh
T
opendance da03777995 fix(models): Assimp 缺失时给出可操作报错,部署脚本自动安装
现象:服务器未安装 Assimp,上传 PMX 压缩包只得到
  GLB 直出:导出失败(ENOENT):spawn assimp ENOENT
  glTF 直出:导出失败(ENOENT):spawn assimp ENOENT
用户看不出缺什么、怎么装,也不知道只传 glb/vrm 其实不需要它。

修复:
1. 新增 src/common/assimp.ts:按 ASSIMP_BIN -> PATH -> /usr/bin、/usr/local/bin
   等常见目录定位可执行文件(只认文件,不认同名目录),转换前先预检;
   找不到就一次性给出各发行版安装命令、查过的路径与替代办法,
   不再打两条 ENOENT。动作预处理(FBX/glTF -> BVH)复用同一文案。
2. 新增 deploy/assimp-lib.sh:install.sh 按发行版自动安装
   (apt-get / dnf / yum / zypper / apk / pacman),装在非常规目录时把绝对
   路径写入 deploy/.env 的 ASSIMP_BIN;装不上只告警不中止,可用
   SKIP_ASSIMP_INSTALL=1 跳过。check-access.sh 自检增加 Assimp 一行。
3. /health 增加 assimp.path;管理端「概览 -> 服务健康」显示 Assimp 状态,
   不可用时给出安装提示。
4. 新增 test/assimp-locate.spec.ts 13 项;强化「assimp 不存在」用例断言安装命令。
   测试规模 12 文件 / 193 项。
5. 版本 0.2.2,README(.md/.txt)/DSH.md 同步(前置要求、FAQ、环境变量说明)。
2026-09-16 02:55:32 +08:00

644 lines
28 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
# ============================================================
# OpenDance Studio Web 服务端 —— Linux 原生站点部署脚本
#
# 部署形态(不使用 Docker、不部署反向代理):
# 本脚本只负责把 NestJS 业务服务跑起来(systemd / OpenRC / SysVinit / 后台进程)。
# 该服务自带管理端页面,反向代理由另外的服务负责,配置说明见 deploy/proxy/。
#
# 数据库仍是部署方提供的**现有 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)
# PORT 软件 API 监听端口(默认 3000)
# ADMIN_PORT 管理端端口(默认 PORT + 1;设为与 PORT 相同即单端口模式)
# ============================================================
set -euo pipefail
# 失败时打印出错行号,避免只看到半截输出
trap 'printf "\033[1;31m[install]\033[0m %s 第 %d 行执行失败(退出码 %d)\n" "${BASH_SOURCE[0]}" "$LINENO" "$?" >&2' ERR
SRC_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$SRC_DIR"
# .env 语法预检(deploy/env-lib.sh):.env 是被 shell 真正 source 的文件,
# 未加引号且含空格的值会被当成命令执行,报错形如
# ./deploy/.env: line 53: Miku.pmx: command not found
# 载入前先查一遍,给出带行号与改法的中文提示。
# shellcheck source=deploy/env-lib.sh
. "$SRC_DIR/deploy/env-lib.sh"
# shellcheck source=deploy/assimp-lib.sh
. "$SRC_DIR/deploy/assimp-lib.sh"
APP_DIR="${APP_DIR:-/opt/opendance}"
SERVICE_USER="${SERVICE_USER:-opendance}"
STORAGE_DIR="${STORAGE_DIR:-/var/lib/opendance/storage}"
API_PORT="${PORT:-3000}"
ADMIN_PORT="${ADMIN_PORT:-$((API_PORT + 1))}"
# 注意:健康检查与汇总输出用的地址不在这里写死——真实监听地址来自
# deploy/.env 的 HOST,脚本在源入 .env 后计算 BIND_HOST / HEALTH_HOST / LAN_IP。
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 权限(安装系统服务、创建服务用户)。
请以 root 身份执行,例如: su -c 'bash deploy/install.sh' 或 sudo bash deploy/install.sh"
# 缺少 deploy/.env 时:
# 1) 若安装目录里已有上次安装留下的配置,直接复用(保持原有语义);
# 2) 否则调用 deploy/init-env.sh 做初始化:
# - 交互终端 / 管道输入 → 逐项询问并写回 deploy/.env;
# - 非交互(CI、`</dev/null`、--yes)→ 只做缺字段校验并以非 0 退出,
# 由本脚本 die 出清晰的错误,绝不阻塞。
# 这样既保留了「复用上次安装配置」的语义,也避免用户每次重新解压发布包
# 都得重填一遍数据库口令(本次就是这么被绊住的)。
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
log "未找到 deploy/.env,启动初始化引导(deploy/init-env.sh)"
if ! bash "$SRC_DIR/deploy/init-env.sh"; then
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 数据库名
非交互环境请先准备好 deploy/.env,再执行:
bash deploy/init-env.sh --non-interactive"
fi
[ -f deploy/.env ] || die "deploy/init-env.sh 结束后仍缺少 deploy/.env"
fi
fi
opendance_validate_env_file "./deploy/.env" || die "deploy/.env 语法预检未通过(见上):按提示给对应行的值加上引号后重新执行。"
set -a
# shellcheck disable=SC1091
. ./deploy/.env
set +a
API_PORT="${PORT:-3000}"
# 管理端端口:默认 API 端口 + 1;等于 API 端口时是单端口兼容模式
ADMIN_PORT="${ADMIN_PORT:-$((API_PORT + 1))}"
ADMIN_BIND_HOST="${ADMIN_HOST:-${HOST:-0.0.0.0}}"
[ -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 未设置"
# 实际生效的监听地址(deploy/.env 的 HOST,默认 0.0.0.0)
BIND_HOST="${HOST:-0.0.0.0}"
case "$BIND_HOST" in
0.0.0.0|'*'|''|127.0.0.1|localhost|::1) HEALTH_HOST="127.0.0.1" ;;
*) HEALTH_HOST="$BIND_HOST" ;; # 绑定具体网卡时,回环地址是连不上的
esac
# 本机对外 IP(用于给出可直接访问的地址,并在安装时验证"外部能否访问")
LAN_IP=""
if command -v ip >/dev/null 2>&1; then
LAN_IP="$(ip route get 1.1.1.1 2>/dev/null | awk '/src/{for(i=1;i<=NF;i++) if($i=="src") print $(i+1)}' | head -1)"
fi
[ -z "$LAN_IP" ] && LAN_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
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}(现有实例,本脚本不会创建或管理数据库)"
# ---------- 0.5 可选外部依赖:Assimp ----------
# 逻辑与文案在 deploy/assimp-lib.sh(可单独测试):Assimp 是外部命令行工具
# (BSD-3-Clause),不在 Node 依赖里,只影响 .pmx/.fbx 模型转换与 FBX/glTF
# 动作预处理;装不上不中止安装,但必须当场给出结论与可复制的安装命令。
log "检查 Assimp(可选依赖:.pmx/.fbx 模型转换、FBX/glTF 动作预处理需要)"
ASSIMP_PATH="$(opendance_assimp_resolve || true)"
if [ -z "$ASSIMP_PATH" ] && [ "${SKIP_ASSIMP_INSTALL:-0}" != "1" ]; then
log "未找到 Assimp,尝试用系统包管理器安装:$(opendance_assimp_install_cmd)"
if opendance_assimp_install >/dev/null 2>&1; then
ASSIMP_PATH="$(opendance_assimp_resolve || true)"
else
warn "自动安装 Assimp 失败(离线环境或该发行版没有对应软件包)"
fi
fi
if [ -n "$ASSIMP_PATH" ]; then
log "Assimp:$ASSIMP_PATH($("$ASSIMP_PATH" version 2>/dev/null | head -1))"
if opendance_assimp_needs_env_path "$ASSIMP_PATH"; then
log "该目录不在服务默认 PATH 中,把 ASSIMP_BIN=\"$ASSIMP_PATH\" 写入 deploy/.env"
if grep -qE '^[[:space:]]*#?[[:space:]]*ASSIMP_BIN=' deploy/.env; then
sed -i "0,/^[[:space:]]*#\?[[:space:]]*ASSIMP_BIN=.*/s||ASSIMP_BIN=\"$ASSIMP_PATH\"|" deploy/.env
else
printf '\nASSIMP_BIN="%s"\n' "$ASSIMP_PATH" >> deploy/.env
fi
fi
else
warn "未找到 Assimp:上传 .pmx / .fbx(或含它们的压缩包)会转换失败,FBX / glTF 动作也无法预处理。"
warn " 手动安装:$(opendance_assimp_install_cmd)"
warn " 只上传 .glb / .vrm 模型与 .bvh / .vmd / 关键点 JSON 动作则不受影响。"
warn " 已装好但不在 PATH?在 deploy/.env 里设置 ASSIMP_BIN 为绝对路径后重跑本脚本。"
warn " 也可用 SKIP_ASSIMP_INSTALL=1 跳过自动安装。"
fi
# ---------- 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
# ---- 管理端产物:两种目录布局都要支持 ----
# 发布包: <root>/admin/ (已构建,直接用)
# 源码检出:<root>/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)"
opendance_validate_env_file "$APP_DIR/deploy/.env" || die "$APP_DIR/deploy/.env 语法预检未通过(见上)"
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://${HEALTH_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://${HEALTH_HOST}:${API_PORT}/health" | head -c 200)"
# 管理端端口单独确认(端口写错时,只有在装的时候点出来才有人看见)
ADMIN_READY=""
ADMIN_HOST_FOR_PROBE="${ADMIN_BIND_HOST}"
case "$ADMIN_HOST_FOR_PROBE" in
0.0.0.0|'*'|'') ADMIN_HOST_FOR_PROBE="127.0.0.1" ;;
esac
if [ "$ADMIN_PORT" = "$API_PORT" ]; then
ADMIN_READY="same"
elif http_ok "http://${ADMIN_HOST_FOR_PROBE}:${ADMIN_PORT}/health"; then
ADMIN_READY="yes"
else
ADMIN_READY="no"
warn "管理端端口 ${ADMIN_PORT} 未在本机就绪(API 端口=${API_PORT})"
fi
# ---------- 5. 边界:确认"外部到底能不能访问" ----------
# 绑定层写错(HOST=127.0.0.1)时,本机 curl 一切正常,但外部永远连不上,
# 而安装日志里看不出任何异常——必须在安装时就点出来。
EXTERNAL_OK=""
case "$BIND_HOST" in
127.0.0.1|localhost|::1)
ACCESS_MODE="loopback"
;;
*)
ACCESS_MODE="external"
if [ -n "$LAN_IP" ] && http_ok "http://${LAN_IP}:${API_PORT}/health"; then
EXTERNAL_OK="yes"
else
EXTERNAL_OK="no"
fi
;;
esac
# ---------- 5. 反向代理(本脚本**不部署**,仅给出说明) ----------
# 需求变更:Web 端只负责业务服务,反向代理由另外的服务/团队提供。
# 本服务已自带管理端页面,因此反代只需把请求**整体转发**到本端口,
# 无需在代理层再托管静态文件、也无需配置 SPA 回退(服务端已处理)。
cat <<EOF
------------------------------------------------------------
反向代理说明(本脚本不部署反代)
------------------------------------------------------------
管理端口与软件 API 端口是分开的,按用途分别转发:
软件 API 端口(给 Windows/Godot 客户端与 Android App):http://<本机地址>:${API_PORT}
提供 /api、/health、/static、/contracts;**不含管理页面**(未知路径返回 404)
管理端端口(给浏览器):http://<本机地址>:${ADMIN_PORT}
提供管理页面 + 上面全部接口;建议只在内网 / VPN 内暴露
健康检查(可用于反代的存活探测,两个端口都提供):/health
上传上限需在代理层同步放开(本服务为 $(( ${UPLOAD_MAX_BYTES:-536870912} / 1024 / 1024 )) MiB)
配置样例与完整说明:
deploy/proxy/nginx.conf.example nginx 站点配置
deploy/proxy/web.config.example IIS(ARR + URL Rewrite)配置
deploy/proxy/README.md IIS / nginx 反代说明文档
注意:若反代与后端不在同一台机器,需保持 HOST=0.0.0.0(当前值:${BIND_HOST})并用防火墙限制来源;
若在同一台机器,建议把 HOST 改为 127.0.0.1(管理端可用 ADMIN_HOST 单独限制,当前值:${ADMIN_BIND_HOST})。
------------------------------------------------------------
EOF
# ---------- 6. 完成 ----------
# 汇总里展示的地址必须与真实绑定一致:
# 回环 → 只能写 127.0.0.1(写对外 IP 会让人以为外部能访问)
# 具体网卡 → 只能写该网卡地址(对外 IP 未必等于它)
# 通配 → 用探测到的对外 IP
HOST_HINT="${DEPLOY_HOST:-}"
if [ -z "$HOST_HINT" ]; then
case "$BIND_HOST" in
127.0.0.1|localhost|::1) HOST_HINT="127.0.0.1" ;;
0.0.0.0|'*'|'') HOST_HINT="${LAN_IP:-<服务器IP>}" ;;
*) HOST_HINT="$BIND_HOST" ;;
esac
fi
case "$BIND_HOST" in
127.0.0.1|localhost|::1)
LISTEN_DESC="仅回环 ${BIND_HOST}:${API_PORT}(外部无法访问)" ;;
0.0.0.0|'*'|'')
if [ -n "$LAN_IP" ]; then
LISTEN_DESC="${BIND_HOST}:${API_PORT}(可直接访问:http://${LAN_IP}:${API_PORT})"
else
LISTEN_DESC="${BIND_HOST}:${API_PORT}"
fi ;;
*)
LISTEN_DESC="${BIND_HOST}:${API_PORT}(仅该网卡地址,请确认客户端可达)" ;;
esac
# 按实际使用的初始化系统给出对应的运维命令(用变量拼装,避免嵌套 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 <<EOF
============================================================
OpenDance Studio Web 服务端已部署(业务服务,无 Docker、不含反向代理)
软件 API: ${LISTEN_DESC}
接口:/api、/health、/static、/contracts(客户端调用;不含管理页面)
管理端: http://${HOST_HINT}:${ADMIN_PORT}/(浏览器访问管理界面)
健康检查: http://${HOST_HINT}:${API_PORT}/health
契约快照: http://${HOST_HINT}:${API_PORT}/contracts/
数据库: 使用现有实例 ${DB_HOST}:${DB_PORT:-3306}/${DB_NAME}
上传目录: ${STORAGE_DIR}
程序目录: ${APP_DIR}
进程管理: ${INIT_SYSTEM}
端口配置: API ${API_PORT} / 管理端 ${ADMIN_PORT}(${APP_DIR}/deploy/.env)
绑定配置: HOST=${BIND_HOST} ADMIN_HOST=${ADMIN_BIND_HOST}
常用命令:
${CMD_HINT}
============================================================
EOF
if [ "$ACCESS_MODE" = "loopback" ]; then
cat <<EOF
============================================================
⚠ 外部访问不了?现在是只监听回环(HOST=${BIND_HOST})
============================================================
服务本身正常,但只有本机能访问 127.0.0.1:${API_PORT},
其它机器(包括浏览器、反向代理)都连不上。
要允许外部访问,改这一行后重启:
sed -i 's/^HOST=.*/HOST=0.0.0.0/' ${APP_DIR}/deploy/.env
EOF
case "$INIT_SYSTEM" in
systemd) echo " systemctl restart opendance-api" ;;
openrc) echo " rc-service opendance-api restart" ;;
sysvinit) echo " /etc/init.d/opendance-api restart" ;;
*) echo " bash ${APP_DIR}/deploy/ctl.sh restart" ;;
esac
cat <<EOF
别忘了在防火墙放行端口(客户端用 ${API_PORT},浏览器管理界面用 ${ADMIN_PORT}):
firewall-cmd --permanent --add-port=${API_PORT}/tcp --add-port=${ADMIN_PORT}/tcp && firewall-cmd --reload
# 或:ufw allow ${API_PORT}/tcp && ufw allow ${ADMIN_PORT}/tcp
诊断脚本(只读):bash ${APP_DIR}/deploy/check-access.sh
============================================================
EOF
elif [ "$EXTERNAL_OK" = "no" ]; then
cat <<EOF
============================================================
⚠ 已监听 ${BIND_HOST}:${API_PORT},但用本机对外 IP 自测失败
============================================================
绑定层没问题,外部却连不上,通常是防火墙或云安全组拦截:
1) 放行端口:firewall-cmd --permanent --add-port=${API_PORT}/tcp --add-port=${ADMIN_PORT}/tcp && firewall-cmd --reload
或 ufw allow ${API_PORT}/tcp && ufw allow ${ADMIN_PORT}/tcp
2) 云服务器还要在控制台「安全组 / 网络 ACL」放行 ${API_PORT}/tcp
3) 确认客户端与服务器网络互通
诊断脚本(只读):bash ${APP_DIR}/deploy/check-access.sh
============================================================
EOF
fi