Files
opendance-web/web-server/scripts/accept-release.sh
T
zijunchiang 06b476c4e2 test(accept): 产物级端到端验收自动化 + 实体注册核对落到 dist(v0.7.4 实测全通过)
背景:`npm run verify` 跑的是源码与刚构建的 dist,而交付给部署方的是
`dist-release/*.tar.gz`;两者之间隔着「解包 → npm install --omit=dev → 迁移 →
起进程」四步。本项目三次生产事故(v0.7.0/v0.7.1 起不来、v0.7.3 场景上传 400、
v0.7.4 `No metadata for "Scene"`)全都只在产物真正跑起来时才暴露。

一、`scripts/check-bootstrap.mjs` 新增「产物级实体注册核对」
   直接读包内 `dist/entities/`:逐个 import 每个 *.entity.js(@Entity() 装饰器
   在模块加载时写 metadata),要求
     * 每个实体文件里被 @Entity() 装饰的类都在 ENTITIES 里;
     * ENTITIES 里不允许有未装饰的成员;不允许重复;
     * Scene 必须注册(点名)。
   为什么必须单独有这一条:漏注册实体**不影响启动**(TypeORM 只按 ENTITIES 注册,
   DI 图照样解析、/health 正常、migrationsPending 0),只在第一次查该实体时抛
   `No metadata`,导致 /api/scenes、/api/catalog/export、/api/catalog/version、
   /api/reviews?status=pending 全部 500 —— 客户端一个目录都拉不到。
   原来的启动冒烟与 entities-registry.spec.ts(查 src/)都抓不到它。

   反向验证(在真实 v0.7.4 发布包里复现生产缺陷):把包内
   dist/entities/index.js 的 ENTITIES 去掉 Scene 后,核对立即失败并给出
   「scene.entity.js 里的 Scene 没有注册进 ENTITIES」与「ENTITIES 里没有 Scene」;
   还原后通过。

二、`scripts/accept-release.sh`(新增):一个 tar.gz 的产物级端到端验收
   解包 → 装生产依赖 → 跑迁移 → 起服务 → 校验 /health 关键字段
   (版本 / 数据库 / migrationsPending / scenes.available)→ 产物级实体注册核对
   (用仓库当前的 checker 覆盖包内旧版本,验收标准以最新为准)→ 跑发布包自带的
   test/smoke.mjs → 停服务。只需要一个现成的 MySQL/MariaDB,不装数据库
   (与项目「不部署 MySQL」的边界一致)。

   实测(opendance-web-v0.7.4.tar.gz + 真实 MySQL 9.7.2,全新库从零迁移):
     ✓ /health version=0.7.4 与 package.json 一致
     ✓ 数据库已连接(opendance_accept,pending=0)
     ✓ scenes.available=true(生产 v0.7.3 是 false + No metadata)
     ✓ 产物级实体注册核对通过:6 个实体文件全部注册进 ENTITIES(含 Scene)
     == 结果:80 通过 / 0 失败 ==
   冒烟 [7] 段即 ④ 验收:POST /api/scenes 201、一律 pending、GLB 直传不重编码、
   glbUrl 生成、重力/风/摩擦/坡度/出生点/相机模式按配置原样入库、越界夹紧与非法
   枚举回退、审核通过后 catalog/export 的 scenes[] 带完整物理参数与 GLB/缩略图字段、
   目录指纹纳入场景变更、详情、状态过滤、DELETE 204。

   `npm run verify` 全绿:契约 6 项 / 35 路由 / 版本一致 / 295 项测试 / build /
   产物级注册核对 / 启动冒烟。
2026-09-17 04:44:30 +08:00

