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