diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..f8e9f82 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,22 @@ +# ============================================================ +# OpenDance Studio Web 服务端 —— 部署环境变量 +# 复制为 .env 后填写,.env 已被 .gitignore 忽略,禁止提交。 +# ============================================================ + +# ---- 现有 MySQL / MariaDB 实例(本项目不部署、不管理数据库)---- +DB_HOST= +DB_PORT=3306 +DB_USER=opendance_app +DB_PASS=<部署方提供> +DB_NAME=opendance +DB_LOGGING=false + +# ---- 端口映射(宿主机端口)---- +API_PORT=3000 +ADMIN_PORT=8080 + +# ---- 上传与预处理 ---- +# 单文件上限(字节),默认 512 MiB +UPLOAD_MAX_BYTES=536870912 +# 可选:Assimp 可执行文件(FBX/glTF 动作预处理需要) +ASSIMP_BIN=assimp diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..49ddb3c --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,68 @@ +# 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 new file mode 100755 index 0000000..5bee60e --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,104 @@ +#!/bin/bash +# ============================================================ +# OpenDance Studio Web 服务端 —— 一键部署脚本 +# +# 部署内容:API(NestJS)+ 管理端(静态站点) +# 不部署内容:MySQL —— 数据库为部署方提供的现有实例(见 contracts/database.md) +# +# 用法: +# cp .env.example .env && vi .env +# ./install.sh +# ============================================================ +set -euo pipefail + +cd "$(dirname "$0")" + +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 + +# shellcheck disable=SC1091 +set -a; . ./.env; set +a + +[ -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 未设置" + +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" +fi + +command -v docker >/dev/null 2>&1 || die "未找到 docker" + +log "目标数据库:${DB_HOST}:${DB_PORT:-3306}/${DB_NAME}(现有实例,本脚本不会创建或管理 MySQL)" + +# ---------- 2. 构建镜像 ---------- +log "构建镜像(API + Admin)..." +$DC build + +# ---------- 3. 执行数据库迁移 ---------- +# 表结构由 TypeORM migration 创建;DB_SYNCHRONIZE 恒为 false +log "执行 TypeORM 迁移..." +$DC run --rm api npm run migration:run + +# ---------- 4. 启动服务 ---------- +log "启动服务..." +$DC up -d + +# ---------- 5. 健康检查 ---------- +API_PORT="${API_PORT:-3000}" +ADMIN_PORT="${ADMIN_PORT:-8080}" +HEALTH_URL="http://127.0.0.1:${API_PORT}/health" + +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 + 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 + 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:-}" +cat </dev/null 2>&1; then + DC="docker compose" +else + DC="docker-compose" +fi + +$DC down "$@" +echo "服务已停止。数据卷 opendance-storage 未被删除。" +echo "如需一并删除上传数据:$DC down -v" diff --git a/web-server/Dockerfile b/web-server/Dockerfile new file mode 100644 index 0000000..022ab1a --- /dev/null +++ b/web-server/Dockerfile @@ -0,0 +1,50 @@ +# 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/scripts/check-contracts.mjs b/web-server/scripts/check-contracts.mjs new file mode 100755 index 0000000..f9feaea --- /dev/null +++ b/web-server/scripts/check-contracts.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +/** + * 校验代码内的契约版本常量与 contracts/VERSION 一致。 + * 在构建前执行:不一致则退出码 1,阻止契约漂移。 + * + * 用法:node scripts/check-contracts.mjs + */ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const versionFile = join(here, '..', '..', 'contracts', 'VERSION'); +const constantFile = join(here, '..', 'src', 'config', 'contract-versions.ts'); + +function parseVersionFile(content) { + const out = {}; + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const idx = line.indexOf(':'); + if (idx <= 0) continue; + out[line.slice(0, idx).trim()] = line.slice(idx + 1).trim(); + } + return out; +} + +function parseConstants(content) { + const block = /CONTRACT_VERSIONS[^=]*=\s*Object\.freeze\(\{([\s\S]*?)\}\)/.exec(content); + if (!block) throw new Error('无法在 contract-versions.ts 中定位 CONTRACT_VERSIONS'); + const out = {}; + // 键可能是带引号的 'rest-api',也可能是不带引号的 database + for (const match of block[1].matchAll(/['"]?([\w-]+)['"]?\s*:\s*['"]([^'"]+)['"]/g)) { + out[match[1]] = match[2]; + } + return out; +} + +let fileVersions; +let codeVersions; +try { + fileVersions = parseVersionFile(readFileSync(versionFile, 'utf8')); +} catch (error) { + console.error(`✗ 无法读取 ${versionFile}:${error.message}`); + process.exit(1); +} +try { + codeVersions = parseConstants(readFileSync(constantFile, 'utf8')); +} catch (error) { + console.error(`✗ 无法解析 ${constantFile}:${error.message}`); + process.exit(1); +} + +const keys = new Set([...Object.keys(fileVersions), ...Object.keys(codeVersions)]); +const problems = []; +for (const key of [...keys].sort()) { + const a = fileVersions[key]; + const b = codeVersions[key]; + if (a === undefined) problems.push(` ${key}: 仅存在于代码常量(${b})`); + else if (b === undefined) problems.push(` ${key}: 仅存在于 contracts/VERSION(${a})`); + else if (a !== b) problems.push(` ${key}: contracts/VERSION=${a} vs 代码常量=${b}`); +} + +if (problems.length > 0) { + console.error('✗ 契约版本不一致:'); + console.error(problems.join('\n')); + console.error('\n请同步 contracts/VERSION 与 web-server/src/config/contract-versions.ts'); + process.exit(1); +} + +console.log(`✓ 契约版本一致(${Object.keys(fileVersions).length} 项):${JSON.stringify(fileVersions)}`); diff --git a/web-server/scripts/check-routes.mjs b/web-server/scripts/check-routes.mjs new file mode 100644 index 0000000..7a37954 --- /dev/null +++ b/web-server/scripts/check-routes.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node +/** + * 校验 contracts/rest-api.yaml 中声明的路由与 NestJS 控制器实际实现的路由一致。 + * + * 双向检查: + * - 契约中声明但未实现 → 失败(契约不可落空) + * - 已实现但契约未声明 → 失败(实现不可绕过契约) + * + * 用法:node scripts/check-routes.mjs + */ +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = join(here, '..'); +const specPath = join(root, '..', 'contracts', 'rest-api.yaml'); + +const METHODS = new Set(['get', 'post', 'patch', 'put', 'delete']); + +/** 从 OpenAPI YAML 中提取 paths 下的路由(不引入 YAML 依赖,按缩进解析) */ +function parseSpecRoutes(yaml) { + const lines = yaml.split(/\r?\n/); + const routes = new Set(); + let inPaths = false; + let currentPath = null; + + for (const line of lines) { + if (/^paths:\s*$/.test(line)) { + inPaths = true; + continue; + } + if (inPaths && /^[A-Za-z]/.test(line)) { + inPaths = false; // 离开 paths 段(进入 components 等) + currentPath = null; + continue; + } + if (!inPaths) continue; + + const pathMatch = /^ {2}(\/\S*):\s*$/.exec(line); + if (pathMatch) { + currentPath = pathMatch[1].replace(/\{([^}]+)\}/g, ':$1'); + continue; + } + const methodMatch = /^ {4}([a-z]+):\s*/.exec(line); + if (methodMatch && currentPath && METHODS.has(methodMatch[1])) { + routes.add(`${methodMatch[1].toUpperCase()} ${currentPath}`); + } + } + return routes; +} + +/** 递归查找 *.controller.ts(避免依赖 fs.globSync 的 Node 版本差异) */ +function findControllers(dir) { + const out = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) out.push(...findControllers(full)); + else if (entry.isFile() && entry.name.endsWith('.controller.ts')) out.push(full); + } + return out; +} + +/** 从 NestJS 控制器提取路由 */ +function parseControllerRoutes(dir) { + const routes = new Set(); + + for (const file of findControllers(dir)) { + const source = readFileSync(file, 'utf8'); + const controllerMatch = /@Controller\(\s*['"]([^'"]*)['"]/.exec(source); + const prefix = controllerMatch ? `/${controllerMatch[1].replace(/^\/|\/$/g, '')}` : ''; + + const decorator = /@(Get|Post|Patch|Put|Delete)\(\s*(?:['"]([^'"]*)['"])?[^)]*\)/g; + let match; + while ((match = decorator.exec(source)) !== null) { + const method = match[1].toUpperCase(); + const sub = (match[2] ?? '').replace(/^\/|\/$/g, ''); + let full = sub ? `${prefix}/${sub}` : prefix; + if (!full) full = '/'; + // Nest 路由参数是 :id,契约是 {id},统一成 :name + full = full.replace(/\{([^}]+)\}/g, ':$1').replace(/\/+$/, '') || '/'; + routes.add(`${method} ${full}`); + } + } + return routes; +} + +const specRoutes = parseSpecRoutes(readFileSync(specPath, 'utf8')); +const implRoutes = parseControllerRoutes(join(root, 'src')); + +const missing = [...specRoutes].filter((r) => !implRoutes.has(r)).sort(); +const extra = [...implRoutes].filter((r) => !specRoutes.has(r)).sort(); + +const problems = []; +if (missing.length) problems.push(`契约中声明但未实现:\n${missing.map((r) => ` - ${r}`).join('\n')}`); +if (extra.length) problems.push(`已实现但契约未声明:\n${extra.map((r) => ` + ${r}`).join('\n')}`); + +if (problems.length) { + console.error(`✗ 路由与契约不一致(契约 ${specRoutes.size} 条 / 实现 ${implRoutes.size} 条)`); + console.error(problems.join('\n\n')); + console.error('\n请同步 contracts/rest-api.yaml 与 web-server/src/**/*.controller.ts'); + process.exit(1); +} + +console.log(`✓ 路由与契约一致(共 ${specRoutes.size} 条)`); diff --git a/web-server/scripts/migrate.mjs b/web-server/scripts/migrate.mjs new file mode 100755 index 0000000..e21ad43 --- /dev/null +++ b/web-server/scripts/migrate.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node +/** + * 跨环境 TypeORM migration 入口。 + * + * - 生产(存在 dist/db/data-source.js):使用 typeorm CLI + 编译产物,无需 ts-node + * - 开发(无 dist):使用 typeorm-ts-node-commonjs + src/*.ts + * + * 这样 `npm run migration:run` 在本地与 Docker 容器内行为一致。 + * + * 用法: + * node scripts/migrate.mjs run|revert|show|generate [额外参数] + */ +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +const command = process.argv[2]; +const extra = process.argv.slice(3); + +if (!command) { + console.error('用法:node scripts/migrate.mjs [args]'); + process.exit(2); +} + +const binDir = join(process.cwd(), 'node_modules', '.bin'); +const suffix = process.platform === 'win32' ? '.cmd' : ''; +const hasDist = existsSync(join(process.cwd(), 'dist', 'db', 'data-source.js')); + +const bin = hasDist ? `typeorm${suffix}` : `typeorm-ts-node-commonjs${suffix}`; +const dataSource = hasDist ? 'dist/db/data-source.js' : 'src/db/data-source.ts'; + +if (!hasDist && command !== 'generate' && !existsSync(join(process.cwd(), 'node_modules', 'ts-node'))) { + console.error('未找到编译产物 dist/,且未安装 ts-node。请先 npm run build。'); + process.exit(1); +} + +const args = [join(binDir, bin), '-d', dataSource, `migration:${command}`, ...extra]; +console.log(`[migrate] ${bin} -d ${dataSource} migration:${command} ${extra.join(' ')}`.trim()); + +const result = spawnSync(args[0], args.slice(1), { stdio: 'inherit' }); +process.exit(result.status ?? 1);