refactor: 拆分核心玩法模块并优化代码质量

将 WechatGame 单体模块拆分为独立的 User、Level、GameConfig 模块,
新增体力值系统、关卡闯关流程,并修复多项代码质量问题:
- 体力不足错误码从 401 修正为 400
- enterLevel 改用 findById 替代全表扫描
- consumeStamina 增加原子更新防止并发竞态
- 并行化独立数据库查询 (Promise.all)
- 移除 WechatGameService/Controller 死代码
This commit is contained in:
richarjiang
2026-04-10 09:07:50 +08:00
parent c775d5c6b0
commit fe2c13258e
33 changed files with 1681 additions and 978 deletions

View File

@@ -2,9 +2,12 @@ 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';
import { AuthModule } from './modules/auth/auth.module';
import { UserModule } from './modules/user/user.module';
import { LevelModule } from './modules/level/level.module';
import { GameConfigModule } from './modules/game-config/game-config.module';
import { ShareModule } from './modules/share/share.module';
import { WechatGameModule } from './modules/wechat-game/wechat-game.module';
@Module({
imports: [
@@ -25,8 +28,11 @@ import { ShareModule } from './modules/share/share.module';
autoLoadEntities: true,
}),
}),
WechatGameModule,
AuthModule,
UserModule,
LevelModule,
GameConfigModule,
WechatGameModule, // 保留用于 entity/repository 导出
ShareModule,
],
})

View File

@@ -10,7 +10,7 @@ import { validateEnvironment } from './env.validation';
isGlobal: true,
load: [databaseConfig],
validate: validateEnvironment,
envFilePath: ['.env.local', '.env.production', '.env'],
envFilePath: ['.env.local', '.env', '.env.production'],
}),
],
exports: [ConfigModule],

View File

@@ -3,22 +3,12 @@ import { JwtService } from '@nestjs/jwt';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { ApiResponseDto } from '../../common/dto/api-response.dto';
import type { JwtPayload } from '../../common/guards/jwt-auth.guard';
describe('AuthController', () => {
let controller: AuthController;
const mockUser: JwtPayload = {
sub: 'user-uuid-1',
openid: 'wx-openid-123',
};
const mockAuthService = {
wxLogin: jest.fn(),
getUserAssets: jest.fn(),
consumePoint: jest.fn(),
earnPoint: jest.fn(),
getGameData: jest.fn(),
};
beforeEach(async () => {
@@ -41,7 +31,7 @@ describe('AuthController', () => {
it('should return success response with token and user info', async () => {
const loginResponse = {
token: 'jwt-token',
user: { id: 'user-uuid-1', nickname: 'Test', points: 10 },
user: { id: 'user-uuid-1', nickname: 'Test', stamina: 5 },
};
mockAuthService.wxLogin.mockResolvedValue(loginResponse);
@@ -53,68 +43,4 @@ describe('AuthController', () => {
expect(mockAuthService.wxLogin).toHaveBeenCalledWith('wx-code-123');
});
});
describe('getUserAssets', () => {
it('should return success response with user points', async () => {
mockAuthService.getUserAssets.mockResolvedValue({ points: 10 });
const result = await controller.getUserAssets(mockUser);
expect(result.success).toBe(true);
expect(result.data).toEqual({ points: 10 });
expect(mockAuthService.getUserAssets).toHaveBeenCalledWith('user-uuid-1');
});
});
describe('consumePoint', () => {
it('should return success response with updated points', async () => {
mockAuthService.consumePoint.mockResolvedValue({ points: 9 });
const dto = { reason: 'hint_unlock' as const, levelId: 'level-1', hintIndex: 2 };
const result = await controller.consumePoint(mockUser, dto);
expect(result.success).toBe(true);
expect(result.data).toEqual({ points: 9 });
expect(mockAuthService.consumePoint).toHaveBeenCalledWith(
'user-uuid-1',
dto,
);
});
});
describe('earnPoint', () => {
it('should return success response with updated points', async () => {
mockAuthService.earnPoint.mockResolvedValue({ points: 11 });
const dto = {
reason: 'level_complete' as const,
levelId: 'level-1',
timeSpent: 30,
};
const result = await controller.earnPoint(mockUser, dto);
expect(result.success).toBe(true);
expect(result.data).toEqual({ points: 11 });
expect(mockAuthService.earnPoint).toHaveBeenCalledWith(
'user-uuid-1',
dto,
);
});
});
describe('getGameData', () => {
it('should return success response with game data', async () => {
const gameData = {
user: { id: 'user-uuid-1', points: 10 },
completedLevelIds: ['level-1', 'level-2'],
};
mockAuthService.getGameData.mockResolvedValue(gameData);
const result = await controller.getGameData(mockUser);
expect(result.success).toBe(true);
expect(result.data).toEqual(gameData);
expect(mockAuthService.getGameData).toHaveBeenCalledWith('user-uuid-1');
});
});
});

View File

@@ -1,31 +1,19 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { Body, Controller, Post } from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { WxLoginRequestDto, WxLoginResponseDto } from './dto/wx-login.dto';
import {
ConsumePointRequestDto,
EarnPointRequestDto,
GameDataResponseDto,
UserAssetsResponseDto,
} from './dto/user-assets.dto';
import { ApiResponseDto } from '../../common/dto/api-response.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import type { JwtPayload } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@ApiTags('用户认证与资产')
@Controller('v1')
@ApiTags('认证')
@Controller('v1/auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
// ==================== 公开接口 ====================
@Post('auth/wx-login')
@Post('wx-login')
@ApiOperation({
summary: '微信登录',
description: '使用微信 wx.login 返回的 code 换取 JWT 令牌',
@@ -38,73 +26,4 @@ export class AuthController {
const data = await this.authService.wxLogin(dto.code);
return ApiResponseDto.success(data);
}
// ==================== 需要鉴权的接口 ====================
@Get('user/assets')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: '获取用户积分',
description: '获取当前登录用户的积分信息',
})
@ApiResponse({ status: 200, description: '成功' })
@ApiResponse({ status: 401, description: '未授权' })
async getUserAssets(
@CurrentUser() user: JwtPayload,
): Promise<ApiResponseDto<UserAssetsResponseDto>> {
const data = await this.authService.getUserAssets(user.sub);
return ApiResponseDto.success(data);
}
@Post('user/assets/consume')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: '消耗积分',
description: '消耗 1 积分(用于解锁提示)',
})
@ApiResponse({ status: 200, description: '消耗成功' })
@ApiResponse({ status: 400, description: '积分不足' })
@ApiResponse({ status: 401, description: '未授权' })
async consumePoint(
@CurrentUser() user: JwtPayload,
@Body() dto: ConsumePointRequestDto,
): Promise<ApiResponseDto<UserAssetsResponseDto>> {
const data = await this.authService.consumePoint(user.sub, dto);
return ApiResponseDto.success(data);
}
@Post('user/assets/earn')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: '获得积分',
description: '通关获得 1 积分(同一关卡不重复奖励)',
})
@ApiResponse({ status: 200, description: '获得成功' })
@ApiResponse({ status: 401, description: '未授权' })
async earnPoint(
@CurrentUser() user: JwtPayload,
@Body() dto: EarnPointRequestDto,
): Promise<ApiResponseDto<UserAssetsResponseDto>> {
const data = await this.authService.earnPoint(user.sub, dto);
return ApiResponseDto.success(data);
}
@Get('user/game-data')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: '获取游戏数据',
description: '获取用户积分和通关进度Loading 页面使用)',
})
@ApiResponse({ status: 200, description: '成功' })
@ApiResponse({ status: 401, description: '未授权' })
async getGameData(
@CurrentUser() user: JwtPayload,
): Promise<ApiResponseDto<GameDataResponseDto>> {
const data = await this.authService.getGameData(user.sub);
return ApiResponseDto.success(data);
}
}

