feat(web): T-WEB-001 NestJS 骨架 + 连接现有 MySQL 实例
This commit is contained in:
@@ -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 {}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 {}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user