init
This commit is contained in:
29
src/app.module.ts
Normal file
29
src/app.module.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AppConfigModule } from './config/config.module';
|
||||
import { WechatGameModule } from './modules/wechat-game/wechat-game.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AppConfigModule,
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
type: 'mysql',
|
||||
host: configService.get<string>('database.host'),
|
||||
port: configService.get<number>('database.port'),
|
||||
username: configService.get<string>('database.username'),
|
||||
password: configService.get<string>('database.password'),
|
||||
database: configService.get<string>('database.database'),
|
||||
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
||||
synchronize: configService.get<string>('NODE_ENV') !== 'production',
|
||||
logging: configService.get<string>('NODE_ENV') !== 'production',
|
||||
autoLoadEntities: true,
|
||||
}),
|
||||
}),
|
||||
WechatGameModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
30
src/common/dto/api-response.dto.ts
Normal file
30
src/common/dto/api-response.dto.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ApiResponseDto<T> {
|
||||
@ApiProperty({ description: '请求是否成功' })
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({ description: '响应数据', nullable: true })
|
||||
data: T | null;
|
||||
|
||||
@ApiProperty({ description: '错误信息', nullable: true })
|
||||
message: string | null;
|
||||
|
||||
@ApiProperty({ description: '响应时间戳' })
|
||||
timestamp: Date;
|
||||
|
||||
constructor(success: boolean, data: T | null, message: string | null = null) {
|
||||
this.success = success;
|
||||
this.data = data;
|
||||
this.message = message;
|
||||
this.timestamp = new Date();
|
||||
}
|
||||
|
||||
static success<T>(data: T): ApiResponseDto<T> {
|
||||
return new ApiResponseDto(true, data, null);
|
||||
}
|
||||
|
||||
static error<T>(message: string): ApiResponseDto<T | null> {
|
||||
return new ApiResponseDto<T | null>(false, null, message);
|
||||
}
|
||||
}
|
||||
49
src/common/filters/http-exception.filter.ts
Normal file
49
src/common/filters/http-exception.filter.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiResponseDto } from '../dto/api-response.dto';
|
||||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let message = 'Internal server error';
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
if (typeof exceptionResponse === 'string') {
|
||||
message = exceptionResponse;
|
||||
} else if (typeof exceptionResponse === 'object') {
|
||||
const responseObj = exceptionResponse as Record<string, unknown>;
|
||||
message = (responseObj.message as string) || exception.message;
|
||||
}
|
||||
} else if (exception instanceof Error) {
|
||||
message = exception.message;
|
||||
this.logger.error(
|
||||
`Internal error: ${exception.message}`,
|
||||
exception.stack,
|
||||
);
|
||||
}
|
||||
|
||||
const errorResponse = ApiResponseDto.error(message);
|
||||
|
||||
response.status(status).json({
|
||||
...errorResponse,
|
||||
path: request.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
18
src/config/config.module.ts
Normal file
18
src/config/config.module.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { databaseConfig } from './database.config';
|
||||
import { validateEnvironment } from './env.validation';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [databaseConfig],
|
||||
validate: validateEnvironment,
|
||||
envFilePath: ['.env.local', '.env'],
|
||||
}),
|
||||
],
|
||||
exports: [ConfigModule],
|
||||
})
|
||||
export class AppConfigModule {}
|
||||
34
src/config/database.config.ts
Normal file
34
src/config/database.config.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||
import { DataSource, DataSourceOptions } from 'typeorm';
|
||||
|
||||
export const databaseConfig = registerAs(
|
||||
'database',
|
||||
(): TypeOrmModuleOptions => ({
|
||||
type: 'mysql',
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '3306', 10),
|
||||
username: process.env.DB_USERNAME || 'meme_user',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_DATABASE || 'meme_mind',
|
||||
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
|
||||
synchronize: process.env.NODE_ENV !== 'production',
|
||||
logging: process.env.NODE_ENV !== 'production',
|
||||
autoLoadEntities: true,
|
||||
}),
|
||||
);
|
||||
|
||||
export const dataSourceOptions: DataSourceOptions = {
|
||||
type: 'mysql',
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '3306', 10),
|
||||
username: process.env.DB_USERNAME || 'meme_user',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_DATABASE || 'meme_mind',
|
||||
entities: ['dist/**/*.entity{.ts,.js}'],
|
||||
synchronize: false,
|
||||
logging: true,
|
||||
};
|
||||
|
||||
const dataSource = new DataSource(dataSourceOptions);
|
||||
export default dataSource;
|
||||
56
src/config/env.validation.ts
Normal file
56
src/config/env.validation.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import {
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsString,
|
||||
validateSync,
|
||||
} from 'class-validator';
|
||||
|
||||
enum Environment {
|
||||
Development = 'development',
|
||||
Production = 'production',
|
||||
Test = 'test',
|
||||
}
|
||||
|
||||
class EnvironmentVariables {
|
||||
@IsEnum(Environment)
|
||||
NODE_ENV: Environment = Environment.Development;
|
||||
|
||||
@IsNumber()
|
||||
PORT: number = 3000;
|
||||
|
||||
@IsString()
|
||||
DB_HOST: string = 'localhost';
|
||||
|
||||
@IsNumber()
|
||||
DB_PORT: number = 3306;
|
||||
|
||||
@IsString()
|
||||
DB_USERNAME: string = 'meme_user';
|
||||
|
||||
@IsString()
|
||||
DB_PASSWORD: string = '';
|
||||
|
||||
@IsString()
|
||||
DB_DATABASE: string = 'meme_mind';
|
||||
}
|
||||
|
||||
export function validateEnvironment(
|
||||
config: Record<string, unknown>,
|
||||
): EnvironmentVariables {
|
||||
const validatedConfig = plainToInstance(EnvironmentVariables, config, {
|
||||
enableImplicitConversion: true,
|
||||
});
|
||||
|
||||
const errors = validateSync(validatedConfig, {
|
||||
skipMissingProperties: false,
|
||||
});
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(
|
||||
`Environment validation failed: ${errors.map((e) => e.toString()).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return validatedConfig;
|
||||
}
|
||||
45
src/main.ts
Normal file
45
src/main.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// 设置全局前缀
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
// 启用 CORS (支持微信小游戏)
|
||||
app.enableCors({
|
||||
origin: true,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// 全局验证管道
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// 全局异常过滤器
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
|
||||
// Swagger 文档配置
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('MemeMind Server API')
|
||||
.setDescription('微信小游戏 MemeMind 服务端 API 文档')
|
||||
.setVersion('1.0')
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
|
||||
const port = process.env.PORT ?? 3000;
|
||||
await app.listen(port);
|
||||
console.log(`Application is running on: http://localhost:${port}/api`);
|
||||
console.log(`Swagger documentation: http://localhost:${port}/api/docs`);
|
||||
}
|
||||
bootstrap();
|
||||
32
src/modules/wechat-game/dto/game-config-response.dto.ts
Normal file
32
src/modules/wechat-game/dto/game-config-response.dto.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class GameConfigResponseDto {
|
||||
@ApiProperty({ description: '配置ID' })
|
||||
id: string;
|
||||
|
||||
@ApiProperty({ description: '配置键名' })
|
||||
configKey: string;
|
||||
|
||||
@ApiProperty({ description: '配置值' })
|
||||
configValue: string;
|
||||
|
||||
@ApiProperty({ description: '配置描述', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@ApiProperty({ description: '是否激活' })
|
||||
isActive: boolean;
|
||||
|
||||
@ApiProperty({ description: '创建时间' })
|
||||
createdAt: Date;
|
||||
|
||||
@ApiProperty({ description: '更新时间' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export class GameConfigListResponseDto {
|
||||
@ApiProperty({ type: [GameConfigResponseDto], description: '配置列表' })
|
||||
configs: GameConfigResponseDto[];
|
||||
|
||||
@ApiProperty({ description: '配置总数' })
|
||||
total: number;
|
||||
}
|
||||
35
src/modules/wechat-game/entities/game-config.entity.ts
Normal file
35
src/modules/wechat-game/entities/game-config.entity.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('game_configs')
|
||||
export class GameConfig {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, name: 'config_key' })
|
||||
configKey: string;
|
||||
|
||||
@Column({ type: 'text', name: 'config_value' })
|
||||
configValue: string;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 100,
|
||||
nullable: true,
|
||||
})
|
||||
description: string | null;
|
||||
|
||||
@Column({ type: 'boolean', default: true, name: 'is_active' })
|
||||
isActive: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { GameConfig } from '../entities/game-config.entity';
|
||||
|
||||
export interface IGameConfigRepository {
|
||||
findAll(): Promise<GameConfig[]>;
|
||||
findById(id: string): Promise<GameConfig | null>;
|
||||
findByKey(key: string): Promise<GameConfig | null>;
|
||||
findActiveConfigs(): Promise<GameConfig[]>;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { GameConfig } from '../entities/game-config.entity';
|
||||
import { IGameConfigRepository } from './game-config.repository.interface';
|
||||
|
||||
@Injectable()
|
||||
export class GameConfigRepository implements IGameConfigRepository {
|
||||
constructor(
|
||||
@InjectRepository(GameConfig)
|
||||
private readonly repository: Repository<GameConfig>,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<GameConfig[]> {
|
||||
return this.repository.find();
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<GameConfig | null> {
|
||||
return this.repository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async findByKey(key: string): Promise<GameConfig | null> {
|
||||
return this.repository.findOne({ where: { configKey: key } });
|
||||
}
|
||||
|
||||
async findActiveConfigs(): Promise<GameConfig[]> {
|
||||
return this.repository.find({ where: { isActive: true } });
|
||||
}
|
||||
}
|
||||
33
src/modules/wechat-game/wechat-game.controller.ts
Normal file
33
src/modules/wechat-game/wechat-game.controller.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { WechatGameService } from './wechat-game.service';
|
||||
import {
|
||||
GameConfigResponseDto,
|
||||
GameConfigListResponseDto,
|
||||
} from './dto/game-config-response.dto';
|
||||
import { ApiResponseDto } from '../../common/dto/api-response.dto';
|
||||
|
||||
@ApiTags('微信小游戏')
|
||||
@Controller('v1/wechat-game')
|
||||
export class WechatGameController {
|
||||
constructor(private readonly wechatGameService: WechatGameService) {}
|
||||
|
||||
@Get('configs')
|
||||
@ApiOperation({ summary: '获取所有游戏配置', description: '获取所有激活的游戏配置列表' })
|
||||
@ApiResponse({ status: 200, description: '成功获取配置列表' })
|
||||
async getAllConfigs(): Promise<ApiResponseDto<GameConfigListResponseDto>> {
|
||||
const data = await this.wechatGameService.getAllConfigs();
|
||||
return ApiResponseDto.success(data);
|
||||
}
|
||||
|
||||
@Get('configs/:key')
|
||||
@ApiOperation({ summary: '根据key获取配置', description: '根据配置键名获取单个游戏配置' })
|
||||
@ApiResponse({ status: 200, description: '成功获取配置' })
|
||||
@ApiResponse({ status: 404, description: '配置不存在' })
|
||||
async getConfigByKey(
|
||||
@Param('key') key: string,
|
||||
): Promise<ApiResponseDto<GameConfigResponseDto>> {
|
||||
const data = await this.wechatGameService.getConfigByKey(key);
|
||||
return ApiResponseDto.success(data);
|
||||
}
|
||||
}
|
||||
14
src/modules/wechat-game/wechat-game.module.ts
Normal file
14
src/modules/wechat-game/wechat-game.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { WechatGameController } from './wechat-game.controller';
|
||||
import { WechatGameService } from './wechat-game.service';
|
||||
import { GameConfig } from './entities/game-config.entity';
|
||||
import { GameConfigRepository } from './repositories/game-config.repository';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([GameConfig])],
|
||||
controllers: [WechatGameController],
|
||||
providers: [WechatGameService, GameConfigRepository],
|
||||
exports: [WechatGameService],
|
||||
})
|
||||
export class WechatGameModule {}
|
||||
86
src/modules/wechat-game/wechat-game.service.spec.ts
Normal file
86
src/modules/wechat-game/wechat-game.service.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { WechatGameService } from './wechat-game.service';
|
||||
import { GameConfigRepository } from './repositories/game-config.repository';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { GameConfig } from './entities/game-config.entity';
|
||||
|
||||
describe('WechatGameService', () => {
|
||||
let service: WechatGameService;
|
||||
let repository: GameConfigRepository;
|
||||
|
||||
const mockGameConfig: GameConfig = {
|
||||
id: 'test-uuid',
|
||||
configKey: 'game_speed',
|
||||
configValue: '1.5',
|
||||
description: 'Game speed multiplier',
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockRepository = {
|
||||
findActiveConfigs: jest.fn(),
|
||||
findByKey: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
WechatGameService,
|
||||
{
|
||||
provide: GameConfigRepository,
|
||||
useValue: mockRepository,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<WechatGameService>(WechatGameService);
|
||||
repository = module.get<GameConfigRepository>(GameConfigRepository);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getAllConfigs', () => {
|
||||
it('should return all active configs', async () => {
|
||||
mockRepository.findActiveConfigs.mockResolvedValue([mockGameConfig]);
|
||||
|
||||
const result = await service.getAllConfigs();
|
||||
|
||||
expect(result.configs).toHaveLength(1);
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.configs[0].configKey).toBe('game_speed');
|
||||
expect(mockRepository.findActiveConfigs).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return empty array when no configs found', async () => {
|
||||
mockRepository.findActiveConfigs.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getAllConfigs();
|
||||
|
||||
expect(result.configs).toHaveLength(0);
|
||||
expect(result.total).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConfigByKey', () => {
|
||||
it('should return config by key', async () => {
|
||||
mockRepository.findByKey.mockResolvedValue(mockGameConfig);
|
||||
|
||||
const result = await service.getConfigByKey('game_speed');
|
||||
|
||||
expect(result.configKey).toBe('game_speed');
|
||||
expect(result.configValue).toBe('1.5');
|
||||
expect(mockRepository.findByKey).toHaveBeenCalledWith('game_speed');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when config not found', async () => {
|
||||
mockRepository.findByKey.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getConfigByKey('nonexistent')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
42
src/modules/wechat-game/wechat-game.service.ts
Normal file
42
src/modules/wechat-game/wechat-game.service.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { GameConfigRepository } from './repositories/game-config.repository';
|
||||
import {
|
||||
GameConfigResponseDto,
|
||||
GameConfigListResponseDto,
|
||||
} from './dto/game-config-response.dto';
|
||||
|
||||
@Injectable()
|
||||
export class WechatGameService {
|
||||
constructor(private readonly gameConfigRepository: GameConfigRepository) {}
|
||||
|
||||
async getAllConfigs(): Promise<GameConfigListResponseDto> {
|
||||
const configs = await this.gameConfigRepository.findActiveConfigs();
|
||||
|
||||
return {
|
||||
configs: configs.map((config) => this.toResponseDto(config)),
|
||||
total: configs.length,
|
||||
};
|
||||
}
|
||||
|
||||
async getConfigByKey(key: string): Promise<GameConfigResponseDto> {
|
||||
const config = await this.gameConfigRepository.findByKey(key);
|
||||
|
||||
if (!config) {
|
||||
throw new NotFoundException(`Game config with key "${key}" not found`);
|
||||
}
|
||||
|
||||
return this.toResponseDto(config);
|
||||
}
|
||||
|
||||
private toResponseDto(config: import('./entities/game-config.entity').GameConfig): GameConfigResponseDto {
|
||||
return {
|
||||
id: config.id,
|
||||
configKey: config.configKey,
|
||||
configValue: config.configValue,
|
||||
description: config.description,
|
||||
isActive: config.isActive,
|
||||
createdAt: config.createdAt,
|
||||
updatedAt: config.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user