View File

@@ -23,6 +23,6 @@ import { UserLevelProgressRepository } from './repositories/user-level-progress.
],
controllers: [AuthController],
providers: [AuthService, UserRepository, UserLevelProgressRepository],
exports: [JwtModule, AuthService],
exports: [JwtModule, AuthService, UserRepository, UserLevelProgressRepository],
})
export class AuthModule {}

View File

@@ -1,16 +1,11 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import {
UnauthorizedException,
BadRequestException,
} from '@nestjs/common';
import { UnauthorizedException } from '@nestjs/common';
import axios from 'axios';
import { AuthService } from './auth.service';
import { UserRepository } from './repositories/user.repository';
import { UserLevelProgressRepository } from './repositories/user-level-progress.repository';
import { User } from './entities/user.entity';
import { UserLevelProgress } from './entities/user-level-progress.entity';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
@@ -24,20 +19,12 @@ describe('AuthService', () => {
sessionKey: 'session-key-abc',
nickname: 'TestUser',
avatarUrl: null,
points: 10,
stamina: 5,
staminaUpdatedAt: null,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
};
const mockLevelProgress: UserLevelProgress = {
id: 'progress-uuid-1',
userId: 'user-uuid-1',
levelId: 'level-1',
user: mockUser,
timeSpent: 30,
completedAt: new Date('2026-01-02'),
};
const mockUserRepository = {
findById: jest.fn(),
findByOpenid: jest.fn(),
@@ -45,13 +32,6 @@ describe('AuthService', () => {
save: jest.fn(),
};
const mockUserLevelProgressRepository = {
findByUserId: jest.fn(),
findByUserAndLevel: jest.fn(),
create: jest.fn(),
save: jest.fn(),
};
const mockJwtService = {
signAsync: jest.fn(),
};
@@ -73,10 +53,6 @@ describe('AuthService', () => {
{ provide: ConfigService, useValue: mockConfigService },
{ provide: JwtService, useValue: mockJwtService },
{ provide: UserRepository, useValue: mockUserRepository },
{
provide: UserLevelProgressRepository,
useValue: mockUserLevelProgressRepository,
},
],
}).compile();
@@ -89,7 +65,7 @@ describe('AuthService', () => {
describe('wxLogin', () => {
it('should create a new user and return JWT token on first login', async () => {
const newUser = { ...mockUser, points: 10 };
const newUser = { ...mockUser, stamina: 5 };
mockedAxios.get.mockResolvedValue({
data: { openid: 'wx-openid-123', session_key: 'session-key-abc' },
});
@@ -102,11 +78,11 @@ describe('AuthService', () => {
expect(result.token).toBe('jwt-token-xyz');
expect(result.user.id).toBe('user-uuid-1');
expect(result.user.points).toBe(10);
expect(result.user.stamina).toBe(5);
expect(mockUserRepository.create).toHaveBeenCalledWith({
openid: 'wx-openid-123',
sessionKey: 'session-key-abc',
points: 10,
stamina: 5,
});
expect(mockJwtService.signAsync).toHaveBeenCalledWith({
sub: 'user-uuid-1',
@@ -150,144 +126,4 @@ describe('AuthService', () => {
);
});
});
describe('getUserAssets', () => {
it('should return user points', async () => {
mockUserRepository.findById.mockResolvedValue(mockUser);
const result = await service.getUserAssets('user-uuid-1');
expect(result.points).toBe(10);
expect(mockUserRepository.findById).toHaveBeenCalledWith('user-uuid-1');
});
it('should throw UnauthorizedException when user not found', async () => {
mockUserRepository.findById.mockResolvedValue(null);
await expect(service.getUserAssets('nonexistent')).rejects.toThrow(
UnauthorizedException,
);
});
});
describe('consumePoint', () => {
it('should deduct 1 point and return updated points', async () => {
const user = { ...mockUser, points: 5 };
const savedUser = { ...user, points: 4 };
mockUserRepository.findById.mockResolvedValue(user);
mockUserRepository.save.mockResolvedValue(savedUser);
const result = await service.consumePoint('user-uuid-1', {
reason: 'hint_unlock',
levelId: 'level-1',
hintIndex: 2,
});
expect(result.points).toBe(4);
expect(mockUserRepository.save).toHaveBeenCalled();
});
it('should throw BadRequestException when points are 0', async () => {
mockUserRepository.findById.mockResolvedValue({
...mockUser,
points: 0,
});
await expect(
service.consumePoint('user-uuid-1', {
reason: 'hint_unlock',
}),
).rejects.toThrow(BadRequestException);
});
it('should throw UnauthorizedException when user not found', async () => {
mockUserRepository.findById.mockResolvedValue(null);
await expect(
service.consumePoint('nonexistent', { reason: 'hint_unlock' }),
).rejects.toThrow(UnauthorizedException);
});
});
describe('earnPoint', () => {
const earnDto = {
reason: 'level_complete' as const,
levelId: 'level-1',
timeSpent: 30,
};
it('should award 1 point for first-time level completion', async () => {
const user = { ...mockUser, points: 10 };
mockUserRepository.findById.mockResolvedValue(user);
mockUserLevelProgressRepository.findByUserAndLevel.mockResolvedValue(null);
mockUserLevelProgressRepository.create.mockReturnValue(mockLevelProgress);
mockUserLevelProgressRepository.save.mockResolvedValue(mockLevelProgress);
mockUserRepository.save.mockResolvedValue({ ...user, points: 11 });
const result = await service.earnPoint('user-uuid-1', earnDto);
expect(result.points).toBe(11);
expect(mockUserLevelProgressRepository.create).toHaveBeenCalledWith({
userId: 'user-uuid-1',
levelId: 'level-1',
timeSpent: 30,
});
});
it('should not award duplicate points for already completed level', async () => {
const user = { ...mockUser, points: 10 };
mockUserRepository.findById.mockResolvedValue(user);
mockUserLevelProgressRepository.findByUserAndLevel.mockResolvedValue(
mockLevelProgress,
);
const result = await service.earnPoint('user-uuid-1', earnDto);
expect(result.points).toBe(10);
expect(mockUserLevelProgressRepository.create).not.toHaveBeenCalled();
expect(mockUserRepository.save).not.toHaveBeenCalled();
});
it('should throw UnauthorizedException when user not found', async () => {
mockUserRepository.findById.mockResolvedValue(null);
await expect(
service.earnPoint('nonexistent', earnDto),
).rejects.toThrow(UnauthorizedException);
});
});
describe('getGameData', () => {
it('should return user info and completed level IDs', async () => {
mockUserRepository.findById.mockResolvedValue(mockUser);
mockUserLevelProgressRepository.findByUserId.mockResolvedValue([
{ ...mockLevelProgress, levelId: 'level-1' },
{ ...mockLevelProgress, levelId: 'level-2' },
]);
const result = await service.getGameData('user-uuid-1');
expect(result.user.id).toBe('user-uuid-1');
expect(result.user.points).toBe(10);
expect(result.completedLevelIds).toEqual(['level-1', 'level-2']);
});
it('should return empty completedLevelIds when no progress', async () => {
mockUserRepository.findById.mockResolvedValue(mockUser);
mockUserLevelProgressRepository.findByUserId.mockResolvedValue([]);
const result = await service.getGameData('user-uuid-1');
expect(result.completedLevelIds).toEqual([]);
});
it('should throw UnauthorizedException when user not found', async () => {
mockUserRepository.findById.mockResolvedValue(null);
mockUserLevelProgressRepository.findByUserId.mockResolvedValue([]);
await expect(service.getGameData('nonexistent')).rejects.toThrow(
UnauthorizedException,
);
});
});
});

View File

@@ -2,20 +2,12 @@ import {
Injectable,
Logger,
UnauthorizedException,
BadRequestException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import axios from 'axios';
import { UserRepository } from './repositories/user.repository';
import { UserLevelProgressRepository } from './repositories/user-level-progress.repository';
import { WxLoginResponseDto, UserInfoDto } from './dto/wx-login.dto';
import {
UserAssetsResponseDto,
ConsumePointRequestDto,
EarnPointRequestDto,
GameDataResponseDto,
} from './dto/user-assets.dto';
import { JwtPayload } from '../../common/guards/jwt-auth.guard';
interface WxSessionResponse {
@@ -35,7 +27,6 @@ export class AuthService {
private readonly configService: ConfigService,
private readonly jwtService: JwtService,
private readonly userRepository: UserRepository,
private readonly userLevelProgressRepository: UserLevelProgressRepository,
) {
this.wxAppId = this.configService.get<string>('WX_APPID', '');
this.wxSecret = this.configService.get<string>('WX_SECRET', '');
@@ -62,7 +53,7 @@ export class AuthService {
user = this.userRepository.create({
openid: wxSession.openid,
sessionKey: wxSession.session_key ?? null,
points: 10, // 新用户默认 10 积分
stamina: 5, // 新用户默认 5 体力值
});
user = await this.userRepository.save(user);
this.logger.log(`新用户注册: ${user.id}`);
@@ -85,101 +76,12 @@ export class AuthService {
const userInfo: UserInfoDto = {
id: user.id,
nickname: user.nickname,
points: user.points,
stamina: user.stamina,
};
return { token, user: userInfo };
}
/**
* 获取用户积分
*/
async getUserAssets(userId: string): Promise<UserAssetsResponseDto> {
const user = await this.findUserOrThrow(userId);
return { points: user.points };
}
/**
* 消耗积分(解锁提示)
*/
async consumePoint(
userId: string,
dto: ConsumePointRequestDto,
): Promise<UserAssetsResponseDto> {
const user = await this.findUserOrThrow(userId);
if (user.points <= 0) {
throw new BadRequestException('积分不足,无法消耗');
}
user.points -= 1;
await this.userRepository.save(user);
this.logger.log(
`用户 ${userId} 消耗 1 积分(${dto.reason}),剩余: ${user.points}`,
);
return { points: user.points };
}
/**
* 获得积分(通关奖励)
*/
async earnPoint(
userId: string,
dto: EarnPointRequestDto,
): Promise<UserAssetsResponseDto> {
const user = await this.findUserOrThrow(userId);
// 检查是否已经领取过该关卡的通关奖励(防重复)
const existing = await this.userLevelProgressRepository.findByUserAndLevel(
userId,
dto.levelId,
);
if (existing) {
this.logger.warn(`用户 ${userId} 已完成关卡 ${dto.levelId},不重复奖励`);
return { points: user.points };
}
// 记录通关进度
const progress = this.userLevelProgressRepository.create({
userId,
levelId: dto.levelId,
timeSpent: dto.timeSpent,
});
await this.userLevelProgressRepository.save(progress);
// 增加积分
user.points += 1;
await this.userRepository.save(user);
this.logger.log(
`用户 ${userId} 通关 ${dto.levelId},获得 1 积分,当前: ${user.points}`,
);
return { points: user.points };
}
/**
* 获取用户游戏数据Loading 页面复合接口)
*/
async getGameData(userId: string): Promise<GameDataResponseDto> {
const [user, progressList] = await Promise.all([
this.findUserOrThrow(userId),
this.userLevelProgressRepository.findByUserId(userId),
]);
const completedLevelIds = progressList.map((p) => p.levelId);
return {
user: {
id: user.id,
points: user.points,
},
completedLevelIds,
};
}
/**
* 调用微信 jscode2session 接口
*/
@@ -200,15 +102,4 @@ export class AuthService {
throw new UnauthorizedException('微信服务调用失败,请重试');
}
}
/**
* 查找用户,不存在则抛异常
*/
private async findUserOrThrow(userId: string) {
const user = await this.userRepository.findById(userId);
if (!user) {
throw new UnauthorizedException('用户不存在');
}
return user;
}
}

View File

@@ -1,52 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class UserAssetsResponseDto {
@ApiProperty({ description: '积分' })
points!: number;
}
export class ConsumePointRequestDto {
@ApiProperty({ description: '消耗原因', enum: ['hint_unlock'] })
@IsString()
@IsNotEmpty()
@IsIn(['hint_unlock'])
reason!: 'hint_unlock';
@ApiProperty({ description: '关卡 ID', required: false })
@IsString()
@IsOptional()
levelId?: string;
@ApiProperty({ description: '提示索引2 或 3', required: false })
@IsOptional()
hintIndex?: number;
}
export class EarnPointRequestDto {
@ApiProperty({ description: '获取原因', enum: ['level_complete'] })
@IsString()
@IsNotEmpty()
@IsIn(['level_complete'])
reason!: 'level_complete';
@ApiProperty({ description: '关卡 ID' })
@IsString()
@IsNotEmpty()
levelId!: string;
@ApiProperty({ description: '通关时间(秒)' })
@IsNotEmpty()
timeSpent!: number;
}
export class GameDataResponseDto {
@ApiProperty({ description: '用户信息' })
user!: {
id: string;
points: number;
};
@ApiProperty({ description: '已完成的关卡 ID 列表' })
completedLevelIds!: string[];
}

View File

@@ -15,8 +15,8 @@ export class UserInfoDto {
@ApiProperty({ description: '用户昵称', nullable: true })
nickname!: string | null;
@ApiProperty({ description: '积分' })
points!: number;
@ApiProperty({ description: '体力值' })
stamina!: number;
}
export class WxLoginResponseDto {

View File

@@ -25,9 +25,13 @@ export class User {
@Column({ type: 'text', name: 'avatar_url', nullable: true })
avatarUrl!: string | null;
/** 积分(默认 10 */
@Column({ type: 'int', default: 10 })
points!: number;
/** 体力值(默认 5上限 5 */
@Column({ type: 'int', default: 5 })
stamina!: number;
/** 体力值最后更新时间(用于计算恢复) */
@Column({ type: 'timestamp', name: 'stamina_updated_at', nullable: true })
staminaUpdatedAt!: Date | null;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;

View File

@@ -5,4 +5,10 @@ export interface IUserRepository {
findByOpenid(openid: string): Promise<User | null>;
create(data: Partial<User>): User;
save(user: User): Promise<User>;
updateStaminaAtomic(
userId: string,
expectedOldStamina: number,
newStamina: number,
staminaUpdatedAt: Date,
): Promise<{ affected: number }>;
}

View File

@@ -26,4 +26,21 @@ export class UserRepository implements IUserRepository {
async save(user: User): Promise<User> {
return this.repository.save(user);
}
/**
* 原子更新体力值,使用 WHERE 条件防止并发竞态。
* 只有当 stamina 仍等于 expectedOldStamina 时才更新。
*/
async updateStaminaAtomic(
userId: string,
expectedOldStamina: number,
newStamina: number,
staminaUpdatedAt: Date,
): Promise<{ affected: number }> {
const result = await this.repository.update(
{ id: userId, stamina: expectedOldStamina },
{ stamina: newStamina, staminaUpdatedAt },
);
return { affected: result.affected ?? 0 };
}
}

View File

@@ -0,0 +1,39 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { GameConfigService } from './game-config.service';
import {
GameConfigResponseDto,
GameConfigListResponseDto,
} from '../wechat-game/dto/game-config-response.dto';
import { ApiResponseDto } from '../../common/dto/api-response.dto';
@ApiTags('游戏配置')
@Controller('v1/game-configs')
export class GameConfigController {
constructor(private readonly gameConfigService: GameConfigService) {}
@Get()
@ApiOperation({
summary: '获取所有游戏配置',
description: '获取所有激活的游戏配置列表',
})
@ApiResponse({ status: 200, description: '成功获取配置列表' })
async getAllConfigs(): Promise<ApiResponseDto<GameConfigListResponseDto>> {
const data = await this.gameConfigService.getAllConfigs();
return ApiResponseDto.success(data);
}
@Get(':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.gameConfigService.getConfigByKey(key);
return ApiResponseDto.success(data);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { GameConfigController } from './game-config.controller';
import { GameConfigService } from './game-config.service';
import { WechatGameModule } from '../wechat-game/wechat-game.module';
@Module({
imports: [WechatGameModule],
controllers: [GameConfigController],
providers: [GameConfigService],
})
export class GameConfigModule {}

View File

@@ -0,0 +1,42 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { GameConfigRepository } from '../wechat-game/repositories/game-config.repository';
import {
GameConfigResponseDto,
GameConfigListResponseDto,
} from '../wechat-game/dto/game-config-response.dto';
import { GameConfig } from '../wechat-game/entities/game-config.entity';
@Injectable()
export class GameConfigService {
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: GameConfig): GameConfigResponseDto {
return {
id: config.id,
configKey: config.configKey,
configValue: config.configValue,
description: config.description,
isActive: config.isActive,
createdAt: config.createdAt,
updatedAt: config.updatedAt,
};
}
}

View File

@@ -0,0 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
export class CompleteLevelRequestDto {
@ApiProperty({ description: '通关时长(秒)' })
@IsNumber()
@IsNotEmpty()
@Min(0)
timeSpent!: number;
}
export class CompleteLevelResponseDto {
@ApiProperty({ description: '是否为首次通关' })
firstClear!: boolean;
@ApiProperty({ description: '关卡 ID' })
levelId!: string;
@ApiProperty({ description: '通关时长(秒)' })
timeSpent!: number;
}

View File

@@ -0,0 +1,28 @@
import { ApiProperty } from '@nestjs/swagger';
import { StaminaInfoDto } from '../../user/dto/user-profile.dto';
export class EnterLevelResponseDto {
@ApiProperty({ description: '关卡 ID' })
id!: string;
@ApiProperty({ description: '关卡编号' })
level!: number;
@ApiProperty({ description: '图片 URL' })
imageUrl!: string;
@ApiProperty({ description: '答案' })
answer!: string;
@ApiProperty({ description: '线索1', nullable: true })
hint1!: string | null;
@ApiProperty({ description: '线索2', nullable: true })
hint2!: string | null;
@ApiProperty({ description: '线索3', nullable: true })
hint3!: string | null;
@ApiProperty({ description: '消耗体力后的体力信息' })
stamina!: StaminaInfoDto;
}

View File

@@ -0,0 +1,38 @@
import { ApiProperty } from '@nestjs/swagger';
export class LevelListItemDto {
@ApiProperty({ description: '关卡 ID' })
id!: string;
@ApiProperty({ description: '关卡编号' })
level!: number;
@ApiProperty({ description: '图片 URL' })
imageUrl!: string;
@ApiProperty({ description: '答案(仅已通关时返回)', nullable: true })
answer!: string | null;
@ApiProperty({ description: '线索1仅已通关时返回', nullable: true })
hint1!: string | null;
@ApiProperty({ description: '线索2仅已通关时返回', nullable: true })
hint2!: string | null;
@ApiProperty({ description: '线索3仅已通关时返回', nullable: true })
hint3!: string | null;
@ApiProperty({ description: '是否已通关' })
completed!: boolean;
@ApiProperty({ description: '通关时长(秒),未通关时为 null', nullable: true })
timeSpent!: number | null;
}
export class LevelListResponseDto {
@ApiProperty({ type: [LevelListItemDto], description: '关卡列表' })
levels!: LevelListItemDto[];
@ApiProperty({ description: '关卡总数' })
total!: number;
}

View File

@@ -0,0 +1,82 @@
import {
Body,
Controller,
Get,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { LevelService } from './level.service';
import { LevelListResponseDto } from './dto/level-list.dto';
import { EnterLevelResponseDto } from './dto/enter-level.dto';
import {
CompleteLevelRequestDto,
CompleteLevelResponseDto,
} from './dto/complete-level.dto';
import { ApiResponseDto } from '../../common/dto/api-response.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import type { JwtPayload } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@ApiTags('关卡')
@Controller('v1/levels')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class LevelController {
constructor(private readonly levelService: LevelService) {}
@Get()
@ApiOperation({
summary: '获取关卡列表',
description:
'获取所有关卡列表。已通关的关卡返回答案和线索,未通关的不返回敏感数据',
})
@ApiResponse({ status: 200, description: '成功' })
@ApiResponse({ status: 401, description: '未授权' })
async getLevels(
@CurrentUser() user: JwtPayload,
): Promise<ApiResponseDto<LevelListResponseDto>> {
const data = await this.levelService.getLevelList(user.sub);
return ApiResponseDto.success(data);
}
@Post(':id/enter')
@ApiOperation({
summary: '进入关卡',
description: '消耗 1 体力进入关卡,返回完整关卡详情(线索+答案)。已通关关卡不消耗体力。',
})
@ApiResponse({ status: 200, description: '成功' })
@ApiResponse({ status: 400, description: '体力不足' })
@ApiResponse({ status: 404, description: '关卡不存在' })
@ApiResponse({ status: 401, description: '未授权' })
async enterLevel(
@CurrentUser() user: JwtPayload,
@Param('id') id: string,
): Promise<ApiResponseDto<EnterLevelResponseDto>> {
const data = await this.levelService.enterLevel(user.sub, id);
return ApiResponseDto.success(data);
}
@Post(':id/complete')
@ApiOperation({
summary: '通关上报',
description: '上报用户通关时长,同一关卡不重复记录',
})
@ApiResponse({ status: 200, description: '成功' })
@ApiResponse({ status: 404, description: '关卡不存在' })
@ApiResponse({ status: 401, description: '未授权' })
async completeLevel(
@CurrentUser() user: JwtPayload,
@Param('id') id: string,
@Body() dto: CompleteLevelRequestDto,
): Promise<ApiResponseDto<CompleteLevelResponseDto>> {
const data = await this.levelService.completeLevel(user.sub, id, dto);
return ApiResponseDto.success(data);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { LevelController } from './level.controller';
import { LevelService } from './level.service';
import { AuthModule } from '../auth/auth.module';
import { UserModule } from '../user/user.module';
import { WechatGameModule } from '../wechat-game/wechat-game.module';
@Module({
imports: [AuthModule, UserModule, WechatGameModule],
controllers: [LevelController],
providers: [LevelService],
exports: [LevelService],
})
export class LevelModule {}

View File

@@ -0,0 +1,146 @@
import {
Injectable,
NotFoundException,
Logger,
} from '@nestjs/common';
import { LevelRepository } from '../wechat-game/repositories/level.repository';
import { UserLevelProgressRepository } from '../auth/repositories/user-level-progress.repository';
import { UserService } from '../user/user.service';
import { LevelListResponseDto, LevelListItemDto } from './dto/level-list.dto';
import { EnterLevelResponseDto } from './dto/enter-level.dto';
import {
CompleteLevelRequestDto,
CompleteLevelResponseDto,
} from './dto/complete-level.dto';
@Injectable()
export class LevelService {
private readonly logger = new Logger(LevelService.name);
constructor(
private readonly levelRepository: LevelRepository,
private readonly userLevelProgressRepository: UserLevelProgressRepository,
private readonly userService: UserService,
) {}
/**
* 获取关卡列表(已通关的返回答案/线索,未通关的不返回)
*/
async getLevelList(userId: string): Promise<LevelListResponseDto> {
const [levels, progressList] = await Promise.all([
this.levelRepository.findAllOrdered(),
this.userLevelProgressRepository.findByUserId(userId),
]);
const progressMap = new Map(
progressList.map((p) => [p.levelId, p]),
);
const items: LevelListItemDto[] = levels.map((level, index) => {
const progress = progressMap.get(level.id);
const completed = !!progress;
return {
id: level.id,
level: index + 1,
imageUrl: level.imageUrl,
answer: completed ? level.answer : null,
hint1: completed ? level.hint1 : null,
hint2: completed ? level.hint2 : null,
hint3: completed ? level.hint3 : null,
completed,
timeSpent: completed ? progress.timeSpent : null,
};
});
return { levels: items, total: items.length };
}
/**
* 进入关卡:消耗 1 体力,返回完整关卡详情
*/
async enterLevel(
userId: string,
levelId: string,
): Promise<EnterLevelResponseDto> {
// 1. 并行查找关卡和通关记录
const [level, existing] = await Promise.all([
this.levelRepository.findById(levelId),
this.userLevelProgressRepository.findByUserAndLevel(userId, levelId),
]);
if (!level) {
throw new NotFoundException(`关卡 ${levelId} 不存在`);
}
let staminaInfo;
if (existing) {
// 已通关,不消耗体力,直接返回
const user = await this.userService.findUserOrThrow(userId);
staminaInfo = this.userService.computeStamina(user);
} else {
// 未通关,消耗体力(返回值已包含 stamina 信息,无需重复计算)
const result = await this.userService.consumeStamina(userId);
staminaInfo = result.stamina;
this.logger.log(`用户 ${userId} 进入关卡 ${levelId},消耗 1 体力`);
}
return {
id: level.id,
level: level.sortOrder,
imageUrl: level.imageUrl,
answer: level.answer,
hint1: level.hint1,
hint2: level.hint2,
hint3: level.hint3,
stamina: staminaInfo,
};
}
/**
* 通关上报:记录通关时长
*/
async completeLevel(
userId: string,
levelId: string,
dto: CompleteLevelRequestDto,
): Promise<CompleteLevelResponseDto> {
// 并行验证关卡存在和检查通关记录
const [level, existing] = await Promise.all([
this.levelRepository.findById(levelId),
this.userLevelProgressRepository.findByUserAndLevel(userId, levelId),
]);
if (!level) {
throw new NotFoundException(`关卡 ${levelId} 不存在`);
}
if (existing) {
this.logger.warn(`用户 ${userId} 已通关关卡 ${levelId},不重复记录`);
return {
firstClear: false,
levelId,
timeSpent: existing.timeSpent,
};
}
// 记录通关进度
const progress = this.userLevelProgressRepository.create({
userId,
levelId,
timeSpent: dto.timeSpent,
});
await this.userLevelProgressRepository.save(progress);
this.logger.log(
`用户 ${userId} 通关 ${levelId},用时 ${dto.timeSpent}`,
);
return {
firstClear: true,
levelId,
timeSpent: dto.timeSpent,
};
}
}

View File

@@ -0,0 +1,34 @@
import { ApiProperty } from '@nestjs/swagger';
export class StaminaInfoDto {
@ApiProperty({ description: '当前体力值' })
current!: number;
@ApiProperty({ description: '体力值上限' })
max!: number;
@ApiProperty({ description: '下次恢复时间ISO 字符串),满体力时为 null', nullable: true })
nextRecoverAt!: string | null;
}
export class UserProfileResponseDto {
@ApiProperty({ description: '用户 ID' })
id!: string;
@ApiProperty({ description: '用户昵称', nullable: true })
nickname!: string | null;
@ApiProperty({ description: '体力信息' })
stamina!: StaminaInfoDto;
}
export class GameDataResponseDto {
@ApiProperty({ description: '用户信息' })
user!: {
id: string;
stamina: StaminaInfoDto;
};
@ApiProperty({ description: '已完成的关卡 ID 列表' })
completedLevelIds!: string[];
}

View File

@@ -0,0 +1,49 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { UserService } from './user.service';
import { UserProfileResponseDto, GameDataResponseDto } from './dto/user-profile.dto';
import { ApiResponseDto } from '../../common/dto/api-response.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import type { JwtPayload } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@ApiTags('用户')
@Controller('v1/user')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class UserController {
constructor(private readonly userService: UserService) {}
@Get('profile')
@ApiOperation({
summary: '获取用户资料',
description: '获取当前用户的资料信息,包括计算后的体力值与下次恢复时间',
})
@ApiResponse({ status: 200, description: '成功' })
@ApiResponse({ status: 401, description: '未授权' })
async getProfile(
@CurrentUser() user: JwtPayload,
): Promise<ApiResponseDto<UserProfileResponseDto>> {
const data = await this.userService.getUserProfile(user.sub);
return ApiResponseDto.success(data);
}
@Get('game-data')
@ApiOperation({
summary: '获取游戏数据',
description: '获取用户体力值和通关进度Loading 页面使用)',
})
@ApiResponse({ status: 200, description: '成功' })
@ApiResponse({ status: 401, description: '未授权' })
async getGameData(
@CurrentUser() user: JwtPayload,
): Promise<ApiResponseDto<GameDataResponseDto>> {
const data = await this.userService.getGameData(user.sub);
return ApiResponseDto.success(data);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [AuthModule],
controllers: [UserController],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}

View File

@@ -0,0 +1,125 @@
import {
Injectable,
BadRequestException,
UnauthorizedException,
} from '@nestjs/common';
import { UserRepository } from '../auth/repositories/user.repository';
import { UserLevelProgressRepository } from '../auth/repositories/user-level-progress.repository';
import { User } from '../auth/entities/user.entity';
import {
StaminaInfoDto,
UserProfileResponseDto,
GameDataResponseDto,
} from './dto/user-profile.dto';
export const MAX_STAMINA = 5;
export const RECOVER_INTERVAL_MS = 10 * 60 * 1000; // 10 分钟
@Injectable()
export class UserService {
constructor(
private readonly userRepository: UserRepository,
private readonly userLevelProgressRepository: UserLevelProgressRepository,
) {}
/**
* 根据数据库中的 stamina + staminaUpdatedAt计算当前实际体力值
*/
computeStamina(user: User): StaminaInfoDto {
if (user.stamina >= MAX_STAMINA) {
return { current: MAX_STAMINA, max: MAX_STAMINA, nextRecoverAt: null };
}
if (!user.staminaUpdatedAt) {
return {
current: user.stamina,
max: MAX_STAMINA,
nextRecoverAt: null,
};
}
const elapsed = Date.now() - user.staminaUpdatedAt.getTime();
const recovered = Math.floor(elapsed / RECOVER_INTERVAL_MS);
const currentStamina = Math.min(MAX_STAMINA, user.stamina + recovered);
let nextRecoverAt: string | null = null;
if (currentStamina < MAX_STAMINA) {
const remainder = elapsed % RECOVER_INTERVAL_MS;
nextRecoverAt = new Date(
Date.now() + RECOVER_INTERVAL_MS - remainder,
).toISOString();
}
return { current: currentStamina, max: MAX_STAMINA, nextRecoverAt };
}
/**
* 消耗 1 点体力,返回消耗后的体力信息。
* 使用原子更新防止并发竞态条件(双击进入关卡场景)。
*/
async consumeStamina(
userId: string,
): Promise<{ user: User; stamina: StaminaInfoDto }> {
const user = await this.findUserOrThrow(userId);
const staminaInfo = this.computeStamina(user);
if (staminaInfo.current <= 0) {
throw new BadRequestException('体力不足');
}
const newStamina = staminaInfo.current - 1;
const now = new Date();
// 原子更新:使用 WHERE 条件确保并发安全
const result = await this.userRepository.updateStaminaAtomic(
userId,
user.stamina,
newStamina,
now,
);
if (result.affected === 0) {
// 并发冲突,重试一次
return this.consumeStamina(userId);
}
const updatedUser = { ...user, stamina: newStamina, staminaUpdatedAt: now };
const updatedStamina = this.computeStamina(updatedUser as User);
return { user: updatedUser as User, stamina: updatedStamina };
}
async getUserProfile(userId: string): Promise<UserProfileResponseDto> {
const user = await this.findUserOrThrow(userId);
const stamina = this.computeStamina(user);
return {
id: user.id,
nickname: user.nickname,
stamina,
};
}
async getGameData(userId: string): Promise<GameDataResponseDto> {
const [user, progressList] = await Promise.all([
this.findUserOrThrow(userId),
this.userLevelProgressRepository.findByUserId(userId),
]);
const stamina = this.computeStamina(user);
const completedLevelIds = progressList.map((p) => p.levelId);
return {
user: { id: user.id, stamina },
completedLevelIds,
};
}
async findUserOrThrow(userId: string): Promise<User> {
const user = await this.userRepository.findById(userId);
if (!user) {
throw new UnauthorizedException('用户不存在');
}
return user;
}
}

View File

@@ -1,133 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { WechatGameController } from './wechat-game.controller';
import { WechatGameService } from './wechat-game.service';
import { ApiResponseDto } from '../../common/dto/api-response.dto';
describe('WechatGameController', () => {
let controller: WechatGameController;
const mockWechatGameService = {
getAllConfigs: jest.fn(),
getConfigByKey: jest.fn(),
getAllLevels: jest.fn(),
getLevelById: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [WechatGameController],
providers: [
{ provide: WechatGameService, useValue: mockWechatGameService },
],
}).compile();
controller = module.get<WechatGameController>(WechatGameController);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('getAllConfigs', () => {
it('should return success response with config list', async () => {
const configList = {
configs: [
{
id: 'config-1',
configKey: 'game_speed',
configValue: '1.5',
description: null,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
},
],
total: 1,
};
mockWechatGameService.getAllConfigs.mockResolvedValue(configList);
const result = await controller.getAllConfigs();
expect(result).toBeInstanceOf(ApiResponseDto);
expect(result.success).toBe(true);
expect(result.data).toEqual(configList);
});
});
describe('getConfigByKey', () => {
it('should return success response with config', async () => {
const config = {
id: 'config-1',
configKey: 'game_speed',
configValue: '1.5',
description: null,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
};
mockWechatGameService.getConfigByKey.mockResolvedValue(config);
const result = await controller.getConfigByKey('game_speed');
expect(result.success).toBe(true);
expect(result.data).toEqual(config);
expect(mockWechatGameService.getConfigByKey).toHaveBeenCalledWith(
'game_speed',
);
});
});
describe('getAllLevels', () => {
it('should return success response with level list', async () => {
const levelList = {
levels: [
{
level: 1,
id: 'level-1',
imageUrl: 'https://example.com/1.jpg',
answer: '答案',
hint1: null,
hint2: null,
hint3: null,
sortOrder: 0,
createdAt: new Date(),
updatedAt: new Date(),
},
],
total: 1,
};
mockWechatGameService.getAllLevels.mockResolvedValue(levelList);
const result = await controller.getAllLevels();
expect(result.success).toBe(true);
expect(result.data).toEqual(levelList);
});
});
describe('getLevelById', () => {
it('should return success response with level', async () => {
const level = {
level: 1,
id: 'level-1',
imageUrl: 'https://example.com/1.jpg',
answer: '答案',
hint1: null,
hint2: null,
hint3: null,
sortOrder: 0,
createdAt: new Date(),
updatedAt: new Date(),
};
mockWechatGameService.getLevelById.mockResolvedValue(level);
const result = await controller.getLevelById('level-1');
expect(result.success).toBe(true);
expect(result.data).toEqual(level);
expect(mockWechatGameService.getLevelById).toHaveBeenCalledWith(
'level-1',
);
});
});
});

View File

@@ -1,68 +0,0 @@
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 {
LevelResponseDto,
LevelListResponseDto,
} from './dto/level-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);
}
@Get('levels')
@ApiOperation({
summary: '获取所有关卡',
description: '获取所有关卡列表按sort_order排序',
})
@ApiResponse({ status: 200, description: '成功获取关卡列表' })
async getAllLevels(): Promise<ApiResponseDto<LevelListResponseDto>> {
const data = await this.wechatGameService.getAllLevels();
return ApiResponseDto.success(data);
}
@Get('levels/:id')
@ApiOperation({
summary: '根据ID获取关卡',
description: '根据关卡ID获取单个关卡信息',
})
@ApiResponse({ status: 200, description: '成功获取关卡' })
@ApiResponse({ status: 404, description: '关卡不存在' })
async getLevelById(
@Param('id') id: string,
): Promise<ApiResponseDto<LevelResponseDto>> {
const data = await this.wechatGameService.getLevelById(id);
return ApiResponseDto.success(data);
}
}

View File

@@ -1,16 +1,17 @@
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 { Level } from './entities/level.entity';
import { GameConfigRepository } from './repositories/game-config.repository';
import { LevelRepository } from './repositories/level.repository';
/**
* 保留此模块仅用于导出 entity/repository供其他模块使用。
* 业务逻辑已迁移至 GameConfigModule、LevelModule。
*/
@Module({
imports: [TypeOrmModule.forFeature([GameConfig, Level])],
controllers: [WechatGameController],
providers: [WechatGameService, GameConfigRepository, LevelRepository],
exports: [WechatGameService, LevelRepository],
providers: [GameConfigRepository, LevelRepository],
exports: [LevelRepository, GameConfigRepository],
})
export class WechatGameModule {}

View File

@@ -1,169 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { WechatGameService } from './wechat-game.service';
import { GameConfigRepository } from './repositories/game-config.repository';
import { LevelRepository } from './repositories/level.repository';
import { GameConfig } from './entities/game-config.entity';
import { Level } from './entities/level.entity';
describe('WechatGameService', () => {
let service: WechatGameService;
const mockGameConfig: GameConfig = {
id: 'config-uuid-1',
configKey: 'game_speed',
configValue: '1.5',
description: 'Game speed multiplier',
isActive: true,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
};
const mockLevel: Level = {
id: 'level-1',
imageUrl: 'https://example.com/meme1.jpg',
answer: '答案一',
hint1: '提示1',
hint2: '提示2',
hint3: null,
sortOrder: 0,
timeLimit: 60,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
};
const mockLevel2: Level = {
id: 'level-2',
imageUrl: 'https://example.com/meme2.jpg',
answer: '答案二',
hint1: '提示A',
hint2: null,
hint3: null,
sortOrder: 1,
timeLimit: null,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
};
const mockGameConfigRepository = {
findActiveConfigs: jest.fn(),
findByKey: jest.fn(),
};
const mockLevelRepository = {
findAllOrdered: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
WechatGameService,
{ provide: GameConfigRepository, useValue: mockGameConfigRepository },
{ provide: LevelRepository, useValue: mockLevelRepository },
],
}).compile();
service = module.get<WechatGameService>(WechatGameService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('getAllConfigs', () => {
it('should return all active configs', async () => {
mockGameConfigRepository.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(result.configs[0].configValue).toBe('1.5');
});
it('should return empty array when no configs found', async () => {
mockGameConfigRepository.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 () => {
mockGameConfigRepository.findByKey.mockResolvedValue(mockGameConfig);
const result = await service.getConfigByKey('game_speed');
expect(result.configKey).toBe('game_speed');
expect(result.configValue).toBe('1.5');
expect(mockGameConfigRepository.findByKey).toHaveBeenCalledWith(
'game_speed',
);
});
it('should throw NotFoundException when config not found', async () => {
mockGameConfigRepository.findByKey.mockResolvedValue(null);
await expect(service.getConfigByKey('nonexistent')).rejects.toThrow(
NotFoundException,
);
});
});
describe('getAllLevels', () => {
it('should return all levels with 1-indexed level numbers', async () => {
mockLevelRepository.findAllOrdered.mockResolvedValue([
mockLevel,
mockLevel2,
]);
const result = await service.getAllLevels();
expect(result.levels).toHaveLength(2);
expect(result.total).toBe(2);
expect(result.levels[0].level).toBe(1);
expect(result.levels[0].id).toBe('level-1');
expect(result.levels[0].answer).toBe('答案一');
expect(result.levels[1].level).toBe(2);
expect(result.levels[1].id).toBe('level-2');
});
it('should return empty array when no levels exist', async () => {
mockLevelRepository.findAllOrdered.mockResolvedValue([]);
const result = await service.getAllLevels();
expect(result.levels).toHaveLength(0);
expect(result.total).toBe(0);
});
});
describe('getLevelById', () => {
it('should return level with correct level number', async () => {
mockLevelRepository.findAllOrdered.mockResolvedValue([
mockLevel,
mockLevel2,
]);
const result = await service.getLevelById('level-2');
expect(result.id).toBe('level-2');
expect(result.level).toBe(2);
expect(result.answer).toBe('答案二');
});
it('should throw NotFoundException when level not found', async () => {
mockLevelRepository.findAllOrdered.mockResolvedValue([mockLevel]);
await expect(service.getLevelById('nonexistent')).rejects.toThrow(
NotFoundException,
);
});
});
});

View File

@@ -1,92 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { GameConfigRepository } from './repositories/game-config.repository';
import { LevelRepository } from './repositories/level.repository';
import {
GameConfigResponseDto,
GameConfigListResponseDto,
} from './dto/game-config-response.dto';
import {
LevelResponseDto,
LevelListResponseDto,
} from './dto/level-response.dto';
@Injectable()
export class WechatGameService {
constructor(
private readonly gameConfigRepository: GameConfigRepository,
private readonly levelRepository: LevelRepository,
) {}
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);
}
async getAllLevels(): Promise<LevelListResponseDto> {
const levels = await this.levelRepository.findAllOrdered();
return {
levels: levels.map((level, index) =>
this.toLevelResponseDto(level, index + 1),
),
total: levels.length,
};
}
async getLevelById(id: string): Promise<LevelResponseDto> {
const levels = await this.levelRepository.findAllOrdered();
const levelIndex = levels.findIndex((l) => l.id === id);
if (levelIndex === -1) {
throw new NotFoundException(`Level with id "${id}" not found`);
}
return this.toLevelResponseDto(levels[levelIndex], levelIndex + 1);
}
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,
};
}
private toLevelResponseDto(
level: import('./entities/level.entity').Level,
levelNumber: number,
): LevelResponseDto {
return {
level: levelNumber,
id: level.id,
imageUrl: level.imageUrl,
answer: level.answer,
hint1: level.hint1,
hint2: level.hint2,
hint3: level.hint3,
sortOrder: level.sortOrder,
createdAt: level.createdAt,
updatedAt: level.updatedAt,
};
}
}