199 lines
8.0 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.
#!/usr/bin/env bash
# ============================================================
# OpenDance Studio Web 服务端 —— **发布产物级端到端验收**
#
# 为什么需要它:仓库里的 `npm run verify`(单元测试 + `check-bootstrap`)跑的是
# **源码与刚构建的 dist**;而真正交付给部署方的是 `dist-release/` 里的 tar.gz。
# 两者之间还隔着「解包 → npm install → 迁移 → 起进程」四步,历史上三次生产
# 事故(v0.7.0/v0.7.1 起不来、v0.7.3 场景上传 400、v0.7.4 `No metadata for "Scene"`)
# 全都只在**产物真正跑起来**时才暴露。
#
# 本脚本把那次验收完整自动化:解包发布包 → 装生产依赖 → 跑迁移 → 起服务 →
# 跑发布包自带的 `test/smoke.mjs` → 停服务 → 汇报。跑完你就得到一份
# 「这个 tarball 能不能用」的结论,而不是「源码测试是绿的」。
#
# 前置:需要一个可连的 **MySQL / MariaDB**(脚本不会安装数据库,与项目
# 「不部署 MySQL」的边界一致)。本机验证时可用纯用户态方案,例如:
# npm i mysql-memory-server # 或任何现成实例
#
# 用法:
# DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=opendance DB_PASS=xxx DB_NAME=opendance_test \
# bash scripts/accept-release.sh dist-release/opendance-web-v0.7.4.tar.gz
#
# 可选环境变量:
# PORT / ADMIN_PORT 服务端口(默认 34100 / 34101,避免撞上真实服务)
# WORK_DIR 解包与存储目录(默认 mktemp -d)
# ASSIMP_BIN Assimp 可执行文件;设为空则跳过需要它的用例
# SKIP_MIGRATE=1 跳过迁移(复用已有库)
# KEEP_RUNNING=1 跑完不杀进程(便于手工继续查)
#
# 退出码:0 全通过 / 1 有失败 / 2 用法或前置问题。
# ============================================================
set -uo pipefail
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$PROJECT_DIR"
TARBALL="${1:-}"
if [ -z "$TARBALL" ]; then
echo "用法:DB_HOST=... DB_USER=... DB_NAME=... bash scripts/accept-release.sh <发布包 tar.gz>" >&2
exit 2
fi
if [ ! -f "$TARBALL" ]; then
echo "✗ 找不到发布包:$TARBALL" >&2
exit 2
fi
for name in DB_HOST DB_USER DB_NAME; do
if [ -z "${!name:-}" ]; then
echo "✗ 缺少环境变量 $name(需要现有 MySQL/MariaDB 连接信息)" >&2
exit 2
fi
done
PORT="${PORT:-34100}"
ADMIN_PORT="${ADMIN_PORT:-34101}"
WORK_DIR="${WORK_DIR:-$(mktemp -d)}"
SKIP_MIGRATE="${SKIP_MIGRATE:-0}"
KEEP_RUNNING="${KEEP_RUNNING:-0}"
log() { printf '\033[1;34m[accept]\033[0m %s\n' "$*"; }
fail() { printf '\033[1;31m[accept] ✗ %s\033[0m\n' "$*" >&2; FAILED=1; }
FAILED=0
log "发布包:$TARBALL"
log "工作目录:$WORK_DIR"
mkdir -p "$WORK_DIR"
# ---- 1. 解包 ----
log "解包…"
tar -xzf "$TARBALL" -C "$WORK_DIR" || { echo "✗ 解包失败" >&2; exit 1; }
PACKAGE_ROOT="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)"
SERVER_DIR="$PACKAGE_ROOT/web-server"
[ -d "$SERVER_DIR" ] || { echo "✗ 包里没有 web-server/ 目录" >&2; exit 1; }
# ---- 2. 装生产依赖 ----
# 与部署脚本一致:只装 dependencies,不装 devDependencies。
log "安装生产依赖(npm install --omit=dev)…"
( cd "$SERVER_DIR" && npm install --omit=dev --no-audit --no-fund >"$WORK_DIR/npm-install.log" 2>&1 ) \
|| { echo "✗ 依赖安装失败,日志:$WORK_DIR/npm-install.log" >&2; tail -20 "$WORK_DIR/npm-install.log"; exit 1; }
# ---- 3. 环境变量 ----
cat >"$WORK_DIR/accept.env" <<EOF
NODE_ENV=production
HOST=127.0.0.1
PORT=$PORT
ADMIN_PORT=$ADMIN_PORT
ADMIN_HOST=127.0.0.1
DB_HOST=$DB_HOST
DB_PORT=${DB_PORT:-3306}
DB_USER=$DB_USER
DB_PASS=${DB_PASS:-}
DB_NAME=$DB_NAME
DB_SYNCHRONIZE=false
STORAGE_DIR=$WORK_DIR/storage
ADMIN_DIR=$PACKAGE_ROOT/admin
CONTRACTS_DIR=$PACKAGE_ROOT/contracts
EOF
mkdir -p "$WORK_DIR/storage"
# shellcheck disable=SC1091
set -a; . "$WORK_DIR/accept.env"; set +a
# ---- 4. 迁移 ----
if [ "$SKIP_MIGRATE" = "1" ]; then
log "跳过迁移(SKIP_MIGRATE=1)"
else
log "执行迁移(npm run migration:run)…"
( cd "$SERVER_DIR" && npm run migration:run >"$WORK_DIR/migrate.log" 2>&1 ) \
|| { echo "✗ 迁移失败,日志:$WORK_DIR/migrate.log" >&2; tail -30 "$WORK_DIR/migrate.log"; exit 1; }
log "迁移完成"
fi
# ---- 5. 起服务 ----
log "启动产物(node dist/main.js)…"
( cd "$SERVER_DIR" && nohup node dist/main.js >"$WORK_DIR/server.log" 2>&1 & echo $! >"$WORK_DIR/server.pid" )
SERVER_PID="$(cat "$WORK_DIR/server.pid")"
cleanup() {
if [ "$KEEP_RUNNING" != "1" ] && [ -n "${SERVER_PID:-}" ] && kill -0 "$SERVER_PID" 2>/dev/null; then
kill "$SERVER_PID" 2>/dev/null || true
sleep 2
kill -9 "$SERVER_PID" 2>/dev/null || true
log "已停止服务(pid $SERVER_PID)"
fi
}
trap cleanup EXIT
API="http://127.0.0.1:$PORT"
READY=0
for _ in $(seq 1 60); do
if curl -sf -m 5 "$API/health" >"$WORK_DIR/health.json" 2>/dev/null; then READY=1; break; fi
if ! kill -0 "$SERVER_PID" 2>/dev/null; then break; fi
sleep 1
done
if [ "$READY" != "1" ]; then
fail "服务 60 秒内没有就绪;日志:$WORK_DIR/server.log"
tail -30 "$WORK_DIR/server.log"
exit 1
fi
# ---- 6. 健康检查关键字段(④ 的生产根因就在这几项上)----
log "健康检查:"
node -e '
const fs = require("node:fs");
const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const pkg = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
let bad = 0;
const say = (ok, text) => { console.log(` ${ok ? "✓" : "✗"} ${text}`); if (!ok) bad = 1; };
say(j.version === pkg.version, `/health 的 version=${j.version} 与 package.json 的 ${pkg.version} 一致`);
say(j.database.connected === true, `数据库已连接(${j.database.database},pending=${j.database.migrationsPending})`);
say(j.database.migrationsPending === 0, "没有未执行的迁移");
say(j.scenes.available === true, `scenes.available=${j.scenes.available}(false 时 error=${JSON.stringify(j.scenes.error)})`);
process.exit(bad);
' "$WORK_DIR/health.json" "$SERVER_DIR/package.json" || fail "健康检查关键字段不符合预期"
# ---- 7. 产物级实体注册核对 ----
# 用**当前仓库**的检查脚本去核对包内产物:验收标准应当是最新的,而不是
# 「这个包自带的那份检查」(包里那份可能比现在旧,旧规则查不出新增的检查项)。
log "产物级实体注册核对:"
CHECKER="$PROJECT_DIR/scripts/check-bootstrap.mjs"
if [ -f "$CHECKER" ] && ! cmp -s "$CHECKER" "$SERVER_DIR/scripts/check-bootstrap.mjs"; then
cp "$CHECKER" "$SERVER_DIR/scripts/check-bootstrap.mjs"
log " 已用仓库当前的 check-bootstrap.mjs 覆盖包内版本(验收标准以最新为准)"
fi
if ( cd "$SERVER_DIR" && node scripts/check-bootstrap.mjs >"$WORK_DIR/bootstrap.log" 2>&1 ); then
grep -E "^✓" "$WORK_DIR/bootstrap.log" | sed 's/^/ /'
else
fail "实体注册核对失败"
sed 's/^/ /' "$WORK_DIR/bootstrap.log" | tail -20
fi
# ---- 8. 发布包自带的端到端冒烟 ----
log "端到端冒烟(发布包自带的 test/smoke.mjs)…"
if ( cd "$SERVER_DIR" && API_BASE="$API" node test/smoke.mjs >"$WORK_DIR/smoke.log" 2>&1 ); then
grep -E "^== 结果" "$WORK_DIR/smoke.log" | sed 's/^/ /'
grep -c "^ ✓" "$WORK_DIR/smoke.log" | sed 's/^/ 通过项:/'
else
fail "冒烟未全通过"
grep -E "^ ✗|^== 结果" "$WORK_DIR/smoke.log" | sed 's/^/ /'
fi
# ---- 9. 契约快照与三端一致 ----
log "包内契约快照:"
if [ -f "$PACKAGE_ROOT/contracts/VERSION" ]; then
sed 's/^/ /' "$PACKAGE_ROOT/contracts/VERSION"
else
fail "包内缺少 contracts/VERSION"
fi
echo ""
if [ "$FAILED" -ne 0 ]; then
echo "============================================================"
echo " 产物级验收失败(日志目录:$WORK_DIR)"
echo "============================================================"
exit 1
fi
echo "============================================================"
echo " 产物级验收全部通过(日志目录:$WORK_DIR)"
echo "============================================================"