feat: 重构关卡接口

This commit is contained in:
richarjiang
2026-04-26 17:08:27 +08:00
parent e5d6c3a674
commit 25d196263b
13 changed files with 437 additions and 327 deletions

View File

@@ -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,

View File

@@ -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,

View File

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

View File

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

View File

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

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

View File

@@ -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: '进入关卡',

View File

@@ -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,
};
}
}

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

View File

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

View File

@@ -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],

View File

@@ -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,
};
}