feat(web): T-WEB-010 部署脚本(Docker Compose,不含 MySQL)
This commit is contained in:
@@ -0,0 +1,22 @@
|
|||||||
|
# ============================================================
|
||||||
|
# OpenDance Studio Web 服务端 —— 部署环境变量
|
||||||
|
# 复制为 .env 后填写,.env 已被 .gitignore 忽略,禁止提交。
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# ---- 现有 MySQL / MariaDB 实例(本项目不部署、不管理数据库)----
|
||||||
|
DB_HOST=<existing-mysql-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
|
||||||
@@ -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
|
||||||
Executable
+104
@@ -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:-<host>}"
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
============================================================
|
||||||
|
OpenDance Studio Web 服务端已启动
|
||||||
|
API: http://${HOST_HINT}:${API_PORT}
|
||||||
|
Admin: http://${HOST_HINT}:${ADMIN_PORT}
|
||||||
|
健康检查:http://${HOST_HINT}:${API_PORT}/health
|
||||||
|
数据库:使用现有实例 ${DB_HOST}:${DB_PORT:-3306}/${DB_NAME}
|
||||||
|
上传存储:Docker 卷 opendance-storage → /data/storage
|
||||||
|
契约快照:./contracts → 容器内 /contracts(只读)
|
||||||
|
============================================================
|
||||||
|
|
||||||
|
常用命令:
|
||||||
|
$DC logs -f api # 查看 API 日志
|
||||||
|
$DC ps # 查看服务状态
|
||||||
|
$DC down # 停止服务(保留数据卷)
|
||||||
|
EOF
|
||||||
Executable
+14
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 停止服务(默认保留数据卷)
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
if docker compose version >/dev/null 2>&1; then
|
||||||
|
DC="docker compose"
|
||||||
|
else
|
||||||
|
DC="docker-compose"
|
||||||
|
fi
|
||||||
|
|
||||||
|
$DC down "$@"
|
||||||
|
echo "服务已停止。数据卷 opendance-storage 未被删除。"
|
||||||
|
echo "如需一并删除上传数据:$DC down -v"
|
||||||
@@ -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"]
|
||||||
Executable
+71
@@ -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)}`);
|
||||||
@@ -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} 条)`);
|
||||||
Executable
+41
@@ -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 <run|revert|show|generate> [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);
|
||||||
Reference in New Issue
Block a user