现象:服务器未安装 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、环境变量说明)。
644 lines
28 KiB
Bash
Executable File
644 lines
28 KiB
Bash
Executable File
#!/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
|