feat(web): T-WEB-001 NestJS 骨架 + 连接现有 MySQL 实例

This commit is contained in:
2026-09-14 19:00:55 +08:00
parent 9b8fc6be96
commit bd6cb9fa90
18 changed files with 7763 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
node_modules
dist
coverage
storage
data
.env
.env.*
!.env.example
*.log
test
admin
+34
View File
@@ -0,0 +1,34 @@
# ============================================================
# OpenDance Studio Web 服务端 —— 环境变量样例
# 复制为 .env 后填写。.env 已被 .gitignore 忽略,禁止提交。
# ============================================================
# ---- 服务 ----
NODE_ENV=production
PORT=3000
# ---- 现有 MySQL 实例(外部提供,本项目不部署 MySQL)----
# 当前部署:MariaDB 10.11 @ 192.168.150.10
DB_HOST=192.168.150.10
DB_PORT=3306
DB_USER=dpendance
DB_PASS=<部署方提供>
DB_NAME=dpendance
# 生产环境必须为 false,表结构一律走 TypeORM migration
DB_SYNCHRONIZE=false
DB_LOGGING=false
# ---- 文件存储 ----
# 容器内路径,docker-compose 会挂载为卷
STORAGE_DIR=/data/storage
# 单文件上传上限(字节),默认 512 MiB
UPLOAD_MAX_BYTES=536870912
# ---- 可选的 Assimp(FBX/glTF 动作预处理)----
# 未安装时 FBX 动作的预处理会以失败状态返回,不影响 BVH
ASSIMP_BIN=assimp
# ---- 契约目录(可选)----
# 设置后 /health 与 /api/catalog/version 会读取该目录下的 VERSION 文件;
# 未设置时使用编译进代码的契约版本常量
# CONTRACTS_DIR=/contracts
+23
View File
@@ -0,0 +1,23 @@
node_modules/
dist/
build/
coverage/
*.log
npm-debug.log*
# 环境变量:仅提交 .env.example
.env
.env.*
!.env.example
# 上传的文件存储(本地开发)
# 注意:必须锚定到本目录,否则会连 src/storage/ 源码目录一起忽略
/storage/
/data/
/tmp/
# 编辑器
.idea/
.vscode/
*.swp
.DS_Store
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"tsConfigPath": "tsconfig.build.json"
}
}
+7026
View File
File diff suppressed because it is too large Load Diff
+53
View File
@@ -0,0 +1,53 @@
{
"name": "opendance-web-server",
"version": "0.1.0",
"description": "OpenDance Studio Web 管理服务端(NestJS + TypeORM + 现有 MySQL 实例)",
"license": "MIT",
"private": true,
"engines": {
"node": ">=22.0.0"
},
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:prod": "node dist/main.js",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:smoke": "node test/smoke.mjs",
"contracts:check": "node scripts/check-contracts.mjs && node scripts/check-routes.mjs",
"verify": "npm run contracts:check && npm run typecheck && npm run test && npm run build",
"typeorm": "node scripts/migrate.mjs",
"migration:generate": "node scripts/migrate.mjs generate",
"migration:run": "node scripts/migrate.mjs run",
"migration:revert": "node scripts/migrate.mjs revert",
"migration:show": "node scripts/migrate.mjs show"
},
"dependencies": {
"@nestjs/common": "^11.0.12",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.12",
"@nestjs/platform-express": "^11.0.12",
"@nestjs/typeorm": "^11.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",
"mysql2": "^3.12.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"typeorm": "^0.3.21"
},
"devDependencies": {
"@nestjs/cli": "^11.0.5",
"@nestjs/schematics": "^11.0.2",
"@types/express": "^5.0.0",
"@types/multer": "^1.4.12",
"@types/node": "^22.13.10",
"source-map-support": "^0.5.21",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"vitest": "^3.0.8"
}
}
+50
View File
@@ -0,0 +1,50 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CatalogModule } from './catalog/catalog.module';
import { AppConfigModule } from './config/config.module';
import { AppConfig } from './config/configuration';
import { dataSourceOptions } from './db/data-source';
import { HealthModule } from './health/health.module';
import { ModelsModule } from './models/models.module';
import { MotionsModule } from './motions/motions.module';
import { PreprocessModule } from './preprocess/preprocess.module';
import { ReviewsModule } from './reviews/reviews.module';
import { SongsModule } from './songs/songs.module';
import { StorageModule } from './storage/storage.module';
@Module({
imports: [
AppConfigModule,
/**
* 连接「现有 MySQL 实例」——本项目不创建、不管理 MySQL。
* 契约 contracts/database.md:synchronize 恒为 false,表结构走 migration。
*/
TypeOrmModule.forRootAsync({
inject: [AppConfig],
useFactory: (config: AppConfig) => {
const db = config.db;
return {
...dataSourceOptions,
host: db.host,
port: db.port,
username: db.user,
password: db.pass,
database: db.name,
synchronize: false,
logging: db.logging,
};
},
}),
StorageModule,
HealthModule,
SongsModule,
MotionsModule,
ModelsModule,
ReviewsModule,
CatalogModule,
PreprocessModule,
],
})
export class AppModule {}
+119
View File
@@ -0,0 +1,119 @@
/**
* 跨模块共享枚举。
* 与 contracts/data-models.md 中的枚举**逐字对应**,改动必须同步契约版本。
*/
export enum SourceType {
/** 原创 */
Original = 'original',
/** 已获授权 */
Licensed = 'licensed',
/** Creative Commons */
Cc = 'cc',
/** 公共领域 */
PublicDomain = 'public_domain',
/** 用户上传(需审核) */
UserUpload = 'user_upload',
/** 其他(须在 sourceName 说明) */
Other = 'other',
}
export enum PublishStatus {
Draft = 'draft',
Pending = 'pending',
Published = 'published',
Rejected = 'rejected',
}
export enum MotionFormat {
Bvh = 'bvh',
Fbx = 'fbx',
Gltf = 'gltf',
Glb = 'glb',
KeypointsJson = 'keypoints-json',
}
export enum ModelFormat {
Vrm = 'vrm',
Glb = 'glb',
Gltf = 'gltf',
Fbx = 'fbx',
}
export enum PreprocessState {
None = 'none',
Running = 'running',
Done = 'done',
Failed = 'failed',
}
export enum ReviewAction {
Submit = 'submit',
Approve = 'approve',
Reject = 'reject',
Withdraw = 'withdraw',
}
export enum TargetType {
Song = 'song',
Motion = 'motion',
Model = 'model',
}
export enum AssetKind {
SongAudio = 'song-audio',
SongCover = 'song-cover',
Motion = 'motion',
Model = 'model',
Thumbnail = 'thumbnail',
Keypoints = 'keypoints',
}
export const SOURCE_TYPE_VALUES = Object.values(SourceType) as string[];
export const PUBLISH_STATUS_VALUES = Object.values(PublishStatus) as string[];
export const MOTION_FORMAT_VALUES = Object.values(MotionFormat) as string[];
export const MODEL_FORMAT_VALUES = Object.values(ModelFormat) as string[];
export const TARGET_TYPE_VALUES = Object.values(TargetType) as string[];
/** 允许上传的扩展名 → 动作格式 */
export const MOTION_EXTENSION_MAP: Record<string, MotionFormat> = {
'.bvh': MotionFormat.Bvh,
'.fbx': MotionFormat.Fbx,
'.gltf': MotionFormat.Gltf,
'.glb': MotionFormat.Glb,
'.json': MotionFormat.KeypointsJson,
};
/** 允许上传的扩展名 → 模型格式 */
export const MODEL_EXTENSION_MAP: Record<string, ModelFormat> = {
'.vrm': ModelFormat.Vrm,
'.glb': ModelFormat.Glb,
'.gltf': ModelFormat.Gltf,
'.fbx': ModelFormat.Fbx,
};
export const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.ogg', '.m4a', '.aac', '.flac'];
export const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp'];
/**
* 状态机:契约 database.md §状态机。
* key = 当前状态,value = 允许流转到的状态集合。
*/
export const STATUS_TRANSITIONS: Record<PublishStatus, PublishStatus[]> = {
[PublishStatus.Draft]: [PublishStatus.Pending, PublishStatus.Published],
[PublishStatus.Pending]: [PublishStatus.Published, PublishStatus.Rejected, PublishStatus.Draft],
[PublishStatus.Published]: [PublishStatus.Draft, PublishStatus.Rejected],
[PublishStatus.Rejected]: [PublishStatus.Pending, PublishStatus.Draft],
};
export function canTransition(from: PublishStatus, to: PublishStatus): boolean {
return (STATUS_TRANSITIONS[from] ?? []).includes(to);
}
/** 审核动作 → 目标状态 */
export const ACTION_TARGET_STATUS: Record<ReviewAction, PublishStatus> = {
[ReviewAction.Submit]: PublishStatus.Pending,
[ReviewAction.Approve]: PublishStatus.Published,
[ReviewAction.Reject]: PublishStatus.Rejected,
[ReviewAction.Withdraw]: PublishStatus.Draft,
};
+21
View File
@@ -0,0 +1,21 @@
import { Global, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppConfig, configuration } from './configuration';
/**
* 全局配置模块:AppConfig 在所有模块中可直接注入。
*/
@Global()
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
envFilePath: ['.env.local', '.env'],
cache: true,
}),
],
providers: [AppConfig],
exports: [AppConfig],
})
export class AppConfigModule {}
+108
View File
@@ -0,0 +1,108 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { resolve } from 'node:path';
import { resolveContractVersions } from './contract-versions';
export interface DbConfig {
host: string;
port: number;
user: string;
pass: string;
name: string;
synchronize: boolean;
logging: boolean;
}
/**
* 类型化的配置访问层。
* 所有环境变量只在此处读取,业务代码不直接触碰 process.env。
*/
@Injectable()
export class AppConfig {
constructor(private readonly config: ConfigService) {}
get nodeEnv(): string {
return this.config.get<string>('nodeEnv') ?? 'development';
}
get isProduction(): boolean {
return this.nodeEnv === 'production';
}
get port(): number {
return this.config.get<number>('port') ?? 3000;
}
get storageDir(): string {
return this.config.get<string>('storageDir') ?? resolve(process.cwd(), 'storage');
}
get uploadMaxBytes(): number {
return this.config.get<number>('uploadMaxBytes') ?? 512 * 1024 * 1024;
}
get assimpBin(): string {
return this.config.get<string>('assimpBin') ?? 'assimp';
}
get contractsDir(): string | undefined {
return this.config.get<string>('contractsDir') || undefined;
}
get contractVersions(): Record<string, string> {
return resolveContractVersions(this.contractsDir);
}
get db(): DbConfig {
return {
host: this.config.get<string>('db.host') ?? '127.0.0.1',
port: this.config.get<number>('db.port') ?? 3306,
user: this.config.get<string>('db.user') ?? 'opendance_app',
pass: this.config.get<string>('db.pass') ?? '',
name: this.config.get<string>('db.name') ?? 'opendance',
synchronize: this.config.get<boolean>('db.synchronize') ?? false,
logging: this.config.get<boolean>('db.logging') ?? false,
};
}
}
function bool(value: string | undefined, fallback = false): boolean {
if (value === undefined || value === '') return fallback;
return value === 'true' || value === '1';
}
/** @nestjs/config 的 load 工厂 */
export const configuration = () => ({
nodeEnv: process.env.NODE_ENV ?? 'development',
port: Number(process.env.PORT ?? 3000),
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',
contractsDir: process.env.CONTRACTS_DIR ?? '',
db: {
host: process.env.DB_HOST ?? '127.0.0.1',
port: Number(process.env.DB_PORT ?? 3306),
user: process.env.DB_USER ?? 'opendance_app',
pass: process.env.DB_PASS ?? '',
name: process.env.DB_NAME ?? 'opendance',
synchronize: bool(process.env.DB_SYNCHRONIZE, false),
logging: bool(process.env.DB_LOGGING, false),
},
});
/**
* 生产环境下禁止 DB_SYNCHRONIZE=true —— 契约 database.md 明确要求。
* 返回错误信息数组,供 main.ts 启动时快速失败。
*/
export function validateEnv(env: Record<string, unknown>): string[] {
const problems: string[] = [];
const db = (env.db ?? {}) as Record<string, unknown>;
if (!db.host) problems.push('DB_HOST 未设置');
if (!db.user) problems.push('DB_USER 未设置');
if (!db.name) problems.push('DB_NAME 未设置');
if (env.nodeEnv === 'production' && db.synchronize === true) {
problems.push('生产环境禁止 DB_SYNCHRONIZE=true,请使用 TypeORM migration');
}
return problems;
}
@@ -0,0 +1,47 @@
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
/**
* 编译进代码的契约版本常量。
* 与 `contracts/VERSION` 必须一致;`npm run contracts:check` 在构建前校验。
* 之所以编译进代码:Docker 构建上下文是 web-server/,contracts/ 不在其中。
*/
export const CONTRACT_VERSIONS: Readonly<Record<string, string>> = Object.freeze({
'rest-api': '0.1.0',
'websocket-keypoints': '0.1.0',
'data-models': '0.1.0',
'skeleton-topology': '0.1.0',
'model-formats': '0.1.0',
database: '0.1.0',
});
/** 解析 `contracts/VERSION`(`key: value` 每行一条,`#` 起注释) */
export function parseVersionFile(content: string): Record<string, string> {
const out: Record<string, string> = {};
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;
const key = line.slice(0, idx).trim();
const value = line.slice(idx + 1).trim();
if (key && value) out[key] = value;
}
return out;
}
/**
* 运行期契约版本。设置 `CONTRACTS_DIR` 时读取实时文件(部署可挂载 contracts/),
* 否则回落到编译常量。
*/
export function resolveContractVersions(contractsDir?: string): Record<string, string> {
if (!contractsDir) return { ...CONTRACT_VERSIONS };
const file = join(contractsDir, 'VERSION');
if (!existsSync(file)) return { ...CONTRACT_VERSIONS };
try {
const parsed = parseVersionFile(readFileSync(file, 'utf8'));
return Object.keys(parsed).length > 0 ? parsed : { ...CONTRACT_VERSIONS };
} catch {
return { ...CONTRACT_VERSIONS };
}
}
+57
View File
@@ -0,0 +1,57 @@
import 'reflect-metadata';
import { config as loadEnv } from 'dotenv';
import { join } from 'node:path';
import { DataSource } from 'typeorm';
import { MysqlConnectionOptions } from 'typeorm/driver/mysql/MysqlConnectionOptions';
import { ENTITIES } from '../entities';
// CLI(typeorm-ts-node-commonjs)与 Nest 运行期都从这里读环境变量
loadEnv();
function bool(value: string | undefined, fallback = false): boolean {
if (value === undefined || value === '') return fallback;
return value === 'true' || value === '1';
}
/**
* 连接「现有 MySQL 实例」——本项目不部署、不管理 MySQL。
* 契约 contracts/database.md。
*
* 返回 MySQL 驱动选项的具体类型(而非 DataSourceOptions 联合类型),
* 以便 AppModule 中安全地做字段覆盖。
*/
export function buildDataSourceOptions(env: NodeJS.ProcessEnv = process.env): MysqlConnectionOptions {
return {
type: 'mysql',
host: env.DB_HOST ?? '127.0.0.1',
port: Number(env.DB_PORT ?? 3306),
username: env.DB_USER ?? 'opendance_app',
password: env.DB_PASS ?? '',
database: env.DB_NAME ?? 'opendance',
// 契约要求:生产环境禁止 synchronize,表结构一律走 migration
synchronize: false,
logging: bool(env.DB_LOGGING, false),
entities: ENTITIES,
migrations: [join(__dirname, '..', 'migrations', '*.{ts,js}')],
migrationsTableName: 'typeorm_migrations',
charset: 'utf8mb4',
timezone: 'Z',
// 让 BIGINT 在可精确表示时返回 number(契约中 id 为 number)
supportBigNumbers: true,
bigNumberStrings: false,
extra: {
connectionLimit: Number(env.DB_POOL_SIZE ?? 10),
decimalNumbers: true,
},
};
}
export const dataSourceOptions: MysqlConnectionOptions = buildDataSourceOptions();
/**
* 注意:本文件必须只有 **一个** DataSource 实例导出(default),
* 否则 typeorm CLI 会报 "must contain only one export of DataSource instance"。
*/
const AppDataSource = new DataSource(dataSourceOptions);
export default AppDataSource;
@@ -0,0 +1,86 @@
import { Controller, Get, Res, ServiceUnavailableException } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { Response } from 'express';
import { DataSource } from 'typeorm';
import { AppConfig } from '../config/configuration';
import { detectAssimp } from '../preprocess/assimp';
export interface HealthReport {
status: 'ok' | 'degraded';
uptimeSec: number;
version: string;
contractVersion: Record<string, string>;
database: {
connected: boolean;
server: string | null;
database: string | null;
migrationsPending: number | null;
error?: string;
};
storage: {
dir: string;
files: number;
totalBytes: number;
};
assimp: {
available: boolean;
version?: string;
error?: string;
};
}
@Controller('health')
export class HealthController {
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly config: AppConfig,
) {}
/**
* 健康检查:必须真实探测数据库连通性(T-WEB-010 验收要求
* `curl http://localhost:3000/health` 通过)。
*/
@Get()
async check(@Res({ passthrough: true }) res: Response): Promise<HealthReport> {
const database: HealthReport['database'] = {
connected: false,
server: null,
database: null,
migrationsPending: null,
};
try {
const rows = await this.dataSource.query<Array<{ version: string; db: string }>>(
'SELECT VERSION() AS version, DATABASE() AS db',
);
database.connected = true;
database.server = rows[0]?.version ?? null;
database.database = rows[0]?.db ?? null;
const pending = await this.dataSource.showMigrations();
database.migrationsPending = pending ? 1 : 0;
} catch (error) {
database.error = error instanceof Error ? error.message : String(error);
}
const assimp = await detectAssimp(this.config.assimpBin);
const report: HealthReport = {
status: database.connected ? 'ok' : 'degraded',
uptimeSec: Math.round(process.uptime() * 1000) / 1000,
version: process.env.npm_package_version ?? '0.1.0',
contractVersion: this.config.contractVersions,
database,
storage: {
dir: this.config.storageDir,
files: 0,
totalBytes: 0,
},
assimp,
};
if (!database.connected) {
res.status(503);
}
return report;
}
}
+7
View File
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}
+70
View File
@@ -0,0 +1,70 @@
import { Logger, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { existsSync, mkdirSync } from 'node:fs';
import { resolve } from 'node:path';
import { AppModule } from './app.module';
import { AppConfig, configuration, validateEnv } from './config/configuration';
async function bootstrap(): Promise<void> {
const logger = new Logger('Bootstrap');
// 启动前校验环境变量(生产环境禁止 DB_SYNCHRONIZE=true 等)
const problems = validateEnv(configuration());
if (problems.length > 0) {
for (const problem of problems) logger.warn(`环境变量问题:${problem}`);
if ((process.env.NODE_ENV ?? 'development') === 'production') {
logger.error('生产环境配置校验失败,启动中止');
process.exit(1);
}
}
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
logger: ['error', 'warn', 'log'],
});
const config = app.get(AppConfig);
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: false,
transformOptions: { enableImplicitConversion: false },
}),
);
// Windows 客户端与管理端跨源访问
app.enableCors({
origin: true,
credentials: false,
exposedHeaders: ['Content-Disposition'],
});
// 上传文件静态访问:/static/<kind>/<yyyy>/<mm>/<sha256>.<ext>
const storageDir = resolve(config.storageDir);
if (!existsSync(storageDir)) mkdirSync(storageDir, { recursive: true });
app.useStaticAssets(storageDir, { prefix: '/static/', index: false, fallthrough: true });
// 契约快照静态访问(若存在):/contracts/rest-api.yaml 等
if (config.contractsDir && existsSync(config.contractsDir)) {
app.useStaticAssets(resolve(config.contractsDir), { prefix: '/contracts/', index: false });
}
app.enableShutdownHooks();
const db = config.db;
await app.listen(config.port, '0.0.0.0');
logger.log(`OpenDance Studio Web 服务端已启动:http://0.0.0.0:${config.port}`);
logger.log(`健康检查:http://0.0.0.0:${config.port}/health`);
logger.log(`数据库:${db.host}:${db.port}/${db.name}(现有实例,本服务不管理 MySQL)`);
logger.log(`文件存储:${storageDir}`);
logger.log(`契约版本:${JSON.stringify(config.contractVersions)}`);
}
void bootstrap().catch((error) => {
// eslint-disable-next-line no-console
console.error('服务启动失败:', error);
process.exit(1);
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*.spec.ts", "vitest.config.ts", "admin"]
}
+29
View File
@@ -0,0 +1,29 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": false,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2022",
"lib": ["ES2023"],
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"strict": true,
"strictNullChecks": true,
"strictPropertyInitialization": false,
"noImplicitAny": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"useDefineForClassFields": false
},
"include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"],
"exclude": ["node_modules", "dist", "admin"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.spec.ts', 'test/**/*.spec.ts'],
environment: 'node',
globals: false,
},
});