refactor: 拆分核心玩法模块并优化代码质量
将 WechatGame 单体模块拆分为独立的 User、Level、GameConfig 模块, 新增体力值系统、关卡闯关流程,并修复多项代码质量问题: - 体力不足错误码从 401 修正为 400 - enterLevel 改用 findById 替代全表扫描 - consumeStamina 增加原子更新防止并发竞态 - 并行化独立数据库查询 (Promise.all) - 移除 WechatGameService/Controller 死代码
This commit is contained in:
@@ -6,10 +6,8 @@
|
|||||||
|
|
||||||
| 模块 | 文档文件 | 说明 | 状态 |
|
| 模块 | 文档文件 | 说明 | 状态 |
|
||||||
|------|----------|------|------|
|
|------|----------|------|------|
|
||||||
| 用户认证 | [auth-api.md](./auth-api.md) | 微信登录、JWT Token | 待编写 |
|
| 核心玩法 | [game-api.md](./game-api.md) | 认证、体力值、关卡闯关、游戏配置 | ✅ 已完成 |
|
||||||
| 分享挑战 | [share-challenge-api.md](./share-challenge-api.md) | 创建分享、加入挑战、进度上报 | 已完成 |
|
| 分享挑战 | [share-challenge-api.md](./share-challenge-api.md) | 创建分享、加入挑战、进度上报 | ✅ 已完成 |
|
||||||
| 游戏关卡 | [game-api.md](./game-api.md) | 关卡数据、答案验证 | 待编写 |
|
|
||||||
| 用户资产 | [user-assets-api.md](./user-assets-api.md) | 积分获取与消耗 | 待编写 |
|
|
||||||
| 排行榜 | [leaderboard-api.md](./leaderboard-api.md) | 排名、分数上报 | 预留 |
|
| 排行榜 | [leaderboard-api.md](./leaderboard-api.md) | 排名、分数上报 | 预留 |
|
||||||
|
|
||||||
## 文档维护规则
|
## 文档维护规则
|
||||||
@@ -37,9 +35,9 @@ Authorization: Bearer <token>
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": { ... },
|
"data": { "..." : "..." },
|
||||||
"message": null,
|
"message": null,
|
||||||
"timestamp": "2026-04-08T12:00:00.000Z"
|
"timestamp": "2026-04-10T12:00:00.000Z"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
972
docs/api/game-api.md
Normal file
972
docs/api/game-api.md
Normal file
@@ -0,0 +1,972 @@
|
|||||||
|
# MemeMind 主玩法 API 协议文档
|
||||||
|
|
||||||
|
> 本文档面向微信小游戏客户端(Cocos Creator)开发人员,涵盖认证、用户体力、关卡闯关等核心玩法接口。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- [概述](#概述)
|
||||||
|
- [认证方式](#认证方式)
|
||||||
|
- [通用响应格式](#通用响应格式)
|
||||||
|
- [体力值系统说明](#体力值系统说明)
|
||||||
|
- [接口列表](#接口列表)
|
||||||
|
- [1. 微信登录](#1-微信登录)
|
||||||
|
- [2. 获取用户资料](#2-获取用户资料)
|
||||||
|
- [3. 获取游戏数据](#3-获取游戏数据)
|
||||||
|
- [4. 获取关卡列表](#4-获取关卡列表)
|
||||||
|
- [5. 进入关卡](#5-进入关卡)
|
||||||
|
- [6. 通关上报](#6-通关上报)
|
||||||
|
- [7. 获取游戏配置](#7-获取游戏配置)
|
||||||
|
- [8. 获取单个游戏配置](#8-获取单个游戏配置)
|
||||||
|
- [错误码说明](#错误码说明)
|
||||||
|
- [接入流程](#接入流程)
|
||||||
|
- [Cocos Creator 调用示例](#cocos-creator-调用示例)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
MemeMind 核心玩法接口分为以下模块:
|
||||||
|
|
||||||
|
| 模块 | 路由前缀 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 认证 | `/api/v1/auth` | 微信登录、JWT 签发 |
|
||||||
|
| 用户 | `/api/v1/user` | 用户资料、体力值、游戏数据 |
|
||||||
|
| 关卡 | `/api/v1/levels` | 关卡列表、进入关卡、通关上报 |
|
||||||
|
| 游戏配置 | `/api/v1/game-configs` | 游戏全局配置 |
|
||||||
|
|
||||||
|
### 与旧版 API 的变更(⚠️ Breaking Changes)
|
||||||
|
|
||||||
|
| 废弃接口 | 替代方案 |
|
||||||
|
|----------|---------|
|
||||||
|
| `GET /api/v1/user/assets` | `GET /api/v1/user/profile` |
|
||||||
|
| `POST /api/v1/user/assets/consume` | 已删除,体力在「进入关卡」时自动扣减 |
|
||||||
|
| `POST /api/v1/user/assets/earn` | 已删除,通关不再奖励积分 |
|
||||||
|
| `GET /api/v1/user/game-data` | `GET /api/v1/user/game-data`(路径不变,响应结构变更) |
|
||||||
|
| `GET /api/v1/wechat-game/levels` | `GET /api/v1/levels`(需鉴权) |
|
||||||
|
| `GET /api/v1/wechat-game/levels/:id` | `POST /api/v1/levels/:id/enter`(需鉴权 + 消耗体力) |
|
||||||
|
| `GET /api/v1/wechat-game/configs` | `GET /api/v1/game-configs` |
|
||||||
|
| `GET /api/v1/wechat-game/configs/:key` | `GET /api/v1/game-configs/:key` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 认证方式
|
||||||
|
|
||||||
|
除微信登录和游戏配置接口外,所有接口均需通过 JWT Token 进行身份认证。
|
||||||
|
|
||||||
|
### 请求头格式
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
`token` 为微信登录接口返回的 JWT 令牌,有效期 **7 天**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 通用响应格式
|
||||||
|
|
||||||
|
所有接口均返回以下 JSON 结构:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": { "..." : "..." },
|
||||||
|
"message": null,
|
||||||
|
"timestamp": "2026-04-10T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| success | boolean | 请求是否成功 |
|
||||||
|
| data | T \| null | 成功时返回业务数据,失败时为 null |
|
||||||
|
| message | string \| null | 失败时的错误信息,成功时为 null |
|
||||||
|
| timestamp | string | 服务器响应时间(ISO 8601) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 体力值系统说明
|
||||||
|
|
||||||
|
体力值(stamina)替代了原有的积分系统,用于控制用户进入关卡的频率。
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 默认体力 | 5(新用户注册时) |
|
||||||
|
| 上限 | 5 |
|
||||||
|
| 恢复速度 | 每 **10 分钟** 恢复 1 点 |
|
||||||
|
| 消耗 | 进入**未通关**关卡时消耗 1 点 |
|
||||||
|
| 已通关关卡 | 再次进入不消耗体力 |
|
||||||
|
|
||||||
|
### 体力值数据结构
|
||||||
|
|
||||||
|
接口中体力信息统一使用以下结构返回:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface StaminaInfo {
|
||||||
|
current: number; // 当前体力值(已计算恢复)
|
||||||
|
max: number; // 体力上限,固定为 5
|
||||||
|
nextRecoverAt: string | null; // 下一点体力恢复的时间(ISO 8601),满体力时为 null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"current": 3,
|
||||||
|
"max": 5,
|
||||||
|
"nextRecoverAt": "2026-04-10T12:10:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**:体力恢复为服务端实时计算,无需客户端轮询。每次调用包含体力信息的接口时,服务端都会返回最新的体力值。客户端可根据 `nextRecoverAt` 自行做倒计时 UI 展示。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 接口列表
|
||||||
|
|
||||||
|
### 1. 微信登录
|
||||||
|
|
||||||
|
获取用户身份令牌。
|
||||||
|
|
||||||
|
**接口地址**:`POST /api/v1/auth/wx-login`
|
||||||
|
|
||||||
|
**是否需要认证**:否
|
||||||
|
|
||||||
|
**请求体**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "微信 wx.login 返回的 code"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| code | string | 是 | 微信 `wx.login` 返回的临时登录凭证 |
|
||||||
|
|
||||||
|
**响应数据**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
token: string;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
nickname: string | null;
|
||||||
|
stamina: number; // 当前体力值(数据库原始值,不含实时恢复计算)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**成功响应示例**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"token": "eyJhbGciOiJIUzI1NiIs...",
|
||||||
|
"user": {
|
||||||
|
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||||
|
"nickname": null,
|
||||||
|
"stamina": 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"message": null,
|
||||||
|
"timestamp": "2026-04-10T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**客户端调用时机**:
|
||||||
|
- 小游戏冷启动时
|
||||||
|
- 缓存的 token 过期后(收到 401 响应时)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 获取用户资料
|
||||||
|
|
||||||
|
获取当前用户的资料,包括实时计算后的体力值。
|
||||||
|
|
||||||
|
**接口地址**:`GET /api/v1/user/profile`
|
||||||
|
|
||||||
|
**是否需要认证**:是
|
||||||
|
|
||||||
|
**请求参数**:无
|
||||||
|
|
||||||
|
**响应数据**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
nickname: string | null;
|
||||||
|
stamina: StaminaInfo; // 实时体力信息
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**成功响应示例**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||||
|
"nickname": null,
|
||||||
|
"stamina": {
|
||||||
|
"current": 3,
|
||||||
|
"max": 5,
|
||||||
|
"nextRecoverAt": "2026-04-10T12:10:00.000Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"message": null,
|
||||||
|
"timestamp": "2026-04-10T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**客户端调用时机**:
|
||||||
|
- 需要刷新用户体力显示时
|
||||||
|
- 从后台切回前台时
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 获取游戏数据
|
||||||
|
|
||||||
|
获取用户体力值和通关进度,适用于游戏 Loading 页面一次性加载。
|
||||||
|
|
||||||
|
**接口地址**:`GET /api/v1/user/game-data`
|
||||||
|
|
||||||
|
**是否需要认证**:是
|
||||||
|
|
||||||
|
**请求参数**:无
|
||||||
|
|
||||||
|
**响应数据**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
stamina: StaminaInfo;
|
||||||
|
};
|
||||||
|
completedLevelIds: string[]; // 已通关的关卡 ID 列表
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**成功响应示例**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"user": {
|
||||||
|
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||||
|
"stamina": {
|
||||||
|
"current": 5,
|
||||||
|
"max": 5,
|
||||||
|
"nextRecoverAt": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"completedLevelIds": ["level_001", "level_002", "level_005"]
|
||||||
|
},
|
||||||
|
"message": null,
|
||||||
|
"timestamp": "2026-04-10T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**客户端调用时机**:
|
||||||
|
- 游戏 Loading 页面
|
||||||
|
- 进入关卡选择页面前
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 获取关卡列表
|
||||||
|
|
||||||
|
获取所有关卡列表。**已通关的关卡**返回答案和线索,**未通关的关卡**不返回敏感数据。
|
||||||
|
|
||||||
|
**接口地址**:`GET /api/v1/levels`
|
||||||
|
|
||||||
|
**是否需要认证**:是
|
||||||
|
|
||||||
|
**请求参数**:无
|
||||||
|
|
||||||
|
**响应数据**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
levels: LevelListItem[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LevelListItem {
|
||||||
|
id: string; // 关卡 ID
|
||||||
|
level: number; // 关卡编号(从 1 开始)
|
||||||
|
imageUrl: string; // 关卡图片 URL
|
||||||
|
answer: string | null; // 答案(仅已通关时返回,否则 null)
|
||||||
|
hint1: string | null; // 线索1(仅已通关时返回,否则 null)
|
||||||
|
hint2: string | null; // 线索2(仅已通关时返回,否则 null)
|
||||||
|
hint3: string | null; // 线索3(仅已通关时返回,否则 null)
|
||||||
|
completed: boolean; // 是否已通关
|
||||||
|
timeSpent: number | null; // 通关时长(秒),未通关时为 null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**成功响应示例**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"levels": [
|
||||||
|
{
|
||||||
|
"id": "level_001",
|
||||||
|
"level": 1,
|
||||||
|
"imageUrl": "https://cdn.example.com/levels/001.png",
|
||||||
|
"answer": "梗答案",
|
||||||
|
"hint1": "这是一个经典的...",
|
||||||
|
"hint2": "和某个明星有关",
|
||||||
|
"hint3": null,
|
||||||
|
"completed": true,
|
||||||
|
"timeSpent": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "level_002",
|
||||||
|
"level": 2,
|
||||||
|
"imageUrl": "https://cdn.example.com/levels/002.png",
|
||||||
|
"answer": null,
|
||||||
|
"hint1": null,
|
||||||
|
"hint2": null,
|
||||||
|
"hint3": null,
|
||||||
|
"completed": false,
|
||||||
|
"timeSpent": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 2
|
||||||
|
},
|
||||||
|
"message": null,
|
||||||
|
"timestamp": "2026-04-10T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**客户端使用说明**:
|
||||||
|
- 关卡选择页面使用此接口获取关卡列表
|
||||||
|
- 根据 `completed` 字段展示不同的 UI 状态(已通关/未通关)
|
||||||
|
- 未通关关卡的 `answer`、`hint1`、`hint2`、`hint3` 均为 `null`,**客户端不应缓存这些字段**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. 进入关卡
|
||||||
|
|
||||||
|
消耗 1 点体力进入关卡,获取完整的关卡详情(含答案和线索)。
|
||||||
|
|
||||||
|
**接口地址**:`POST /api/v1/levels/{id}/enter`
|
||||||
|
|
||||||
|
**是否需要认证**:是
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| id | string | 是 | 关卡 ID |
|
||||||
|
|
||||||
|
**请求体**:无
|
||||||
|
|
||||||
|
**响应数据**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
level: number;
|
||||||
|
imageUrl: string;
|
||||||
|
answer: string;
|
||||||
|
hint1: string | null;
|
||||||
|
hint2: string | null;
|
||||||
|
hint3: string | null;
|
||||||
|
stamina: StaminaInfo; // 消耗后的体力信息
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**成功响应示例**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"id": "level_002",
|
||||||
|
"level": 2,
|
||||||
|
"imageUrl": "https://cdn.example.com/levels/002.png",
|
||||||
|
"answer": "这是答案",
|
||||||
|
"hint1": "第一个线索",
|
||||||
|
"hint2": "第二个线索",
|
||||||
|
"hint3": null,
|
||||||
|
"stamina": {
|
||||||
|
"current": 2,
|
||||||
|
"max": 5,
|
||||||
|
"nextRecoverAt": "2026-04-10T12:10:00.000Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"message": null,
|
||||||
|
"timestamp": "2026-04-10T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**业务逻辑**:
|
||||||
|
|
||||||
|
| 场景 | 是否消耗体力 | 说明 |
|
||||||
|
|------|-------------|------|
|
||||||
|
| 首次进入未通关关卡 | ✅ 消耗 1 点 | 正常扣减 |
|
||||||
|
| 再次进入已通关关卡 | ❌ 不消耗 | 直接返回关卡详情 |
|
||||||
|
| 体力为 0 且关卡未通关 | ❌ 返回错误 | 返回 401 体力不足 |
|
||||||
|
|
||||||
|
**客户端调用时机**:
|
||||||
|
- 用户在关卡选择页面点击某个关卡进入时
|
||||||
|
- **必须**调用此接口获取关卡详情后才能开始游戏
|
||||||
|
- 客户端应在调用前检查体力是否足够,体力不足时提示用户等待恢复
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. 通关上报
|
||||||
|
|
||||||
|
用户通关后上报通关时长。同一关卡不会重复记录。
|
||||||
|
|
||||||
|
**接口地址**:`POST /api/v1/levels/{id}/complete`
|
||||||
|
|
||||||
|
**是否需要认证**:是
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| id | string | 是 | 关卡 ID |
|
||||||
|
|
||||||
|
**请求体**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timeSpent": 45
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| timeSpent | number | 是 | 通关时长(秒),≥ 0 |
|
||||||
|
|
||||||
|
**响应数据**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
firstClear: boolean; // 是否为首次通关
|
||||||
|
levelId: string; // 关卡 ID
|
||||||
|
timeSpent: number; // 记录的通关时长(秒)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**成功响应示例(首次通关)**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"firstClear": true,
|
||||||
|
"levelId": "level_002",
|
||||||
|
"timeSpent": 45
|
||||||
|
},
|
||||||
|
"message": null,
|
||||||
|
"timestamp": "2026-04-10T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**成功响应示例(重复通关)**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"firstClear": false,
|
||||||
|
"levelId": "level_002",
|
||||||
|
"timeSpent": 30
|
||||||
|
},
|
||||||
|
"message": null,
|
||||||
|
"timestamp": "2026-04-10T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**业务逻辑**:
|
||||||
|
- 首次通关:记录 `timeSpent`,返回 `firstClear: true`
|
||||||
|
- 重复通关:不覆盖记录,返回首次通关的 `timeSpent`,`firstClear: false`
|
||||||
|
|
||||||
|
**客户端调用时机**:
|
||||||
|
- 用户成功回答正确答案后调用
|
||||||
|
- 只在通关成功时调用,答错不需要上报
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. 获取游戏配置
|
||||||
|
|
||||||
|
获取所有激活的游戏配置。
|
||||||
|
|
||||||
|
**接口地址**:`GET /api/v1/game-configs`
|
||||||
|
|
||||||
|
**是否需要认证**:否
|
||||||
|
|
||||||
|
**请求参数**:无
|
||||||
|
|
||||||
|
**响应数据**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
configs: GameConfig[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GameConfig {
|
||||||
|
id: string;
|
||||||
|
configKey: string;
|
||||||
|
configValue: string;
|
||||||
|
description: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**成功响应示例**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"configs": [
|
||||||
|
{
|
||||||
|
"id": "cfg-001",
|
||||||
|
"configKey": "hint_unlock_cost",
|
||||||
|
"configValue": "1",
|
||||||
|
"description": "解锁提示消耗体力值",
|
||||||
|
"isActive": true,
|
||||||
|
"createdAt": "2026-04-01T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-04-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1
|
||||||
|
},
|
||||||
|
"message": null,
|
||||||
|
"timestamp": "2026-04-10T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. 获取单个游戏配置
|
||||||
|
|
||||||
|
根据配置 key 获取单个配置。
|
||||||
|
|
||||||
|
**接口地址**:`GET /api/v1/game-configs/{key}`
|
||||||
|
|
||||||
|
**是否需要认证**:否
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| key | string | 是 | 配置键名 |
|
||||||
|
|
||||||
|
**响应数据**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
configKey: string;
|
||||||
|
configValue: string;
|
||||||
|
description: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 错误码说明
|
||||||
|
|
||||||
|
| HTTP Status | message | 说明 |
|
||||||
|
|-------------|---------|------|
|
||||||
|
| 401 | 未提供访问令牌 | 请求头缺少 Authorization |
|
||||||
|
| 401 | 访问令牌无效或已过期 | JWT Token 无效或过期,需重新登录 |
|
||||||
|
| 401 | 微信登录失败,请重试 | 微信 code 无效 |
|
||||||
|
| 401 | 用户不存在 | 用户 ID 在数据库中不存在 |
|
||||||
|
| 401 | 体力不足 | 进入关卡时体力为 0 |
|
||||||
|
| 404 | 关卡 {id} 不存在 | 关卡 ID 不存在 |
|
||||||
|
| 404 | Game config with key "xxx" not found | 配置 key 不存在 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 接入流程
|
||||||
|
|
||||||
|
### 核心游戏流程
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Phase 1: 启动 & 登录 │
|
||||||
|
├──────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ 1. 小游戏启动,调用 wx.login 获取 code │
|
||||||
|
│ 2. POST /api/v1/auth/wx-login → 获取 JWT token │
|
||||||
|
│ 3. 缓存 token 到本地 │
|
||||||
|
└──────────────────────┬───────────────────────────────────────────────┘
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Phase 2: Loading 页面 │
|
||||||
|
├──────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ 4. GET /api/v1/user/game-data → 获取体力值 + 已通关关卡列表 │
|
||||||
|
│ 5. GET /api/v1/game-configs → 获取游戏配置(可选) │
|
||||||
|
└──────────────────────┬───────────────────────────────────────────────┘
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Phase 3: 关卡选择页面 │
|
||||||
|
├──────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ 6. GET /api/v1/levels → 获取关卡列表(含通关状态) │
|
||||||
|
│ 7. 展示关卡网格,已通关的显示已完成状态 │
|
||||||
|
│ 8. 用户点击某个关卡 │
|
||||||
|
│ ├─ 体力足够 → Phase 4 │
|
||||||
|
│ └─ 体力不足 → 提示等待恢复(显示 nextRecoverAt 倒计时) │
|
||||||
|
└──────────────────────┬───────────────────────────────────────────────┘
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Phase 4: 关卡游玩 │
|
||||||
|
├──────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ 9. POST /api/v1/levels/{id}/enter → 消耗体力,获取关卡详情 │
|
||||||
|
│ 10. 展示关卡图片,开始计时 │
|
||||||
|
│ 11. 用户输入答案 │
|
||||||
|
│ ├─ 答案正确 → 停止计时 │
|
||||||
|
│ │ └─ POST /api/v1/levels/{id}/complete → 上报通关时长 │
|
||||||
|
│ └─ 答案错误 → 提示错误,可使用线索 │
|
||||||
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 客户端状态管理建议
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface GameState {
|
||||||
|
// 用户信息
|
||||||
|
token: string | null;
|
||||||
|
userId: string | null;
|
||||||
|
|
||||||
|
// 体力信息
|
||||||
|
stamina: {
|
||||||
|
current: number;
|
||||||
|
max: number;
|
||||||
|
nextRecoverAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 关卡数据
|
||||||
|
completedLevelIds: Set<string>; // 已通关关卡 ID
|
||||||
|
currentLevel: { // 当前正在玩的关卡
|
||||||
|
id: string;
|
||||||
|
answer: string;
|
||||||
|
hints: (string | null)[];
|
||||||
|
startTime: number; // 开始时间戳,用于计算 timeSpent
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 体力恢复倒计时实现建议
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 客户端体力恢复倒计时(纯 UI 展示)
|
||||||
|
function startStaminaTimer(staminaInfo: StaminaInfo) {
|
||||||
|
if (!staminaInfo.nextRecoverAt || staminaInfo.current >= staminaInfo.max) {
|
||||||
|
// 满体力,无需倒计时
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetTime = new Date(staminaInfo.nextRecoverAt).getTime();
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
const remaining = targetTime - Date.now();
|
||||||
|
if (remaining <= 0) {
|
||||||
|
// 恢复一点体力(本地模拟)
|
||||||
|
staminaInfo.current = Math.min(staminaInfo.current + 1, staminaInfo.max);
|
||||||
|
clearInterval(timer);
|
||||||
|
|
||||||
|
if (staminaInfo.current < staminaInfo.max) {
|
||||||
|
// 继续下一轮倒计时
|
||||||
|
staminaInfo.nextRecoverAt = new Date(Date.now() + 10 * 60 * 1000).toISOString();
|
||||||
|
startStaminaTimer(staminaInfo);
|
||||||
|
} else {
|
||||||
|
staminaInfo.nextRecoverAt = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 更新倒计时 UI
|
||||||
|
const minutes = Math.floor(remaining / 60000);
|
||||||
|
const seconds = Math.floor((remaining % 60000) / 1000);
|
||||||
|
updateStaminaUI(`${minutes}:${seconds.toString().padStart(2, '0')}`);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ 客户端体力倒计时仅用于 UI 展示。实际体力值以服务端返回为准,每次调用接口时会获得最新体力。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cocos Creator 调用示例
|
||||||
|
|
||||||
|
### 1. HTTP 请求工具类
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// HttpManager.ts
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data: T | null;
|
||||||
|
message: string | null;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StaminaInfo {
|
||||||
|
current: number;
|
||||||
|
max: number;
|
||||||
|
nextRecoverAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class HttpManager {
|
||||||
|
private baseUrl = 'https://your-api-domain.com/api';
|
||||||
|
private token: string | null = null;
|
||||||
|
|
||||||
|
setToken(token: string) {
|
||||||
|
this.token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
getToken(): string | null {
|
||||||
|
return this.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async request<T>(
|
||||||
|
method: 'GET' | 'POST',
|
||||||
|
url: string,
|
||||||
|
body?: object
|
||||||
|
): Promise<ApiResponse<T>> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open(method, this.baseUrl + url, true);
|
||||||
|
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
if (this.token) {
|
||||||
|
xhr.setRequestHeader('Authorization', `Bearer ${this.token}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.onload = () => {
|
||||||
|
try {
|
||||||
|
const resp = JSON.parse(xhr.responseText);
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
resolve(resp);
|
||||||
|
} else if (xhr.status === 401) {
|
||||||
|
// Token 过期,触发重新登录
|
||||||
|
this.token = null;
|
||||||
|
reject(new Error(resp.message || '登录已过期,请重新登录'));
|
||||||
|
} else {
|
||||||
|
reject(new Error(resp.message || `请求失败: ${xhr.status}`));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
reject(new Error(`请求失败: ${xhr.status}`));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = () => reject(new Error('网络错误'));
|
||||||
|
xhr.send(body ? JSON.stringify(body) : undefined);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get<T>(url: string) { return this.request<T>('GET', url); }
|
||||||
|
post<T>(url: string, body?: object) { return this.request<T>('POST', url, body); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const http = new HttpManager();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 微信登录
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function wxLogin() {
|
||||||
|
const { code } = await new Promise<{ code: string }>((resolve, reject) => {
|
||||||
|
wx.login({ success: (res) => resolve({ code: res.code }), fail: reject });
|
||||||
|
});
|
||||||
|
|
||||||
|
const resp = await http.post<{
|
||||||
|
token: string;
|
||||||
|
user: { id: string; nickname: string | null; stamina: number };
|
||||||
|
}>('/v1/auth/wx-login', { code });
|
||||||
|
|
||||||
|
if (resp.success && resp.data) {
|
||||||
|
http.setToken(resp.data.token);
|
||||||
|
wx.setStorageSync('jwt_token', resp.data.token);
|
||||||
|
return resp.data;
|
||||||
|
}
|
||||||
|
throw new Error(resp.message || '登录失败');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Loading 页面加载游戏数据
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface GameData {
|
||||||
|
user: { id: string; stamina: StaminaInfo };
|
||||||
|
completedLevelIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGameData(): Promise<GameData> {
|
||||||
|
const resp = await http.get<GameData>('/v1/user/game-data');
|
||||||
|
if (resp.success && resp.data) {
|
||||||
|
return resp.data;
|
||||||
|
}
|
||||||
|
throw new Error(resp.message || '加载游戏数据失败');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 获取关卡列表
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface LevelListItem {
|
||||||
|
id: string;
|
||||||
|
level: number;
|
||||||
|
imageUrl: string;
|
||||||
|
answer: string | null;
|
||||||
|
hint1: string | null;
|
||||||
|
hint2: string | null;
|
||||||
|
hint3: string | null;
|
||||||
|
completed: boolean;
|
||||||
|
timeSpent: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLevels(): Promise<LevelListItem[]> {
|
||||||
|
const resp = await http.get<{ levels: LevelListItem[]; total: number }>('/v1/levels');
|
||||||
|
if (resp.success && resp.data) {
|
||||||
|
return resp.data.levels;
|
||||||
|
}
|
||||||
|
throw new Error(resp.message || '获取关卡列表失败');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 进入关卡(核心接口)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface EnterLevelResponse {
|
||||||
|
id: string;
|
||||||
|
level: number;
|
||||||
|
imageUrl: string;
|
||||||
|
answer: string;
|
||||||
|
hint1: string | null;
|
||||||
|
hint2: string | null;
|
||||||
|
hint3: string | null;
|
||||||
|
stamina: StaminaInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enterLevel(levelId: string): Promise<EnterLevelResponse> {
|
||||||
|
const resp = await http.post<EnterLevelResponse>(`/v1/levels/${levelId}/enter`);
|
||||||
|
|
||||||
|
if (resp.success && resp.data) {
|
||||||
|
// 更新本地体力状态
|
||||||
|
updateLocalStamina(resp.data.stamina);
|
||||||
|
return resp.data;
|
||||||
|
}
|
||||||
|
throw new Error(resp.message || '进入关卡失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用示例
|
||||||
|
async function onClickLevel(levelId: string, currentStamina: number, isCompleted: boolean) {
|
||||||
|
if (!isCompleted && currentStamina <= 0) {
|
||||||
|
showToast('体力不足,请等待恢复');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const levelData = await enterLevel(levelId);
|
||||||
|
// 跳转到游戏页面,传入关卡数据
|
||||||
|
startGame(levelData);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 通关上报
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CompleteLevelResponse {
|
||||||
|
firstClear: boolean;
|
||||||
|
levelId: string;
|
||||||
|
timeSpent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeLevel(
|
||||||
|
levelId: string,
|
||||||
|
timeSpent: number
|
||||||
|
): Promise<CompleteLevelResponse> {
|
||||||
|
const resp = await http.post<CompleteLevelResponse>(
|
||||||
|
`/v1/levels/${levelId}/complete`,
|
||||||
|
{ timeSpent }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (resp.success && resp.data) {
|
||||||
|
if (resp.data.firstClear) {
|
||||||
|
showToast('恭喜通关!');
|
||||||
|
}
|
||||||
|
return resp.data;
|
||||||
|
}
|
||||||
|
throw new Error(resp.message || '上报通关失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用示例:用户答对时
|
||||||
|
async function onAnswerCorrect(levelId: string, startTime: number) {
|
||||||
|
const timeSpent = Math.floor((Date.now() - startTime) / 1000);
|
||||||
|
const result = await completeLevel(levelId, timeSpent);
|
||||||
|
// 更新关卡选择页面的状态
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. 完整启动流程
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// GameEntry.ts
|
||||||
|
import { _decorator, Component } from 'cc';
|
||||||
|
const { ccclass } = _decorator;
|
||||||
|
|
||||||
|
@ccclass('GameEntry')
|
||||||
|
export class GameEntry extends Component {
|
||||||
|
async start() {
|
||||||
|
try {
|
||||||
|
// 1. 尝试使用缓存 token
|
||||||
|
const cachedToken = wx.getStorageSync('jwt_token');
|
||||||
|
if (cachedToken) {
|
||||||
|
http.setToken(cachedToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 登录(获取最新 token)
|
||||||
|
const loginData = await wxLogin();
|
||||||
|
console.log('登录成功:', loginData.user.id);
|
||||||
|
|
||||||
|
// 3. 加载游戏数据
|
||||||
|
const gameData = await loadGameData();
|
||||||
|
console.log('体力:', gameData.user.stamina.current);
|
||||||
|
console.log('已通关:', gameData.completedLevelIds.length, '关');
|
||||||
|
|
||||||
|
// 4. 启动体力恢复倒计时
|
||||||
|
startStaminaTimer(gameData.user.stamina);
|
||||||
|
|
||||||
|
// 5. 获取关卡列表,进入关卡选择页面
|
||||||
|
const levels = await getLevels();
|
||||||
|
showLevelSelect(levels);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('启动失败:', error);
|
||||||
|
showRetryDialog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **Token 有效期**:JWT Token 有效期 7 天,建议本地缓存并在启动时尝试复用
|
||||||
|
2. **体力值以服务端为准**:客户端倒计时仅为 UI 展示,实际体力以接口返回为准
|
||||||
|
3. **进入关卡必须调用接口**:不要使用列表接口中缓存的关卡数据直接开始游戏,必须调用 `enter` 接口
|
||||||
|
4. **已通关关卡免费进入**:已通关关卡再次进入不消耗体力
|
||||||
|
5. **通关上报仅限成功**:只在用户答对后调用 `complete` 接口,答错不需要上报
|
||||||
|
6. **hint 字段**:`hint1/hint2/hint3` 可能为 `null`,表示该线索未配置
|
||||||
|
7. **网络异常处理**:建议所有接口调用加 loading 状态,并处理 401(重新登录)和网络错误
|
||||||
@@ -107,7 +107,7 @@ Authorization: Bearer <token>
|
|||||||
user: {
|
user: {
|
||||||
id: string; // 用户 ID
|
id: string; // 用户 ID
|
||||||
nickname: string | null; // 用户昵称(微信昵称)
|
nickname: string | null; // 用户昵称(微信昵称)
|
||||||
points: number; // 当前积分
|
stamina: number; // 当前体力值
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -122,7 +122,7 @@ Authorization: Bearer <token>
|
|||||||
"user": {
|
"user": {
|
||||||
"id": "user_abc123",
|
"id": "user_abc123",
|
||||||
"nickname": "游戏玩家",
|
"nickname": "游戏玩家",
|
||||||
"points": 10
|
"stamina": 5
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"message": null,
|
"message": null,
|
||||||
@@ -512,7 +512,7 @@ async function wxLogin() {
|
|||||||
try {
|
try {
|
||||||
const response = await httpManager.post<{
|
const response = await httpManager.post<{
|
||||||
token: string;
|
token: string;
|
||||||
user: { id: string; nickname: string | null; points: number };
|
user: { id: string; nickname: string | null; stamina: number };
|
||||||
}>('/v1/auth/wx-login', {
|
}>('/v1/auth/wx-login', {
|
||||||
code: wxLoginRes.code
|
code: wxLoginRes.code
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import { Module } from '@nestjs/common';
|
|||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { AppConfigModule } from './config/config.module';
|
import { AppConfigModule } from './config/config.module';
|
||||||
import { WechatGameModule } from './modules/wechat-game/wechat-game.module';
|
|
||||||
import { AuthModule } from './modules/auth/auth.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 { ShareModule } from './modules/share/share.module';
|
||||||
|
import { WechatGameModule } from './modules/wechat-game/wechat-game.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -25,8 +28,11 @@ import { ShareModule } from './modules/share/share.module';
|
|||||||
autoLoadEntities: true,
|
autoLoadEntities: true,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
WechatGameModule,
|
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
UserModule,
|
||||||
|
LevelModule,
|
||||||
|
GameConfigModule,
|
||||||
|
WechatGameModule, // 保留用于 entity/repository 导出
|
||||||
ShareModule,
|
ShareModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { validateEnvironment } from './env.validation';
|
|||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
load: [databaseConfig],
|
load: [databaseConfig],
|
||||||
validate: validateEnvironment,
|
validate: validateEnvironment,
|
||||||
envFilePath: ['.env.local', '.env.production', '.env'],
|
envFilePath: ['.env.local', '.env', '.env.production'],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
exports: [ConfigModule],
|
exports: [ConfigModule],
|
||||||
|
|||||||
@@ -3,22 +3,12 @@ import { JwtService } from '@nestjs/jwt';
|
|||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { ApiResponseDto } from '../../common/dto/api-response.dto';
|
import { ApiResponseDto } from '../../common/dto/api-response.dto';
|
||||||
import type { JwtPayload } from '../../common/guards/jwt-auth.guard';
|
|
||||||
|
|
||||||
describe('AuthController', () => {
|
describe('AuthController', () => {
|
||||||
let controller: AuthController;
|
let controller: AuthController;
|
||||||
|
|
||||||
const mockUser: JwtPayload = {
|
|
||||||
sub: 'user-uuid-1',
|
|
||||||
openid: 'wx-openid-123',
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockAuthService = {
|
const mockAuthService = {
|
||||||
wxLogin: jest.fn(),
|
wxLogin: jest.fn(),
|
||||||
getUserAssets: jest.fn(),
|
|
||||||
consumePoint: jest.fn(),
|
|
||||||
earnPoint: jest.fn(),
|
|
||||||
getGameData: jest.fn(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -41,7 +31,7 @@ describe('AuthController', () => {
|
|||||||
it('should return success response with token and user info', async () => {
|
it('should return success response with token and user info', async () => {
|
||||||
const loginResponse = {
|
const loginResponse = {
|
||||||
token: 'jwt-token',
|
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);
|
mockAuthService.wxLogin.mockResolvedValue(loginResponse);
|
||||||
|
|
||||||
@@ -53,68 +43,4 @@ describe('AuthController', () => {
|
|||||||
expect(mockAuthService.wxLogin).toHaveBeenCalledWith('wx-code-123');
|
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,31 +1,19 @@
|
|||||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Post } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ApiBearerAuth,
|
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { WxLoginRequestDto, WxLoginResponseDto } from './dto/wx-login.dto';
|
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 { 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('用户认证与资产')
|
@ApiTags('认证')
|
||||||
@Controller('v1')
|
@Controller('v1/auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly authService: AuthService) {}
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
// ==================== 公开接口 ====================
|
@Post('wx-login')
|
||||||
|
|
||||||
@Post('auth/wx-login')
|
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: '微信登录',
|
summary: '微信登录',
|
||||||
description: '使用微信 wx.login 返回的 code 换取 JWT 令牌',
|
description: '使用微信 wx.login 返回的 code 换取 JWT 令牌',
|
||||||
@@ -38,73 +26,4 @@ export class AuthController {
|
|||||||
const data = await this.authService.wxLogin(dto.code);
|
const data = await this.authService.wxLogin(dto.code);
|
||||||
return ApiResponseDto.success(data);
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,6 @@ import { UserLevelProgressRepository } from './repositories/user-level-progress.
|
|||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, UserRepository, UserLevelProgressRepository],
|
providers: [AuthService, UserRepository, UserLevelProgressRepository],
|
||||||
exports: [JwtModule, AuthService],
|
exports: [JwtModule, AuthService, UserRepository, UserLevelProgressRepository],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import {
|
import { UnauthorizedException } from '@nestjs/common';
|
||||||
UnauthorizedException,
|
|
||||||
BadRequestException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { UserRepository } from './repositories/user.repository';
|
import { UserRepository } from './repositories/user.repository';
|
||||||
import { UserLevelProgressRepository } from './repositories/user-level-progress.repository';
|
|
||||||
import { User } from './entities/user.entity';
|
import { User } from './entities/user.entity';
|
||||||
import { UserLevelProgress } from './entities/user-level-progress.entity';
|
|
||||||
|
|
||||||
jest.mock('axios');
|
jest.mock('axios');
|
||||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||||
@@ -24,20 +19,12 @@ describe('AuthService', () => {
|
|||||||
sessionKey: 'session-key-abc',
|
sessionKey: 'session-key-abc',
|
||||||
nickname: 'TestUser',
|
nickname: 'TestUser',
|
||||||
avatarUrl: null,
|
avatarUrl: null,
|
||||||
points: 10,
|
stamina: 5,
|
||||||
|
staminaUpdatedAt: null,
|
||||||
createdAt: new Date('2026-01-01'),
|
createdAt: new Date('2026-01-01'),
|
||||||
updatedAt: 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 = {
|
const mockUserRepository = {
|
||||||
findById: jest.fn(),
|
findById: jest.fn(),
|
||||||
findByOpenid: jest.fn(),
|
findByOpenid: jest.fn(),
|
||||||
@@ -45,13 +32,6 @@ describe('AuthService', () => {
|
|||||||
save: jest.fn(),
|
save: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockUserLevelProgressRepository = {
|
|
||||||
findByUserId: jest.fn(),
|
|
||||||
findByUserAndLevel: jest.fn(),
|
|
||||||
create: jest.fn(),
|
|
||||||
save: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockJwtService = {
|
const mockJwtService = {
|
||||||
signAsync: jest.fn(),
|
signAsync: jest.fn(),
|
||||||
};
|
};
|
||||||
@@ -73,10 +53,6 @@ describe('AuthService', () => {
|
|||||||
{ provide: ConfigService, useValue: mockConfigService },
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
{ provide: JwtService, useValue: mockJwtService },
|
{ provide: JwtService, useValue: mockJwtService },
|
||||||
{ provide: UserRepository, useValue: mockUserRepository },
|
{ provide: UserRepository, useValue: mockUserRepository },
|
||||||
{
|
|
||||||
provide: UserLevelProgressRepository,
|
|
||||||
useValue: mockUserLevelProgressRepository,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@@ -89,7 +65,7 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
describe('wxLogin', () => {
|
describe('wxLogin', () => {
|
||||||
it('should create a new user and return JWT token on first login', async () => {
|
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({
|
mockedAxios.get.mockResolvedValue({
|
||||||
data: { openid: 'wx-openid-123', session_key: 'session-key-abc' },
|
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.token).toBe('jwt-token-xyz');
|
||||||
expect(result.user.id).toBe('user-uuid-1');
|
expect(result.user.id).toBe('user-uuid-1');
|
||||||
expect(result.user.points).toBe(10);
|
expect(result.user.stamina).toBe(5);
|
||||||
expect(mockUserRepository.create).toHaveBeenCalledWith({
|
expect(mockUserRepository.create).toHaveBeenCalledWith({
|
||||||
openid: 'wx-openid-123',
|
openid: 'wx-openid-123',
|
||||||
sessionKey: 'session-key-abc',
|
sessionKey: 'session-key-abc',
|
||||||
points: 10,
|
stamina: 5,
|
||||||
});
|
});
|
||||||
expect(mockJwtService.signAsync).toHaveBeenCalledWith({
|
expect(mockJwtService.signAsync).toHaveBeenCalledWith({
|
||||||
sub: 'user-uuid-1',
|
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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,20 +2,12 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
BadRequestException,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { UserRepository } from './repositories/user.repository';
|
import { UserRepository } from './repositories/user.repository';
|
||||||
import { UserLevelProgressRepository } from './repositories/user-level-progress.repository';
|
|
||||||
import { WxLoginResponseDto, UserInfoDto } from './dto/wx-login.dto';
|
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';
|
import { JwtPayload } from '../../common/guards/jwt-auth.guard';
|
||||||
|
|
||||||
interface WxSessionResponse {
|
interface WxSessionResponse {
|
||||||
@@ -35,7 +27,6 @@ export class AuthService {
|
|||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly userRepository: UserRepository,
|
private readonly userRepository: UserRepository,
|
||||||
private readonly userLevelProgressRepository: UserLevelProgressRepository,
|
|
||||||
) {
|
) {
|
||||||
this.wxAppId = this.configService.get<string>('WX_APPID', '');
|
this.wxAppId = this.configService.get<string>('WX_APPID', '');
|
||||||
this.wxSecret = this.configService.get<string>('WX_SECRET', '');
|
this.wxSecret = this.configService.get<string>('WX_SECRET', '');
|
||||||
@@ -62,7 +53,7 @@ export class AuthService {
|
|||||||
user = this.userRepository.create({
|
user = this.userRepository.create({
|
||||||
openid: wxSession.openid,
|
openid: wxSession.openid,
|
||||||
sessionKey: wxSession.session_key ?? null,
|
sessionKey: wxSession.session_key ?? null,
|
||||||
points: 10, // 新用户默认 10 积分
|
stamina: 5, // 新用户默认 5 体力值
|
||||||
});
|
});
|
||||||
user = await this.userRepository.save(user);
|
user = await this.userRepository.save(user);
|
||||||
this.logger.log(`新用户注册: ${user.id}`);
|
this.logger.log(`新用户注册: ${user.id}`);
|
||||||
@@ -85,101 +76,12 @@ export class AuthService {
|
|||||||
const userInfo: UserInfoDto = {
|
const userInfo: UserInfoDto = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
nickname: user.nickname,
|
nickname: user.nickname,
|
||||||
points: user.points,
|
stamina: user.stamina,
|
||||||
};
|
};
|
||||||
|
|
||||||
return { token, user: userInfo };
|
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 接口
|
* 调用微信 jscode2session 接口
|
||||||
*/
|
*/
|
||||||
@@ -200,15 +102,4 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('微信服务调用失败,请重试');
|
throw new UnauthorizedException('微信服务调用失败,请重试');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 查找用户,不存在则抛异常
|
|
||||||
*/
|
|
||||||
private async findUserOrThrow(userId: string) {
|
|
||||||
const user = await this.userRepository.findById(userId);
|
|
||||||
if (!user) {
|
|
||||||
throw new UnauthorizedException('用户不存在');
|
|
||||||
}
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[];
|
|
||||||
}
|
|
||||||
@@ -15,8 +15,8 @@ export class UserInfoDto {
|
|||||||
@ApiProperty({ description: '用户昵称', nullable: true })
|
@ApiProperty({ description: '用户昵称', nullable: true })
|
||||||
nickname!: string | null;
|
nickname!: string | null;
|
||||||
|
|
||||||
@ApiProperty({ description: '积分' })
|
@ApiProperty({ description: '体力值' })
|
||||||
points!: number;
|
stamina!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class WxLoginResponseDto {
|
export class WxLoginResponseDto {
|
||||||
|
|||||||
@@ -25,9 +25,13 @@ export class User {
|
|||||||
@Column({ type: 'text', name: 'avatar_url', nullable: true })
|
@Column({ type: 'text', name: 'avatar_url', nullable: true })
|
||||||
avatarUrl!: string | null;
|
avatarUrl!: string | null;
|
||||||
|
|
||||||
/** 积分(默认 10) */
|
/** 体力值(默认 5,上限 5) */
|
||||||
@Column({ type: 'int', default: 10 })
|
@Column({ type: 'int', default: 5 })
|
||||||
points!: number;
|
stamina!: number;
|
||||||
|
|
||||||
|
/** 体力值最后更新时间(用于计算恢复) */
|
||||||
|
@Column({ type: 'timestamp', name: 'stamina_updated_at', nullable: true })
|
||||||
|
staminaUpdatedAt!: Date | null;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|||||||
@@ -5,4 +5,10 @@ export interface IUserRepository {
|
|||||||
findByOpenid(openid: string): Promise<User | null>;
|
findByOpenid(openid: string): Promise<User | null>;
|
||||||
create(data: Partial<User>): User;
|
create(data: Partial<User>): User;
|
||||||
save(user: User): Promise<User>;
|
save(user: User): Promise<User>;
|
||||||
|
updateStaminaAtomic(
|
||||||
|
userId: string,
|
||||||
|
expectedOldStamina: number,
|
||||||
|
newStamina: number,
|
||||||
|
staminaUpdatedAt: Date,
|
||||||
|
): Promise<{ affected: number }>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,4 +26,21 @@ export class UserRepository implements IUserRepository {
|
|||||||
async save(user: User): Promise<User> {
|
async save(user: User): Promise<User> {
|
||||||
return this.repository.save(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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
39
src/modules/game-config/game-config.controller.ts
Normal file
39
src/modules/game-config/game-config.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/modules/game-config/game-config.module.ts
Normal file
11
src/modules/game-config/game-config.module.ts
Normal 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 {}
|
||||||
42
src/modules/game-config/game-config.service.ts
Normal file
42
src/modules/game-config/game-config.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/modules/level/dto/complete-level.dto.ts
Normal file
21
src/modules/level/dto/complete-level.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
28
src/modules/level/dto/enter-level.dto.ts
Normal file
28
src/modules/level/dto/enter-level.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
38
src/modules/level/dto/level-list.dto.ts
Normal file
38
src/modules/level/dto/level-list.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
82
src/modules/level/level.controller.ts
Normal file
82
src/modules/level/level.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/modules/level/level.module.ts
Normal file
14
src/modules/level/level.module.ts
Normal 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 {}
|
||||||
146
src/modules/level/level.service.ts
Normal file
146
src/modules/level/level.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/modules/user/dto/user-profile.dto.ts
Normal file
34
src/modules/user/dto/user-profile.dto.ts
Normal 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[];
|
||||||
|
}
|
||||||
49
src/modules/user/user.controller.ts
Normal file
49
src/modules/user/user.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/modules/user/user.module.ts
Normal file
12
src/modules/user/user.module.ts
Normal 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 {}
|
||||||
125
src/modules/user/user.service.ts
Normal file
125
src/modules/user/user.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
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 { GameConfig } from './entities/game-config.entity';
|
||||||
import { Level } from './entities/level.entity';
|
import { Level } from './entities/level.entity';
|
||||||
import { GameConfigRepository } from './repositories/game-config.repository';
|
import { GameConfigRepository } from './repositories/game-config.repository';
|
||||||
import { LevelRepository } from './repositories/level.repository';
|
import { LevelRepository } from './repositories/level.repository';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保留此模块仅用于导出 entity/repository,供其他模块使用。
|
||||||
|
* 业务逻辑已迁移至 GameConfigModule、LevelModule。
|
||||||
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([GameConfig, Level])],
|
imports: [TypeOrmModule.forFeature([GameConfig, Level])],
|
||||||
controllers: [WechatGameController],
|
providers: [GameConfigRepository, LevelRepository],
|
||||||
providers: [WechatGameService, GameConfigRepository, LevelRepository],
|
exports: [LevelRepository, GameConfigRepository],
|
||||||
exports: [WechatGameService, LevelRepository],
|
|
||||||
})
|
})
|
||||||
export class WechatGameModule {}
|
export class WechatGameModule {}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user