Files
zijunchiang caca7672cb fix(release): 修复发布包缺失 storage 模块,并加入产物完整性校验
用户实测:用发布包部署后启动报 MODULE_NOT_FOUND
  at dist/models/models.module.js:13  require('../storage/storage.module')

根因:package-release.sh 的 rsync 用了 --exclude 'storage'。该模式匹配
**任意层级**的同名目录,把源码目录 src/storage/ 与编译产物 dist/storage/
一并排除——发布包里完全没有 storage 模块,服务必然起不来。
这与第一阶段 .gitignore 里 'storage/' 误伤 src/storage/ 是同一类错误,
本项目第二次踩到。

修复:
- 排除模式全部锚定到 web-server 根:'/storage/'、'/data/'、'/tmp/'、
  '/.env'、'/admin/',不再匹配嵌套同名目录
- 新增产物完整性校验(打包后自动执行,失败即删除压缩包):
  src 下每个 .ts 必须有对应 dist/*.js;关键文件清单齐全;
  不得混入 node_modules / 真实 .env / 运行时存储 / keystore
- ctl.sh 补上缺失的 warn 函数(line 120: warn: command not found)

实测:
- 对照实验:排除模式改回 'storage' 后校验准确报出缺少
  dist/storage/storage.module.js 并删除压缩包,退出码 1
- 新包端到端:解压 → npm ci --omit=dev(213 包)→ 启动 → /health 200、
  MariaDB 已连接、迁移 0、catalog/songs/models/motions 全部 200
2026-09-15 00:54:22 +08:00

180 lines
5.8 KiB
Bash
Executable File
Raw Permalink 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 API —— 进程控制(不依赖 systemd / OpenRC / SysV)
#
# 适用于没有 systemd 的环境(容器、LXC、精简系统等)。
# 以 nohup 后台运行 Node 进程并记录 PID,日志写入 LOG_FILE。
#
# 用法(需 root):
# bash deploy/ctl.sh start|stop|restart|status|logs
#
# 配置来源:$APP_DIR/deploy/.env
# 可用环境变量覆盖:APP_DIR / SERVICE_USER / LOG_FILE
# ============================================================
set -uo pipefail
APP_DIR="${APP_DIR:-/opt/opendance}"
SERVICE_USER="${SERVICE_USER:-opendance}"
LOG_FILE="${LOG_FILE:-/var/log/opendance-api.log}"
RUN_DIR="${RUN_DIR:-/run}"
PID_FILE="${PID_FILE:-$RUN_DIR/opendance-api.pid}"
MAIN_JS="$APP_DIR/web-server/dist/main.js"
die() { printf '\033[1;31m[ctl]\033[0m %s\n' "$*" >&2; exit 1; }
log() { printf '\033[1;34m[ctl]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[ctl]\033[0m %s\n' "$*"; }
[ "$(id -u)" -eq 0 ] || die "需要 root 权限"
# 以服务用户身份执行;不使用 sudo(本脚本已要求 root)
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 -- "$@"
else
su -s /bin/bash "$SERVICE_USER" -c "$(printf '%q ' "$@")"
fi
}
# 后台启动服务进程,回显启动器 PID。
# 注意:这里**不能**写成 `nohup as_service_user ...`。
# nohup 是外部命令,只能执行可执行文件,无法调用 shell 函数
# (实测直接退出码 127 且什么都不运行),会让无 systemd 的降级路径完全失效。
# 因此三种降权工具各自显式拼接成外部命令。
spawn_service_process() {
local node_bin="$1" script="$2"
if command -v runuser >/dev/null 2>&1; then
nohup runuser -u "$SERVICE_USER" -- "$node_bin" "$script" >>"$LOG_FILE" 2>&1 &
elif command -v setpriv >/dev/null 2>&1; then
nohup setpriv --reuid="$SERVICE_USER" --regid="$SERVICE_USER" --init-groups -- \
"$node_bin" "$script" >>"$LOG_FILE" 2>&1 &
else
nohup su -s /bin/bash "$SERVICE_USER" -c "$(printf '%q ' "$node_bin" "$script")" \
>>"$LOG_FILE" 2>&1 &
fi
echo $!
}
node_bin() {
command -v node 2>/dev/null || die "未找到 node"
}
# 按 pid 文件与命令行双重判定,避免 PID 记录失准。
# 返回以空格分隔的 PID 列表;**没有进程时返回空串**。
# 注意:不能再用 `echo "$pids" | xargs -n1` 去重 —— xargs 在空输入下仍会
# 执行一次 echo,输出一个空行,最终得到一个空格。调用方 `[ -n "$(...)" ]`
# 会把 " " 判为“有进程”,导致 start 直接跳过、服务根本没起来。
find_pids() {
local pids=""
if [ -f "$PID_FILE" ]; then
local pid
pid="$(cat "$PID_FILE" 2>/dev/null || true)"
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
pids="$pid"
fi
fi
# 兜底:命令行里带 main.js 绝对路径的进程(启动时即用绝对路径,故匹配精确)
local extra
extra="$(pgrep -f -- "$MAIN_JS" 2>/dev/null || true)"
for p in $extra; do
case " $pids " in
*" $p "*) ;;
*) pids="${pids:+$pids }$p" ;;
esac
done
printf '%s' "$pids"
}
is_running() { [ -n "$(find_pids)" ]; }
start() {
[ -f "$MAIN_JS" ] || die "找不到 $MAIN_JS,请先执行 deploy/install.sh"
if is_running; then
log "已在运行(PID:$(find_pids)),跳过启动"
return 0
fi
# 载入部署环境变量
if [ -f "$APP_DIR/deploy/.env" ]; then
set -a; . "$APP_DIR/deploy/.env"; set +a
else
die "缺少 $APP_DIR/deploy/.env"
fi
mkdir -p "$(dirname "$LOG_FILE")" "$RUN_DIR"
touch "$LOG_FILE"
chown "$SERVICE_USER:$SERVICE_USER" "$LOG_FILE" 2>/dev/null || true
local NODE; NODE="$(node_bin)"
log "启动:$MAIN_JS(用户 $SERVICE_USER)"
cd "$APP_DIR/web-server" || die "无法进入 $APP_DIR/web-server"
# 用绝对路径启动,便于 stop 时精确匹配命令行
local launcher_pid
launcher_pid="$(spawn_service_process "$NODE" "$MAIN_JS")"
echo "$launcher_pid" > "$PID_FILE"
# 先确认启动器没有立刻失败(如降权工具不可用、node 路径错误)
sleep 1
if ! is_running; then
warn "进程未能启动,日志如下:"
tail -20 "$LOG_FILE" >&2 || true
rm -f "$PID_FILE"
return 1
fi
# 等待端口就绪
local port="${PORT:-3000}"
for _ in $(seq 1 30); do
if command -v curl >/dev/null 2>&1; then
curl -fsS -m 2 "http://127.0.0.1:${port}/health" >/dev/null 2>&1 && { log "已就绪(PID:$(find_pids))"; return 0; }
else
"$NODE" -e 'fetch(process.argv[1]).then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))' \
"http://127.0.0.1:${port}/health" >/dev/null 2>&1 && { log "已就绪(PID:$(find_pids))"; return 0; }
fi
sleep 1
done
log "警告:30 秒内未通过健康检查,请查看 $LOG_FILE"
tail -20 "$LOG_FILE" >&2 || true
return 1
}
stop() {
local pids; pids="$(find_pids)"
if [ -z "$pids" ]; then
log "未在运行"
rm -f "$PID_FILE"
return 0
fi
log "停止:$pids"
for p in $pids; do kill -TERM "$p" 2>/dev/null || true; done
for _ in $(seq 1 15); do is_running || break; sleep 1; done
pids="$(find_pids)"
if [ -n "$pids" ]; then
log "优雅退出超时,强制结束:$pids"
for p in $pids; do kill -KILL "$p" 2>/dev/null || true; done
fi
rm -f "$PID_FILE"
log "已停止"
}
status() {
if is_running; then
log "运行中(PID:$(find_pids))"
return 0
fi
log "未运行"
return 3
}
case "${1:-}" in
start) start ;;
stop) stop ;;
restart) stop; start ;;
status) status ;;
logs) tail -n "${2:-80}" -f "$LOG_FILE" ;;
*) die "用法:bash deploy/ctl.sh start|stop|restart|status|logs" ;;
esac