diff --git a/docs/api/game-api.md b/docs/api/game-api.md index 657c512..d63d5b1 100644 --- a/docs/api/game-api.md +++ b/docs/api/game-api.md @@ -15,8 +15,9 @@ - [3. 获取游戏数据](#3-获取游戏数据) - [4. 进入关卡](#4-进入关卡) - [5. 通关上报](#5-通关上报) - - [6. 获取游戏配置](#6-获取游戏配置) - - [7. 获取单个游戏配置](#7-获取单个游戏配置) + - [6. 获取已通关关卡列表](#6-获取已通关关卡列表) + - [7. 获取游戏配置](#7-获取游戏配置) + - [8. 获取单个游戏配置](#8-获取单个游戏配置) - [错误码说明](#错误码说明) - [接入流程](#接入流程) - [Cocos Creator 调用示例](#cocos-creator-调用示例) @@ -25,6 +26,9 @@ ## Changelog +- **2026-04-30**: + - **新增** `GET /api/v1/levels/completed`:获取当前用户已通关关卡列表(按 sortOrder 升序,含完整关卡信息 + 通关时长 + 通关时间) + - **2026-04-26**: - **删除** `GET /api/v1/levels`(关卡列表接口),客户端不再需要拉取全量关卡列表 - **变更** `GET /api/v1/user/game-data`:移除 `completedLevelIds`,新增 `completedLevelCount`(数字)和 `nextLevel`(下一关完整数据) @@ -575,7 +579,106 @@ interface NextLevel { --- -### 6. 获取游戏配置 +### 6. 获取已通关关卡列表 + +获取当前用户所有已通关的关卡,适用于「成就墙」「关卡回看」等场景。 + +**接口地址**:`GET /api/v1/levels/completed` + +**是否需要认证**:是 + +**请求参数**:无 + +**响应数据**: + +```typescript +CompletedLevel[] + +interface CompletedLevel { + id: string; // 关卡 ID + level: number; // 关卡编号(sortOrder) + image1Url: string; + image1Description: string | null; + image2Url: string; + image2Description: string | null; + answer: string; + punchline: string | null; + hint1: string | null; + hint2: string | null; + hint3: string | null; + timeLimit: number | null; + timeSpent: number; // 首次通关时长(秒) + completedAt: string; // 通关时间(ISO 8601) +} +``` + +**成功响应示例**: + +```json +{ + "success": true, + "data": [ + { + "id": "level_001", + "level": 1, + "image1Url": "https://cdn.example.com/levels/001_1.png", + "image1Description": "一只猫", + "image2Url": "https://cdn.example.com/levels/001_2.png", + "image2Description": "一个头", + "answer": "猫头鹰", + "punchline": "谐音梗示例", + "hint1": "和动物有关", + "hint2": null, + "hint3": null, + "timeLimit": null, + "timeSpent": 42, + "completedAt": "2026-04-10T11:58:12.000Z" + }, + { + "id": "level_002", + "level": 2, + "image1Url": "https://cdn.example.com/levels/002_1.png", + "image1Description": "一条鱼", + "image2Url": "https://cdn.example.com/levels/002_2.png", + "image2Description": "一个缸", + "answer": "鱼缸", + "punchline": null, + "hint1": "容器", + "hint2": null, + "hint3": null, + "timeLimit": null, + "timeSpent": 31, + "completedAt": "2026-04-10T12:05:47.000Z" + } + ], + "message": null, + "timestamp": "2026-04-30T10:00:00.000Z" +} +``` + +**用户未通关任何关卡时**: + +```json +{ + "success": true, + "data": [], + "message": null, + "timestamp": "2026-04-30T10:00:00.000Z" +} +``` + +**业务逻辑**: +- 返回列表按关卡顺序(`sortOrder`)升序排列 +- `timeSpent` 为**首次通关**时上报的时长,重复通关不会覆盖 +- 每项包含完整关卡信息,客户端可直接在回看页面渲染图片、答案、线索 + +**客户端调用时机**: +- 用户打开「关卡回看 / 成就墙」页面时 +- 不建议在 Loading 阶段调用(Loading 使用 `game-data` 即可) + +--- + +### 7. 获取游戏配置 获取所有激活的游戏配置。 @@ -630,7 +733,7 @@ interface GameConfig { --- -### 7. 获取单个游戏配置 +### 8. 获取单个游戏配置 根据配置 key 获取单个配置。 diff --git a/src/modules/level/dto/completed-level.dto.ts b/src/modules/level/dto/completed-level.dto.ts new file mode 100644 index 0000000..83f3144 --- /dev/null +++ b/src/modules/level/dto/completed-level.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { NextLevelDto } from './next-level.dto'; + +export class CompletedLevelDto extends NextLevelDto { + @ApiProperty({ description: '通关时长(秒)' }) + timeSpent!: number; + + @ApiProperty({ description: '通关时间(ISO 8601)' }) + completedAt!: Date; +} diff --git a/src/modules/level/level.controller.ts b/src/modules/level/level.controller.ts index 8ff824c..fb72ac0 100644 --- a/src/modules/level/level.controller.ts +++ b/src/modules/level/level.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, @@ -11,6 +11,7 @@ import { CompleteLevelRequestDto, CompleteLevelResponseDto, } from './dto/complete-level.dto'; +import { CompletedLevelDto } from './dto/completed-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'; @@ -23,6 +24,21 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator'; export class LevelController { constructor(private readonly levelService: LevelService) {} + @Get('completed') + @ApiOperation({ + summary: '获取已通关关卡列表', + description: + '返回当前用户所有已通关的关卡,按关卡顺序(sortOrder)升序排列。每项包含完整关卡信息 + 通关时长 + 通关时间。', + }) + @ApiResponse({ status: 200, description: '成功' }) + @ApiResponse({ status: 401, description: '未授权' }) + async getCompletedLevels( + @CurrentUser() user: JwtPayload, + ): Promise> { + const data = await this.levelService.getCompletedLevels(user.sub); + return ApiResponseDto.success(data); + } + @Post(':id/enter') @ApiOperation({ summary: '进入关卡', diff --git a/src/modules/level/level.service.ts b/src/modules/level/level.service.ts index 264d24d..758bc39 100644 --- a/src/modules/level/level.service.ts +++ b/src/modules/level/level.service.ts @@ -7,6 +7,7 @@ import { CompleteLevelRequestDto, CompleteLevelResponseDto, } from './dto/complete-level.dto'; +import { CompletedLevelDto } from './dto/completed-level.dto'; import { pickLevelImageFields } from '../wechat-game/level-fields.helper'; import { findNextUncompletedLevels, toNextLevelDto } from './next-level.helper'; @@ -127,4 +128,38 @@ export class LevelService { nextLevel: nextLevels[0] ? toNextLevelDto(nextLevels[0]) : null, }; } + + /** + * 获取用户已通关的关卡列表,按关卡顺序(sortOrder)升序返回 + */ + async getCompletedLevels(userId: string): Promise { + const progressList = + await this.userLevelProgressRepository.findByUserId(userId); + + if (progressList.length === 0) { + return []; + } + + const levelIds = progressList.map((p) => p.levelId); + const levels = await this.levelRepository.findByIds(levelIds); + + // 构建 levelId -> progress 映射,便于合并 timeSpent / completedAt + const progressMap = new Map(progressList.map((p) => [p.levelId, p])); + + return levels + .slice() + .sort((a, b) => a.sortOrder - b.sortOrder) + .map((level) => { + const progress = progressMap.get(level.id)!; + return { + id: level.id, + level: level.sortOrder, + ...pickLevelImageFields(level), + answer: level.answer, + timeLimit: level.timeLimit, + timeSpent: progress.timeSpent, + completedAt: progress.completedAt, + }; + }); + } }