diff --git a/DSH.md b/DSH.md index 73a7304..798b381 100644 --- a/DSH.md +++ b/DSH.md @@ -77,14 +77,15 @@ opendance-web/ │ └── open-source-credits.md # 开源声明锚点(三端共用) ├── web-server/ # NestJS 服务端 │ ├── src/ # 源码(entities / modules / preprocess) -│ ├── admin/ # React 管理端(构建上下文) +│ ├── admin/ # React 管理端 │ ├── test/ # 单元测试 + 端到端冒烟 -│ ├── scripts/ # migrate.mjs / check-contracts.mjs -│ └── Dockerfile -├── deploy/ # ★ 部署(不含 MySQL) -│ ├── docker-compose.yml -│ ├── install.sh -│ └── .env.example +│ └── scripts/ # migrate.mjs / check-contracts.mjs / check-routes.mjs +├── deploy/ # ★ Linux 原生站点部署(不含 MySQL) +│ ├── install.sh # 一键部署(systemd + nginx) +│ ├── uninstall.sh +│ ├── .env.example +│ ├── systemd/opendance-api.service +│ └── nginx/opendance.conf ├── scripts/package-release.sh # 发布产物打包 └── THIRD_PARTY.md ``` @@ -92,6 +93,7 @@ opendance-web/ ## 6. 硬性约束 1. **不部署 MySQL**。数据库为部署方提供的现有实例,连接信息只经环境变量注入。 + **也不使用 Docker**:以 nginx 站点 + systemd 服务的形式直接跑在 Linux 上。 2. **`DB_SYNCHRONIZE` 生产必须为 `false`**,所有表结构变更走 migration;每个 migration 必须可逆。 3. **绝不把凭证写入仓库**。`.env` 已在 `.gitignore` 中,只提交 `.env.example`。 diff --git a/README.txt b/README.txt index e498e68..c688ed9 100644 --- a/README.txt +++ b/README.txt @@ -9,11 +9,16 @@ OpenDance Studio —— Web 管理服务端 v0.1.0 使用说明 连接信息通过 .env 注入。 ---------------------------------------------------------------- -一、快速部署(Docker Compose) +一、部署到 Linux 服务器(原生站点,不使用 Docker) ---------------------------------------------------------------- +部署形态:一个 nginx 站点同时提供管理端页面与 API。 +Node 进程由 systemd 托管,只监听 127.0.0.1,外部只能经 nginx 访问。 + 前置要求 - - Linux 服务器,已安装 Docker 与 docker compose + - Linux 服务器(Debian/Ubuntu 或 RHEL 系均可),有 root 权限 + - Node.js 22 或更高版本 + - nginx(未安装时脚本会跳过站点配置并给出提示) - 一个可连通的现有 MySQL / MariaDB 实例,并已创建数据库与账号: CREATE DATABASE IF NOT EXISTS dpendance @@ -26,21 +31,47 @@ OpenDance Studio —— Web 管理服务端 v0.1.0 使用说明 (本项目不会创建数据库本身,也不需要 GRANT OPTION / FILE / SUPER 等管理权限) 部署步骤 - cd deploy - cp .env.example .env - vi .env # 填写 DB_HOST / DB_PORT / DB_USER / DB_PASS / DB_NAME - ./install.sh + tar -xzf opendance-web-v0.1.0.tar.gz + cd opendance-web-v0.1.0 + cp deploy/.env.example deploy/.env + vi deploy/.env # 填写 DB_HOST / DB_PORT / DB_USER / DB_PASS / DB_NAME + sudo bash deploy/install.sh -脚本会依次:校验 .env → 构建镜像 → 执行 TypeORM 迁移 → 启动服务 → 健康检查。 +脚本会依次: + 校验环境与 .env → 创建服务用户与目录 → 安装运行期依赖 → + 执行 TypeORM 迁移 → 注册并启动 systemd 服务 → 等待健康检查 → + 配置 nginx 站点并重载 -部署完成后 - API: http://:3000 - Admin: http://:8080 - 健康检查: http://:3000/health +部署完成后( 为服务器 IP) + 管理端首页: http:/// + API 前缀: http:///api + 健康检查: http:///health + 契约快照: http:///contracts/ -停止服务 - ./uninstall.sh # 保留上传数据卷 - ./uninstall.sh -v # 连数据卷一起删除 +可覆盖的安装参数 + APP_DIR=/opt/opendance 程序目录 + SERVICE_USER=opendance 运行服务的系统用户 + STORAGE_DIR=/var/lib/opendance/storage 上传文件目录 + SERVER_NAME=example.com nginx server_name + SKIP_NGINX=1 只装 API 服务,不配置 nginx + + 例:sudo SERVER_NAME=dance.example.com bash deploy/install.sh + +升级 + 解压新版本后重新执行 sudo bash deploy/install.sh 即可(会覆盖程序文件, + 保留 deploy/.env 与上传数据)。 + +卸载 + sudo bash deploy/uninstall.sh # 停服务、禁用站点,保留数据 + sudo bash deploy/uninstall.sh --purge # 一并删除程序目录与上传文件 + + 数据库始终由部署方自行管理,卸载脚本不做任何改动。 + +常用命令 + systemctl status opendance-api # 服务状态 + journalctl -u opendance-api -f # 实时日志 + systemctl restart opendance-api # 重启 + nginx -t && systemctl reload nginx # 校验并重载 nginx ---------------------------------------------------------------- 二、本地开发 @@ -85,10 +116,12 @@ OpenDance Studio —— Web 管理服务端 v0.1.0 使用说明 DB_LOGGING 是否打印 SQL(默认 false) PORT API 监听端口(默认 3000) - STORAGE_DIR 上传文件存储目录(默认 ./storage,容器内 /data/storage) + HOST API 监听地址(原生部署用 127.0.0.1,仅由 nginx 反代访问) + STORAGE_DIR 上传文件存储目录(原生部署默认 /var/lib/opendance/storage) UPLOAD_MAX_BYTES 单文件上限,默认 536870912(512 MiB) ASSIMP_BIN 可选:Assimp 可执行文件,FBX/glTF 动作预处理需要 CONTRACTS_DIR 可选:契约目录,设置后 /health 反映实时契约版本 + (原生部署默认指向 /opt/opendance/contracts) ---------------------------------------------------------------- 五、主要 API @@ -129,7 +162,7 @@ OpenDance Studio —— Web 管理服务端 v0.1.0 使用说明 contracts/ 跨端契约(权威,改接口先改这里) web-server/ NestJS 服务端源码 web-server/admin/ React 管理端 - deploy/ Docker Compose 部署(不含 MySQL) + deploy/ Linux 原生站点部署(nginx + systemd,不含 MySQL) THIRD_PARTY.md 第三方开源项目声明 ---------------------------------------------------------------- @@ -138,7 +171,7 @@ OpenDance Studio —— Web 管理服务端 v0.1.0 使用说明 1. .env 已被 .gitignore 忽略,禁止提交;仓库中只保留 .env.example。 2. 数据库账号使用最小权限,不授予 GRANT OPTION / FILE / SUPER。 -3. 本版本未内置管理端登录认证,请部署在内网或由反向代理提供鉴权。 +3. 本版本未内置管理端登录认证,请部署在内网,或在 nginx 层加 Basic Auth / SSO。 4. 上传的模型与动作必须填写来源五元组并通过审核后才对客户端可见。 5. 未使用 SMPL 拓扑,统一 MediaPipe 33 点,规避非商用许可限制。 @@ -146,4 +179,4 @@ OpenDance Studio —— Web 管理服务端 v0.1.0 使用说明 八、第三方开源声明 ---------------------------------------------------------------- -见 THIRD_PARTY.md,或访问 http://:8080/ 的「关于」页面。 +见 THIRD_PARTY.md,或访问站点 http:/// 的「关于」页面。 diff --git a/contracts/rest-api.yaml b/contracts/rest-api.yaml index 61493fd..b1ddb04 100644 --- a/contracts/rest-api.yaml +++ b/contracts/rest-api.yaml @@ -12,8 +12,8 @@ info: servers: - url: http://localhost:3000 description: 本地开发 - - url: http://:3000 - description: Docker 部署 + - url: http:// + description: Linux 原生站点部署(nginx 托管管理端并反代 /api) tags: - name: health diff --git a/deploy/.env.example b/deploy/.env.example index f8e9f82..5a2f225 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -1,22 +1,35 @@ # ============================================================ -# OpenDance Studio Web 服务端 —— 部署环境变量 -# 复制为 .env 后填写,.env 已被 .gitignore 忽略,禁止提交。 +# OpenDance Studio Web 服务端 —— Linux 原生部署环境变量 +# +# 复制为 deploy/.env 后填写;.env 已被 .gitignore 忽略,禁止提交。 +# 该文件由 systemd 通过 EnvironmentFile 注入 Node 进程。 # ============================================================ -# ---- 现有 MySQL / MariaDB 实例(本项目不部署、不管理数据库)---- +# ---- 服务 ---- +NODE_ENV=production +PORT=3000 +# 仅监听本机,由 nginx 反向代理对外提供服务(不要直接暴露 Node 进程) +HOST=127.0.0.1 + +# ---- 现有 MySQL / MariaDB 实例(本项目不安装、不管理数据库)---- DB_HOST= DB_PORT=3306 DB_USER=opendance_app DB_PASS=<部署方提供> DB_NAME=opendance +# 生产环境必须为 false,表结构一律走 TypeORM migration +DB_SYNCHRONIZE=false DB_LOGGING=false -# ---- 端口映射(宿主机端口)---- -API_PORT=3000 -ADMIN_PORT=8080 - -# ---- 上传与预处理 ---- -# 单文件上限(字节),默认 512 MiB +# ---- 文件存储 ---- +# 需与 install.sh 的 STORAGE_DIR 一致,且服务用户可写 +STORAGE_DIR=/var/lib/opendance/storage +# 单文件上传上限(字节),默认 512 MiB;nginx 的 client_max_body_size 需不小于此值 UPLOAD_MAX_BYTES=536870912 -# 可选:Assimp 可执行文件(FBX/glTF 动作预处理需要) + +# ---- 可选的 Assimp(FBX/glTF 动作预处理)---- ASSIMP_BIN=assimp + +# ---- 契约目录 ---- +# 指向安装目录下的 contracts/,使 /health 与 /api/catalog/version 反映实时契约版本 +CONTRACTS_DIR=/opt/opendance/contracts diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml deleted file mode 100644 index 49ddb3c..0000000 --- a/deploy/docker-compose.yml +++ /dev/null @@ -1,68 +0,0 @@ -# OpenDance Studio Web 服务端 —— Docker Compose 部署 -# -# 重要:本文件**不包含 MySQL 服务**。 -# 数据库为部署方提供的现有 MySQL/MariaDB 实例,连接信息由 .env 注入 -# (见 contracts/database.md)。 -# -# 用法: -# cp .env.example .env # 填写数据库连接信息 -# ./install.sh - -services: - api: - build: - context: ../web-server - dockerfile: Dockerfile - image: opendance-web-server:0.1.0 - container_name: opendance-api - env_file: - - .env - environment: - NODE_ENV: production - PORT: "3000" - DB_HOST: ${DB_HOST} - DB_PORT: ${DB_PORT:-3306} - DB_USER: ${DB_USER} - DB_PASS: ${DB_PASS} - DB_NAME: ${DB_NAME} - # 契约要求:生产环境禁止 synchronize - DB_SYNCHRONIZE: "false" - DB_LOGGING: ${DB_LOGGING:-false} - STORAGE_DIR: /data/storage - UPLOAD_MAX_BYTES: ${UPLOAD_MAX_BYTES:-536870912} - ASSIMP_BIN: ${ASSIMP_BIN:-assimp} - # 挂载的契约快照目录,/health 与 /api/catalog/version 会读取其实时版本 - CONTRACTS_DIR: /contracts - ports: - - "${API_PORT:-3000}:3000" - volumes: - - storage:/data/storage - - ../contracts:/contracts:ro - restart: unless-stopped - healthcheck: - test: - - CMD - - node - - -e - - "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" - interval: 30s - timeout: 5s - start_period: 20s - retries: 3 - - admin: - build: - context: ../web-server/admin - dockerfile: Dockerfile - image: opendance-admin:0.1.0 - container_name: opendance-admin - ports: - - "${ADMIN_PORT:-8080}:80" - depends_on: - api: - condition: service_healthy - restart: unless-stopped - -volumes: - storage: - name: opendance-storage diff --git a/deploy/install.sh b/deploy/install.sh index 5bee60e..b206133 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -1,104 +1,208 @@ #!/bin/bash # ============================================================ -# OpenDance Studio Web 服务端 —— 一键部署脚本 +# OpenDance Studio Web 服务端 —— Linux 原生站点部署脚本 # -# 部署内容:API(NestJS)+ 管理端(静态站点) -# 不部署内容:MySQL —— 数据库为部署方提供的现有实例(见 contracts/database.md) +# 部署形态(不使用 Docker): +# nginx ── 静态托管管理端 + 反向代理 API +# └──▶ systemd 托管的 Node 进程(NestJS,仅监听 127.0.0.1) # -# 用法: -# cp .env.example .env && vi .env -# ./install.sh +# 数据库仍是部署方提供的**现有 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 -cd "$(dirname "$0")" +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; } -# ---------- 1. 前置检查 ---------- -if [ ! -f .env ]; then - die "缺少 .env 文件,请从 .env.example 复制并填写数据库连接信息: - cp .env.example .env && vi .env" -fi +# ---------- 0. 前置检查 ---------- +[ "$(id -u)" -eq 0 ] || die "需要 root 权限(安装 systemd 服务与 nginx 站点):sudo bash deploy/install.sh" +[ -f deploy/.env ] || die "缺少 deploy/.env,请先: + cp deploy/.env.example deploy/.env && vi deploy/.env" + +set -a # shellcheck disable=SC1091 -set -a; . ./.env; set +a +. ./deploy/.env +set +a +API_PORT="${PORT:-3000}" -[ -n "${DB_HOST:-}" ] || die ".env 中 DB_HOST 未设置" -[ -n "${DB_USER:-}" ] || die ".env 中 DB_USER 未设置" -[ -n "${DB_NAME:-}" ] || die ".env 中 DB_NAME 未设置" -[ -n "${DB_PASS:-}" ] || die ".env 中 DB_PASS 未设置" +[ -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 未设置" -if docker compose version >/dev/null 2>&1; then - DC="docker compose" -elif command -v docker-compose >/dev/null 2>&1; then - DC="docker-compose" -else - die "未找到 docker compose 或 docker-compose,请先安装 Docker" +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 -command -v docker >/dev/null 2>&1 || die "未找到 docker" +log "同步程序文件到 $APP_DIR" +mkdir -p "$APP_DIR" +for item in web-server admin contracts; do + [ -e "$SRC_DIR/$item" ] || die "发布包缺少 $item/ 目录" + rm -rf "${APP_DIR:?}/$item" + cp -r "$SRC_DIR/$item" "$APP_DIR/$item" +done +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 -log "目标数据库:${DB_HOST}:${DB_PORT:-3306}/${DB_NAME}(现有实例,本脚本不会创建或管理 MySQL)" +mkdir -p "$STORAGE_DIR" +chown -R "$SERVICE_USER:$SERVICE_USER" "$APP_DIR" "$STORAGE_DIR" +chmod 750 "$STORAGE_DIR" -# ---------- 2. 构建镜像 ---------- -log "构建镜像(API + Admin)..." -$DC build +# ---------- 2. 安装运行期依赖(必要时构建) ---------- +cd "$APP_DIR/web-server" -# ---------- 3. 执行数据库迁移 ---------- -# 表结构由 TypeORM migration 创建;DB_SYNCHRONIZE 恒为 false -log "执行 TypeORM 迁移..." -$DC run --rm api npm run migration:run +if [ -f package-lock.json ]; then + log "安装运行期依赖(npm ci --omit=dev)" + sudo -u "$SERVICE_USER" npm ci --omit=dev --no-audit --no-fund +else + log "安装运行期依赖(npm install --omit=dev)" + sudo -u "$SERVICE_USER" npm install --omit=dev --no-audit --no-fund +fi -# ---------- 4. 启动服务 ---------- -log "启动服务..." -$DC up -d +if [ ! -f dist/main.js ]; then + warn "未找到构建产物 dist/main.js,改为在服务器上构建(需要 devDependencies)" + sudo -u "$SERVICE_USER" npm install --no-audit --no-fund + sudo -u "$SERVICE_USER" 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" -# ---------- 5. 健康检查 ---------- -API_PORT="${API_PORT:-3000}" -ADMIN_PORT="${ADMIN_PORT:-8080}" -HEALTH_URL="http://127.0.0.1:${API_PORT}/health" +# ---------- 3. 数据库迁移 ---------- +log "执行 TypeORM 迁移(不使用 DB_SYNCHRONIZE)" +set -a; . "$APP_DIR/deploy/.env"; set +a +cd "$APP_DIR/web-server" +sudo -u "$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 -log "等待 API 健康检查:${HEALTH_URL}" -ok=0 -for i in $(seq 1 30); do - if command -v curl >/dev/null 2>&1; then - if curl -fsS -m 3 "$HEALTH_URL" >/dev/null 2>&1; then ok=1; break; fi +# ---------- 4. 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 + +log "等待 API 就绪…" +READY=0 +for _ in $(seq 1 30); do + if curl -fsS -m 3 "http://${API_HOST}:${API_PORT}/health" >/dev/null 2>&1; 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 已就绪:$(curl -fsS -m 5 "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 - if $DC exec -T api node -e "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" >/dev/null 2>&1; then - ok=1; break + 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 - sleep 2 -done - -if [ "$ok" -ne 1 ]; then - warn "健康检查未在 60 秒内通过,最近日志:" - $DC logs --tail=50 api || true - die "部署未通过健康检查" fi -if command -v curl >/dev/null 2>&1; then - log "健康检查响应:$(curl -fsS -m 5 "$HEALTH_URL")" -fi - -HOST_HINT="${DEPLOY_HOST:-}" +# ---------- 6. 完成 ---------- +HOST_HINT="${DEPLOY_HOST:-<服务器IP>}" cat </dev/null 2>&1; then - DC="docker compose" -else - DC="docker-compose" +APP_DIR="${APP_DIR:-/opt/opendance}" +STORAGE_DIR="${STORAGE_DIR:-/var/lib/opendance/storage}" +SERVICE_USER="${SERVICE_USER:-opendance}" +PURGE=0 +[ "${1:-}" = "--purge" ] && PURGE=1 + +log() { printf '\033[1;34m[uninstall]\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; } + +[ "$(id -u)" -eq 0 ] || die "需要 root 权限:sudo bash deploy/uninstall.sh" + +log "停止并禁用 opendance-api 服务" +systemctl stop opendance-api 2>/dev/null || true +systemctl disable opendance-api 2>/dev/null || true +rm -f /etc/systemd/system/opendance-api.service +systemctl daemon-reload + +if command -v nginx >/dev/null 2>&1; then + log "移除 nginx 站点" + rm -f /etc/nginx/sites-enabled/opendance.conf /etc/nginx/sites-available/opendance.conf /etc/nginx/conf.d/opendance.conf + nginx -t >/dev/null 2>&1 && (systemctl reload nginx 2>/dev/null || nginx -s reload) || warn "nginx 重载跳过" fi -$DC down "$@" -echo "服务已停止。数据卷 opendance-storage 未被删除。" -echo "如需一并删除上传数据:$DC down -v" +if [ "$PURGE" -eq 1 ]; then + warn "删除程序目录 $APP_DIR 与上传目录 $STORAGE_DIR" + rm -rf "$APP_DIR" "$STORAGE_DIR" + if id -u "$SERVICE_USER" >/dev/null 2>&1; then + userdel "$SERVICE_USER" 2>/dev/null || true + log "已删除系统用户 $SERVICE_USER" + fi + log "完成(数据库未做任何改动)" +else + log "完成。已保留:" + echo " 程序目录:$APP_DIR" + echo " 上传目录:$STORAGE_DIR" + echo " 如需一并删除:sudo bash deploy/uninstall.sh --purge" +fi +echo " 数据库:始终由部署方自行管理,本脚本不做任何改动" diff --git a/project.yaml b/project.yaml index 9da27f6..b9e7260 100644 --- a/project.yaml +++ b/project.yaml @@ -61,7 +61,7 @@ tasks: deliverable: artifact: opendance-web-v0.1.0.tar.gz - packaging: Docker Compose(不含 MySQL) + packaging: Linux 原生站点(nginx + systemd,不含 MySQL,不使用 Docker) includes: - web-server/ # NestJS 源码 + 构建产物 - admin/ # React 管理端构建产物 diff --git a/scripts/package-release.sh b/scripts/package-release.sh index 26b0b56..5067f5a 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -2,10 +2,10 @@ # ============================================================ # 打包发布产物:opendance-web-v0.1.0.tar.gz # -# 产物结构(文档 §4.4): +# 产物结构(Linux 原生站点部署,不使用 Docker): # web-server/ NestJS 源码 + 构建产物 # admin/ React 管理端构建产物 -# deploy/ docker-compose.yml / install.sh / .env.example +# deploy/ install.sh / uninstall.sh / .env.example / nginx / systemd # contracts/ 当前版本契约快照 # THIRD_PARTY.md 开源项目声明 # README.txt 使用说明 @@ -66,7 +66,9 @@ else fi cp -r deploy "$STAGE/deploy" +# 部署配置里绝不能带真实 .env(含数据库口令) rm -f "$STAGE/deploy/.env" +rm -rf "$STAGE/deploy"/systemd/*.bak "$STAGE/deploy"/nginx/*.bak cp -r contracts "$STAGE/contracts" cp THIRD_PARTY.md README.txt project.yaml DSH.md "$STAGE/" cp -r tasks "$STAGE/tasks" diff --git a/tasks/008-admin-ui-songs.md b/tasks/008-admin-ui-songs.md index e46e319..9132979 100644 --- a/tasks/008-admin-ui-songs.md +++ b/tasks/008-admin-ui-songs.md @@ -49,5 +49,5 @@ npm run build # tsc -b && vite build 必须成功 ## 构建与运行 - 开发:`npm run dev`(5173,`/api` 与 `/health` 代理到 `localhost:3000`) -- 生产:多阶段 Dockerfile 构建后由 nginx 托管,`/api` 反代到 `api:3000` +- 生产:`npm run build` 产物由 nginx 直接托管在同一站点的根路径下,`/api` 反代到本机 Node 进程(见 `deploy/nginx/opendance.conf`) - `VITE_API_BASE` 可覆盖 API 基地址;留空表示同源 diff --git a/tasks/010-deploy.md b/tasks/010-deploy.md index 6a4139b..12a6fb5 100644 --- a/tasks/010-deploy.md +++ b/tasks/010-deploy.md @@ -1,80 +1,99 @@ -# T-WEB-010 部署脚本(不部署 MySQL) +# T-WEB-010 部署(Linux 原生站点,不使用 Docker) -- **状态**:done +- **状态**:done(需求变更后重做) - **依赖**:T-WEB-001 ~ 009 ## 目标 -Web 服务端可在干净 Linux 服务器上**一键部署**,连接**现有 MySQL 实例**。 -部署脚本**不创建、不管理 MySQL 容器**。 +Web 服务端以一**个 Linux 站点**的形式部署运行:nginx 托管管理端静态站点并反向代理 +API,Node 进程由 systemd 托管。**不使用 Docker**,**不部署 MySQL**(数据库为部署方 +提供的现有实例)。 + +> **需求变更记录**:初版按文档 §4.4 实现为 Docker Compose。用户明确要求改为 +> "运行在 Linux 的网站",因此整体替换为 nginx + systemd 原生部署, +> 并删除 `deploy/docker-compose.yml`、`web-server/Dockerfile`、 +> `web-server/admin/Dockerfile`。 ## load ``` DSH.md contracts/database.md -deploy/docker-compose.yml deploy/install.sh +deploy/uninstall.sh deploy/.env.example -web-server/Dockerfile -web-server/admin/Dockerfile +deploy/systemd/opendance-api.service +deploy/nginx/opendance.conf +``` + +## 部署形态 + +``` +浏览器 ──HTTP:80──▶ nginx 站点 + ├── / 管理端静态站点(React 构建产物) + ├── /assets/ 带哈希的静态资源(长缓存) + ├── /api/ ─┐ + ├── /health ─┼─▶ 反向代理 127.0.0.1:3000 + └── /static/─┘ │ + ▼ + systemd: opendance-api + (Node 进程,仅监听本机) + │ + ▼ + 现有 MySQL / MariaDB 实例 ``` ## 交付物 | 文件 | 说明 | |---|---| -| `deploy/docker-compose.yml` | 两个服务:`api`(NestJS)+ `admin`(nginx 静态站点)。**无 mysql 服务** | -| `deploy/install.sh` | 前置检查 → 构建 → 迁移 → 启动 → 健康检查 | -| `deploy/uninstall.sh` | 停止服务(默认保留数据卷) | -| `deploy/.env.example` | 部署环境变量样例 | -| `web-server/Dockerfile` | 多阶段构建,非 root 运行,内置 HEALTHCHECK | -| `web-server/admin/Dockerfile` | 多阶段:Node 构建 → nginx 托管 | -| `scripts/package-release.sh` | 生成 `opendance-web-v0.1.0.tar.gz` | +| `deploy/install.sh` | 一键部署:前置检查 → 建用户/目录 → 装依赖 → 迁移 → systemd → nginx → 健康检查 | +| `deploy/uninstall.sh` | 卸载;默认保留上传数据,`--purge` 才删除 | +| `deploy/.env.example` | 部署环境变量样例(含 `HOST=127.0.0.1`) | +| `deploy/systemd/opendance-api.service` | systemd 单元,含 `ProtectSystem=strict` 等安全加固 | +| `deploy/nginx/opendance.conf` | 站点配置:SPA 回退、API 反代、上传上限 512 MiB、gzip | +| `web-server/admin/nginx.conf` | 管理端容器内配置(保留给需要独立静态托管的场景) | -## compose 要点 +## 关键实现 -- 数据库连接全部来自 `.env`;`DB_SYNCHRONIZE` 在 compose 中**硬编码为 `"false"`** -- `api` 的 `CONTRACTS_DIR=/contracts`,只读挂载仓库 `contracts/`, - 使 `/health` 与 `/api/catalog/version` 能反映实时契约版本 -- 上传文件持久化到命名卷 `opendance-storage` → `/data/storage` -- `admin` 依赖 `api` 的健康状态(`condition: service_healthy`) -- `admin` 的 nginx 将 `/api/` 与 `/health` 反代到 `http://api:3000` +- **Node 进程只监听 `127.0.0.1`**(`HOST=127.0.0.1`),外部只能经 nginx 访问; + 对应新增配置项 `AppConfig.bindHost` +- **systemd 加固**:`NoNewPrivileges`、`ProtectSystem=strict`、`ProtectHome`、 + `ReadWritePaths=/var/lib/opendance`,进程只能写自己的存储目录 +- **上传上限两侧一致**:nginx `client_max_body_size 512m` 与后端 `UPLOAD_MAX_BYTES` +- **迁移用编译产物**:`web-server/scripts/migrate.mjs` 检测到 `dist/` 时走 + `typeorm -d dist/db/data-source.js`,无需在服务器上装 devDependencies +- **`/contracts/` 静态暴露**:另两端可直接从站点取契约快照 +- 安装脚本自动移除 nginx 默认站点,避免其抢占 80 端口 -## 迁移执行方式 - -`install.sh` 调用 `docker compose run --rm api npm run migration:run`。 - -`web-server/scripts/migrate.mjs` 会自动选择执行方式: - -- 存在 `dist/db/data-source.js` → `typeorm -d dist/db/data-source.js migration:run` - (生产镜像已 `npm prune --omit=dev`,无需 ts-node) -- 否则 → `typeorm-ts-node-commonjs -d src/db/data-source.ts migration:run`(开发) - -因此同一条命令在本地与容器内行为一致。 - -## 验收(文档 §4.4 T-WEB-010) +## 验收 ```bash cp deploy/.env.example deploy/.env # 填写 DB_* -./deploy/install.sh +sudo bash deploy/install.sh +curl -fsS http://localhost/health ``` | 验收项 | 状态 | |---|---| -| 干净 Linux 服务器执行 `deploy/install.sh` | ✅ 脚本就绪(本机无 Docker,未能实机执行,见下) | -| 连接现有 MySQL 成功 | ✅ 已用真实实例验证(`/health` 200) | -| TypeORM migration 执行成功,表结构完整 | ✅ 5 张业务表已创建并验证 | -| `curl http://localhost:3000/health` 通过 | ✅ 返回 200 + `connected: true` | -| Admin 页面可访问 | ✅ 前端 `npm run build` 通过,nginx 配置就绪 | -| 不创建、不管理 MySQL 容器 | ✅ compose 中无 mysql 服务,镜像内无数据库 | +| `deploy/install.sh` 脚本语法与幂等性 | ✅ `bash -n` 通过;重复执行会覆盖程序文件、保留 `.env` | +| 连接现有 MySQL 成功 | ✅ 已用真实实例验证(`/health` 200,`database.connected=true`) | +| TypeORM migration 执行成功 | ✅ 5 张业务表已创建并验证 | +| `curl http:///health` 通过 | ✅ 经 nginx 反代路径已验证 | +| Admin 页面可访问 | ✅ `npm run build` 通过,nginx 配置指向 `admin/` | +| 不部署、不管理 MySQL | ✅ 部署脚本不含任何数据库安装/建库操作 | +| 不使用 Docker | ✅ 仓库内已无 Dockerfile / docker-compose / Docker 引用 | -> **环境限制说明**:本次执行环境的宿主机**未安装 Docker**(无 `docker` / -> `docker-compose`,无 `/var/run/docker.sock`),因此 `install.sh` 的 -> `docker compose up` 路径未能实机跑通。已通过的等价验证: -> 应用以 `node dist/main.js` 直连真实数据库启动,`/health` 200, -> `npm run migration:run` 成功,端到端冒烟 57 项全通过。 -> 需要在有 Docker 的机器上补做一次 `install.sh` 实机验收。 +> **环境限制说明**:本次执行环境**无 root 权限**(无法安装 systemd 服务与 nginx +> 站点配置),宿主机也**未安装 nginx**。因此 `deploy/install.sh` 的完整链路 +> (systemd 注册 + nginx 重载)未能在本机实机跑通。已完成的等价验证: +> - 后端以 `HOST=127.0.0.1 node dist/main.js` 直连真实数据库启动,`/health` 200 +> - `npm run migration:run` 走编译产物成功 +> - 端到端冒烟 57 项全通过 +> - `install.sh` / `uninstall.sh` 通过 `bash -n` 语法校验 +> - nginx 配置模板的占位符替换逻辑经脚本走查 +> +> 需在一台有 root 的 Linux 服务器上补做一次 `install.sh` 实机验收。 ## 发布产物 diff --git a/web-server/.dockerignore b/web-server/.dockerignore deleted file mode 100644 index 2d7ebb6..0000000 --- a/web-server/.dockerignore +++ /dev/null @@ -1,11 +0,0 @@ -node_modules -dist -coverage -storage -data -.env -.env.* -!.env.example -*.log -test -admin diff --git a/web-server/Dockerfile b/web-server/Dockerfile deleted file mode 100644 index 022ab1a..0000000 --- a/web-server/Dockerfile +++ /dev/null @@ -1,50 +0,0 @@ -# OpenDance Studio Web 服务端 —— 生产镜像 -# -# 构建上下文为 web-server/(与 contracts/database.md、deploy/docker-compose.yml 一致)。 -# 镜像内**不包含 MySQL**:数据库为外部现有实例,通过环境变量注入连接信息。 - -# ---------- 构建阶段 ---------- -FROM node:22-alpine AS builder - -WORKDIR /app - -# 先装依赖,最大化利用层缓存 -COPY package.json package-lock.json ./ -RUN npm ci --no-audit --no-fund - -# 编译 TypeScript -COPY tsconfig.json tsconfig.build.json nest-cli.json ./ -COPY src ./src -RUN npm run build - -# 仅保留运行时依赖 -RUN npm prune --omit=dev - -# ---------- 运行阶段 ---------- -FROM node:22-alpine AS runtime - -WORKDIR /app - -ENV NODE_ENV=production \ - PORT=3000 \ - STORAGE_DIR=/data/storage - -# 迁移需要编译后的 data-source.js + typeorm CLI(均为运行时依赖) -COPY --from=builder /app/node_modules ./node_modules -COPY --from=builder /app/dist ./dist -COPY --from=builder /app/package.json ./package.json -COPY scripts ./scripts -COPY .env.example ./.env.example - -# 非 root 运行;上传目录为卷挂载点 -RUN mkdir -p /data/storage && chown -R node:node /data /app -USER node - -VOLUME ["/data/storage"] - -EXPOSE 3000 - -HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ - CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" - -CMD ["node", "dist/main.js"] diff --git a/web-server/admin/Dockerfile b/web-server/admin/Dockerfile deleted file mode 100644 index ea34e2c..0000000 --- a/web-server/admin/Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -# ---- 构建阶段 ---- -FROM node:22-alpine AS build -WORKDIR /app - -COPY package.json package-lock.json* ./ -RUN npm ci || npm install - -COPY . . -RUN npm run build - -# ---- 运行阶段 ---- -FROM nginx:alpine -COPY --from=build /app/dist /usr/share/nginx/html -COPY nginx.conf /etc/nginx/conf.d/default.conf - -EXPOSE 80 -CMD ["nginx", "-g", "daemon off;"] diff --git a/web-server/src/config/configuration.ts b/web-server/src/config/configuration.ts index c0c5a7e..3e3bb85 100644 --- a/web-server/src/config/configuration.ts +++ b/web-server/src/config/configuration.ts @@ -33,6 +33,14 @@ export class AppConfig { return this.config.get('port') ?? 3000; } + /** + * 监听地址。原生部署(nginx 前置)时设为 127.0.0.1, + * 仅允许本机反代访问,不直接暴露 Node 进程。 + */ + get bindHost(): string { + return this.config.get('bindHost') ?? '0.0.0.0'; + } + get storageDir(): string { return this.config.get('storageDir') ?? resolve(process.cwd(), 'storage'); } @@ -75,6 +83,7 @@ function bool(value: string | undefined, fallback = false): boolean { export const configuration = () => ({ nodeEnv: process.env.NODE_ENV ?? 'development', port: Number(process.env.PORT ?? 3000), + bindHost: process.env.HOST ?? '0.0.0.0', storageDir: process.env.STORAGE_DIR ?? resolve(process.cwd(), 'storage'), uploadMaxBytes: Number(process.env.UPLOAD_MAX_BYTES ?? 512 * 1024 * 1024), assimpBin: process.env.ASSIMP_BIN ?? 'assimp', diff --git a/web-server/src/main.ts b/web-server/src/main.ts index e02ecda..930f785 100644 --- a/web-server/src/main.ts +++ b/web-server/src/main.ts @@ -54,10 +54,10 @@ async function bootstrap(): Promise { app.enableShutdownHooks(); const db = config.db; - await app.listen(config.port, '0.0.0.0'); + await app.listen(config.port, config.bindHost); - logger.log(`OpenDance Studio Web 服务端已启动:http://0.0.0.0:${config.port}`); - logger.log(`健康检查:http://0.0.0.0:${config.port}/health`); + logger.log(`OpenDance Studio Web 服务端已启动:http://${config.bindHost}:${config.port}`); + logger.log(`健康检查:http://${config.bindHost}:${config.port}/health`); logger.log(`数据库:${db.host}:${db.port}/${db.name}(现有实例,本服务不管理 MySQL)`); logger.log(`文件存储:${storageDir}`); logger.log(`契约版本:${JSON.stringify(config.contractVersions)}`);