feat: 重构关卡接口
This commit is contained in:
@@ -2,6 +2,7 @@ import { UserLevelProgress } from '../entities/user-level-progress.entity';
|
||||
|
||||
export interface IUserLevelProgressRepository {
|
||||
findByUserId(userId: string): Promise<UserLevelProgress[]>;
|
||||
countByUserId(userId: string): Promise<number>;
|
||||
findByUserAndLevel(
|
||||
userId: string,
|
||||
levelId: string,
|
||||
|
||||
@@ -15,6 +15,10 @@ export class UserLevelProgressRepository implements IUserLevelProgressRepository
|
||||
return this.repository.find({ where: { userId } });
|
||||
}
|
||||
|
||||
async countByUserId(userId: string): Promise<number> {
|
||||
return this.repository.count({ where: { userId } });
|
||||
}
|
||||
|
||||
async findByUserAndLevel(
|
||||
userId: string,
|
||||
levelId: string,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
|
||||
import { IsNotEmpty, IsNumber, Min } from 'class-validator';
|
||||
import { NextLevelDto } from './next-level.dto';
|
||||
|
||||
export class CompleteLevelRequestDto {
|
||||
@ApiProperty({ description: '通关时长(秒)' })
|
||||
@@ -18,4 +19,11 @@ export class CompleteLevelResponseDto {
|
||||
|
||||
@ApiProperty({ description: '通关时长(秒)' })
|
||||
timeSpent!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '下一个待通关的关卡(全部通关时为 null)',
|
||||
nullable: true,
|
||||
type: NextLevelDto,
|
||||
})
|
||||
nextLevel!: NextLevelDto | null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { StaminaInfoDto } from '../../user/dto/user-profile.dto';
|
||||
import { NextLevelDto } from './next-level.dto';
|
||||
|
||||
export class EnterLevelResponseDto {
|
||||
@ApiProperty({ description: '关卡 ID' })
|
||||
@@ -37,4 +38,12 @@ export class EnterLevelResponseDto {
|
||||
|
||||
@ApiProperty({ description: '消耗体力后的体力信息' })
|
||||
stamina!: StaminaInfoDto;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'预加载的下一关数据(用于客户端预加载资源,无下一关时为 null)',
|
||||
nullable: true,
|
||||
type: NextLevelDto,
|
||||
})
|
||||
preloadNextLevel!: NextLevelDto | null;
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class LevelListItemDto {
|
||||
@ApiProperty({ description: '关卡 ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '关卡编号' })
|
||||
level!: number;
|
||||
|
||||
@ApiProperty({ description: '图片1 URL' })
|
||||
image1Url!: string;
|
||||
|
||||
@ApiProperty({ description: '图片1 文本说明', nullable: true })
|
||||
image1Description!: string | null;
|
||||
|
||||
@ApiProperty({ description: '图片2 URL' })
|
||||
image2Url!: string;
|
||||
|
||||
@ApiProperty({ description: '图片2 文本说明', nullable: true })
|
||||
image2Description!: string | null;
|
||||
|
||||
@ApiProperty({ description: '答案(仅已通关时返回)', nullable: true })
|
||||
answer!: string | null;
|
||||
|
||||
@ApiProperty({ description: '谐音梗说明(始终返回)', nullable: true })
|
||||
punchline!: 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;
|
||||
}
|
||||
39
src/modules/level/dto/next-level.dto.ts
Normal file
39
src/modules/level/dto/next-level.dto.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class NextLevelDto {
|
||||
@ApiProperty({ description: '关卡 ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '关卡编号(sortOrder)' })
|
||||
level!: number;
|
||||
|
||||
@ApiProperty({ description: '图片1 URL' })
|
||||
image1Url!: string;
|
||||
|
||||
@ApiProperty({ description: '图片1 文本说明', nullable: true })
|
||||
image1Description!: string | null;
|
||||
|
||||
@ApiProperty({ description: '图片2 URL' })
|
||||
image2Url!: string;
|
||||
|
||||
@ApiProperty({ description: '图片2 文本说明', nullable: true })
|
||||
image2Description!: string | null;
|
||||
|
||||
@ApiProperty({ description: '答案' })
|
||||
answer!: string;
|
||||
|
||||
@ApiProperty({ description: '谐音梗说明', nullable: true })
|
||||
punchline!: 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: '限时(秒)', nullable: true })
|
||||
timeLimit!: number | null;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
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,
|
||||
@@ -24,21 +23,6 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
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: '进入关卡',
|
||||
|
||||
@@ -2,16 +2,13 @@ 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';
|
||||
import {
|
||||
pickLevelImageFields,
|
||||
pickLevelImageFieldsMasked,
|
||||
} from '../wechat-game/level-fields.helper';
|
||||
import { pickLevelImageFields } from '../wechat-game/level-fields.helper';
|
||||
import { findNextUncompletedLevels, toNextLevelDto } from './next-level.helper';
|
||||
|
||||
@Injectable()
|
||||
export class LevelService {
|
||||
@@ -24,35 +21,7 @@ export class LevelService {
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取关卡列表(已通关的返回答案/线索,未通关的不返回)
|
||||
*/
|
||||
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,
|
||||
...pickLevelImageFieldsMasked(level, completed),
|
||||
answer: completed ? level.answer : null,
|
||||
completed,
|
||||
timeSpent: completed ? progress.timeSpent : null,
|
||||
};
|
||||
});
|
||||
|
||||
return { levels: items, total: items.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* 进入关卡:消耗 1 体力,返回完整关卡详情
|
||||
* 进入关卡:消耗 1 体力,返回完整关卡详情 + 预加载下一关
|
||||
*/
|
||||
async enterLevel(
|
||||
userId: string,
|
||||
@@ -79,17 +48,34 @@ export class LevelService {
|
||||
this.logger.log(`用户 ${userId} 进入关卡 ${levelId},消耗 1 体力`);
|
||||
}
|
||||
|
||||
// 计算预加载的下一关(当前关卡之后的第一个未完成关卡)
|
||||
const [allLevels, progressList] = await Promise.all([
|
||||
this.levelRepository.findAllOrdered(),
|
||||
this.userLevelProgressRepository.findByUserId(userId),
|
||||
]);
|
||||
const completedIds = new Set(progressList.map((p) => p.levelId));
|
||||
// 当前关卡不算已完成(用户正在玩),找当前关卡之后的第一个未完成关卡
|
||||
const levelsAfterCurrent = allLevels.filter(
|
||||
(l) => l.sortOrder > level.sortOrder,
|
||||
);
|
||||
const nextLevels = findNextUncompletedLevels(
|
||||
levelsAfterCurrent,
|
||||
completedIds,
|
||||
1,
|
||||
);
|
||||
|
||||
return {
|
||||
id: level.id,
|
||||
level: level.sortOrder,
|
||||
...pickLevelImageFields(level),
|
||||
answer: level.answer,
|
||||
stamina: staminaInfo,
|
||||
preloadNextLevel: nextLevels[0] ? toNextLevelDto(nextLevels[0]) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 通关上报:记录通关时长
|
||||
* 通关上报:记录通关时长,返回下一关数据
|
||||
*/
|
||||
async completeLevel(
|
||||
userId: string,
|
||||
@@ -105,28 +91,40 @@ export class LevelService {
|
||||
throw new NotFoundException(`关卡 ${levelId} 不存在`);
|
||||
}
|
||||
|
||||
let firstClear: boolean;
|
||||
let timeSpent: number;
|
||||
|
||||
if (existing) {
|
||||
this.logger.warn(`用户 ${userId} 已通关关卡 ${levelId},不重复记录`);
|
||||
return {
|
||||
firstClear: false,
|
||||
firstClear = false;
|
||||
timeSpent = existing.timeSpent;
|
||||
} else {
|
||||
const progress = this.userLevelProgressRepository.create({
|
||||
userId,
|
||||
levelId,
|
||||
timeSpent: existing.timeSpent,
|
||||
};
|
||||
timeSpent: dto.timeSpent,
|
||||
});
|
||||
await this.userLevelProgressRepository.save(progress);
|
||||
this.logger.log(
|
||||
`用户 ${userId} 通关 ${levelId},用时 ${dto.timeSpent} 秒`,
|
||||
);
|
||||
firstClear = true;
|
||||
timeSpent = dto.timeSpent;
|
||||
}
|
||||
|
||||
const progress = this.userLevelProgressRepository.create({
|
||||
userId,
|
||||
levelId,
|
||||
timeSpent: dto.timeSpent,
|
||||
});
|
||||
await this.userLevelProgressRepository.save(progress);
|
||||
|
||||
this.logger.log(`用户 ${userId} 通关 ${levelId},用时 ${dto.timeSpent} 秒`);
|
||||
// 计算下一关
|
||||
const [allLevels, allProgress] = await Promise.all([
|
||||
this.levelRepository.findAllOrdered(),
|
||||
this.userLevelProgressRepository.findByUserId(userId),
|
||||
]);
|
||||
const completedIds = new Set(allProgress.map((p) => p.levelId));
|
||||
const nextLevels = findNextUncompletedLevels(allLevels, completedIds, 1);
|
||||
|
||||
return {
|
||||
firstClear: true,
|
||||
firstClear,
|
||||
levelId,
|
||||
timeSpent: dto.timeSpent,
|
||||
timeSpent,
|
||||
nextLevel: nextLevels[0] ? toNextLevelDto(nextLevels[0]) : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
35
src/modules/level/next-level.helper.ts
Normal file
35
src/modules/level/next-level.helper.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Level } from '../wechat-game/entities/level.entity';
|
||||
import { NextLevelDto } from './dto/next-level.dto';
|
||||
import { pickLevelImageFields } from '../wechat-game/level-fields.helper';
|
||||
|
||||
/**
|
||||
* Convert a Level entity to a NextLevelDto for client consumption.
|
||||
*/
|
||||
export function toNextLevelDto(level: Level): NextLevelDto {
|
||||
return {
|
||||
id: level.id,
|
||||
level: level.sortOrder,
|
||||
...pickLevelImageFields(level),
|
||||
answer: level.answer,
|
||||
timeLimit: level.timeLimit,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Given all levels (sorted by sortOrder ASC) and the set of completed level IDs,
|
||||
* return the next `count` uncompleted levels.
|
||||
*/
|
||||
export function findNextUncompletedLevels(
|
||||
allLevelsOrdered: Level[],
|
||||
completedLevelIds: Set<string>,
|
||||
count: number,
|
||||
): Level[] {
|
||||
const result: Level[] = [];
|
||||
for (const level of allLevelsOrdered) {
|
||||
if (!completedLevelIds.has(level.id)) {
|
||||
result.push(level);
|
||||
if (result.length >= count) break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { NextLevelDto } from '../../level/dto/next-level.dto';
|
||||
|
||||
export class StaminaInfoDto {
|
||||
@ApiProperty({ description: '当前体力值' })
|
||||
@@ -32,6 +33,13 @@ export class GameDataResponseDto {
|
||||
stamina: StaminaInfoDto;
|
||||
};
|
||||
|
||||
@ApiProperty({ description: '已完成的关卡 ID 列表' })
|
||||
completedLevelIds!: string[];
|
||||
@ApiProperty({ description: '已通关的关卡数量' })
|
||||
completedLevelCount!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '下一个待通关的关卡(全部通关时为 null)',
|
||||
nullable: true,
|
||||
type: NextLevelDto,
|
||||
})
|
||||
nextLevel!: NextLevelDto | null;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
|
||||
import { UserController } from './user.controller';
|
||||
import { UserService } from './user.service';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { WechatGameModule } from '../wechat-game/wechat-game.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
imports: [AuthModule, WechatGameModule],
|
||||
controllers: [UserController],
|
||||
providers: [UserService],
|
||||
exports: [UserService],
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { UserRepository } from '../auth/repositories/user.repository';
|
||||
import { UserLevelProgressRepository } from '../auth/repositories/user-level-progress.repository';
|
||||
import { LevelRepository } from '../wechat-game/repositories/level.repository';
|
||||
import { User } from '../auth/entities/user.entity';
|
||||
import {
|
||||
StaminaInfoDto,
|
||||
@@ -15,6 +16,10 @@ import {
|
||||
MAX_STAMINA,
|
||||
RECOVER_INTERVAL_MS,
|
||||
} from '../../common/constants/game.constants';
|
||||
import {
|
||||
findNextUncompletedLevels,
|
||||
toNextLevelDto,
|
||||
} from '../level/next-level.helper';
|
||||
|
||||
export { MAX_STAMINA, RECOVER_INTERVAL_MS };
|
||||
|
||||
@@ -23,6 +28,7 @@ export class UserService {
|
||||
constructor(
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly userLevelProgressRepository: UserLevelProgressRepository,
|
||||
private readonly levelRepository: LevelRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -122,17 +128,20 @@ export class UserService {
|
||||
}
|
||||
|
||||
async getGameData(userId: string): Promise<GameDataResponseDto> {
|
||||
const [user, progressList] = await Promise.all([
|
||||
const [user, progressList, allLevels] = await Promise.all([
|
||||
this.findUserOrThrow(userId),
|
||||
this.userLevelProgressRepository.findByUserId(userId),
|
||||
this.levelRepository.findAllOrdered(),
|
||||
]);
|
||||
|
||||
const stamina = this.computeStamina(user);
|
||||
const completedLevelIds = progressList.map((p) => p.levelId);
|
||||
const completedIds = new Set(progressList.map((p) => p.levelId));
|
||||
const nextLevels = findNextUncompletedLevels(allLevels, completedIds, 1);
|
||||
|
||||
return {
|
||||
user: { id: user.id, stamina },
|
||||
completedLevelIds,
|
||||
completedLevelCount: completedIds.size,
|
||||
nextLevel: nextLevels[0] ? toNextLevelDto(nextLevels[0]) : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user