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
+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);
});