From fe2c13258efe7d515d768def829d871a3f3e18c5 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Fri, 10 Apr 2026 09:07:50 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=8B=86=E5=88=86=E6=A0=B8?= =?UTF-8?q?=E5=BF=83=E7=8E=A9=E6=B3=95=E6=A8=A1=E5=9D=97=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E4=BB=A3=E7=A0=81=E8=B4=A8=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 WechatGame 单体模块拆分为独立的 User、Level、GameConfig 模块, 新增体力值系统、关卡闯关流程,并修复多项代码质量问题: - 体力不足错误码从 401 修正为 400 - enterLevel 改用 findById 替代全表扫描 - consumeStamina 增加原子更新防止并发竞态 - 并行化独立数据库查询 (Promise.all) - 移除 WechatGameService/Controller 死代码 --- docs/api/README.md | 10 +- docs/api/game-api.md | 972 ++++++++++++++++++ docs/api/share-challenge-api.md | 6 +- src/app.module.ts | 10 +- src/config/config.module.ts | 2 +- src/modules/auth/auth.controller.spec.ts | 76 +- src/modules/auth/auth.controller.ts | 89 +- src/modules/auth/auth.module.ts | 2 +- src/modules/auth/auth.service.spec.ts | 176 +--- src/modules/auth/auth.service.ts | 113 +- src/modules/auth/dto/user-assets.dto.ts | 52 - src/modules/auth/dto/wx-login.dto.ts | 4 +- src/modules/auth/entities/user.entity.ts | 10 +- .../repositories/user.repository.interface.ts | 6 + .../auth/repositories/user.repository.ts | 17 + .../game-config/game-config.controller.ts | 39 + src/modules/game-config/game-config.module.ts | 11 + .../game-config/game-config.service.ts | 42 + src/modules/level/dto/complete-level.dto.ts | 21 + src/modules/level/dto/enter-level.dto.ts | 28 + src/modules/level/dto/level-list.dto.ts | 38 + src/modules/level/level.controller.ts | 82 ++ src/modules/level/level.module.ts | 14 + src/modules/level/level.service.ts | 146 +++ src/modules/user/dto/user-profile.dto.ts | 34 + src/modules/user/user.controller.ts | 49 + src/modules/user/user.module.ts | 12 + src/modules/user/user.service.ts | 125 +++ .../wechat-game.controller.spec.ts | 133 --- .../wechat-game/wechat-game.controller.ts | 68 -- src/modules/wechat-game/wechat-game.module.ts | 11 +- .../wechat-game/wechat-game.service.spec.ts | 169 --- .../wechat-game/wechat-game.service.ts | 92 -- 33 files changed, 1681 insertions(+), 978 deletions(-) create mode 100644 docs/api/game-api.md delete mode 100644 src/modules/auth/dto/user-assets.dto.ts create mode 100644 src/modules/game-config/game-config.controller.ts create mode 100644 src/modules/game-config/game-config.module.ts create mode 100644 src/modules/game-config/game-config.service.ts create mode 100644 src/modules/level/dto/complete-level.dto.ts create mode 100644 src/modules/level/dto/enter-level.dto.ts create mode 100644 src/modules/level/dto/level-list.dto.ts create mode 100644 src/modules/level/level.controller.ts create mode 100644 src/modules/level/level.module.ts create mode 100644 src/modules/level/level.service.ts create mode 100644 src/modules/user/dto/user-profile.dto.ts create mode 100644 src/modules/user/user.controller.ts create mode 100644 src/modules/user/user.module.ts create mode 100644 src/modules/user/user.service.ts delete mode 100644 src/modules/wechat-game/wechat-game.controller.spec.ts delete mode 100644 src/modules/wechat-game/wechat-game.controller.ts delete mode 100644 src/modules/wechat-game/wechat-game.service.spec.ts delete mode 100644 src/modules/wechat-game/wechat-game.service.ts diff --git a/docs/api/README.md b/docs/api/README.md index 10df8c7..0013ac9 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -6,10 +6,8 @@ | 模块 | 文档文件 | 说明 | 状态 | |------|----------|------|------| -| 用户认证 | [auth-api.md](./auth-api.md) | 微信登录、JWT Token | 待编写 | -| 分享挑战 | [share-challenge-api.md](./share-challenge-api.md) | 创建分享、加入挑战、进度上报 | 已完成 | -| 游戏关卡 | [game-api.md](./game-api.md) | 关卡数据、答案验证 | 待编写 | -| 用户资产 | [user-assets-api.md](./user-assets-api.md) | 积分获取与消耗 | 待编写 | +| 核心玩法 | [game-api.md](./game-api.md) | 认证、体力值、关卡闯关、游戏配置 | ✅ 已完成 | +| 分享挑战 | [share-challenge-api.md](./share-challenge-api.md) | 创建分享、加入挑战、进度上报 | ✅ 已完成 | | 排行榜 | [leaderboard-api.md](./leaderboard-api.md) | 排名、分数上报 | 预留 | ## 文档维护规则 @@ -37,9 +35,9 @@ Authorization: Bearer ```json { "success": true, - "data": { ... }, + "data": { "..." : "..." }, "message": null, - "timestamp": "2026-04-08T12:00:00.000Z" + "timestamp": "2026-04-10T12:00:00.000Z" } ``` diff --git a/docs/api/game-api.md b/docs/api/game-api.md new file mode 100644 index 0000000..7d141d7 --- /dev/null +++ b/docs/api/game-api.md @@ -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` 为微信登录接口返回的 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; // 已通关关卡 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 { + 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( + method: 'GET' | 'POST', + url: string, + body?: object + ): Promise> { + 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(url: string) { return this.request('GET', url); } + post(url: string, body?: object) { return this.request('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 { + const resp = await http.get('/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 { + 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 { + const resp = await http.post(`/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 { + const resp = await http.post( + `/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(重新登录)和网络错误 diff --git a/docs/api/share-challenge-api.md b/docs/api/share-challenge-api.md index a269540..9790c30 100644 --- a/docs/api/share-challenge-api.md +++ b/docs/api/share-challenge-api.md @@ -107,7 +107,7 @@ Authorization: Bearer user: { id: string; // 用户 ID nickname: string | null; // 用户昵称(微信昵称) - points: number; // 当前积分 + stamina: number; // 当前体力值 } } ``` @@ -122,7 +122,7 @@ Authorization: Bearer "user": { "id": "user_abc123", "nickname": "游戏玩家", - "points": 10 + "stamina": 5 } }, "message": null, @@ -512,7 +512,7 @@ async function wxLogin() { try { const response = await httpManager.post<{ token: string; - user: { id: string; nickname: string | null; points: number }; + user: { id: string; nickname: string | null; stamina: number }; }>('/v1/auth/wx-login', { code: wxLoginRes.code }); diff --git a/src/app.module.ts b/src/app.module.ts index 37e158d..523daf4 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -2,9 +2,12 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AppConfigModule } from './config/config.module'; -import { WechatGameModule } from './modules/wechat-game/wechat-game.module'; import { AuthModule } from './modules/auth/auth.module'; +import { UserModule } from './modules/user/user.module'; +import { LevelModule } from './modules/level/level.module'; +import { GameConfigModule } from './modules/game-config/game-config.module'; import { ShareModule } from './modules/share/share.module'; +import { WechatGameModule } from './modules/wechat-game/wechat-game.module'; @Module({ imports: [ @@ -25,8 +28,11 @@ import { ShareModule } from './modules/share/share.module'; autoLoadEntities: true, }), }), - WechatGameModule, AuthModule, + UserModule, + LevelModule, + GameConfigModule, + WechatGameModule, // 保留用于 entity/repository 导出 ShareModule, ], }) diff --git a/src/config/config.module.ts b/src/config/config.module.ts index 2ea12b4..2f834ca 100644 --- a/src/config/config.module.ts +++ b/src/config/config.module.ts @@ -10,7 +10,7 @@ import { validateEnvironment } from './env.validation'; isGlobal: true, load: [databaseConfig], validate: validateEnvironment, - envFilePath: ['.env.local', '.env.production', '.env'], + envFilePath: ['.env.local', '.env', '.env.production'], }), ], exports: [ConfigModule], diff --git a/src/modules/auth/auth.controller.spec.ts b/src/modules/auth/auth.controller.spec.ts index 56ee77f..88b802f 100644 --- a/src/modules/auth/auth.controller.spec.ts +++ b/src/modules/auth/auth.controller.spec.ts @@ -3,22 +3,12 @@ import { JwtService } from '@nestjs/jwt'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { ApiResponseDto } from '../../common/dto/api-response.dto'; -import type { JwtPayload } from '../../common/guards/jwt-auth.guard'; describe('AuthController', () => { let controller: AuthController; - const mockUser: JwtPayload = { - sub: 'user-uuid-1', - openid: 'wx-openid-123', - }; - const mockAuthService = { wxLogin: jest.fn(), - getUserAssets: jest.fn(), - consumePoint: jest.fn(), - earnPoint: jest.fn(), - getGameData: jest.fn(), }; beforeEach(async () => { @@ -41,7 +31,7 @@ describe('AuthController', () => { it('should return success response with token and user info', async () => { const loginResponse = { token: 'jwt-token', - user: { id: 'user-uuid-1', nickname: 'Test', points: 10 }, + user: { id: 'user-uuid-1', nickname: 'Test', stamina: 5 }, }; mockAuthService.wxLogin.mockResolvedValue(loginResponse); @@ -53,68 +43,4 @@ describe('AuthController', () => { expect(mockAuthService.wxLogin).toHaveBeenCalledWith('wx-code-123'); }); }); - - describe('getUserAssets', () => { - it('should return success response with user points', async () => { - mockAuthService.getUserAssets.mockResolvedValue({ points: 10 }); - - const result = await controller.getUserAssets(mockUser); - - expect(result.success).toBe(true); - expect(result.data).toEqual({ points: 10 }); - expect(mockAuthService.getUserAssets).toHaveBeenCalledWith('user-uuid-1'); - }); - }); - - describe('consumePoint', () => { - it('should return success response with updated points', async () => { - mockAuthService.consumePoint.mockResolvedValue({ points: 9 }); - const dto = { reason: 'hint_unlock' as const, levelId: 'level-1', hintIndex: 2 }; - - const result = await controller.consumePoint(mockUser, dto); - - expect(result.success).toBe(true); - expect(result.data).toEqual({ points: 9 }); - expect(mockAuthService.consumePoint).toHaveBeenCalledWith( - 'user-uuid-1', - dto, - ); - }); - }); - - describe('earnPoint', () => { - it('should return success response with updated points', async () => { - mockAuthService.earnPoint.mockResolvedValue({ points: 11 }); - const dto = { - reason: 'level_complete' as const, - levelId: 'level-1', - timeSpent: 30, - }; - - const result = await controller.earnPoint(mockUser, dto); - - expect(result.success).toBe(true); - expect(result.data).toEqual({ points: 11 }); - expect(mockAuthService.earnPoint).toHaveBeenCalledWith( - 'user-uuid-1', - dto, - ); - }); - }); - - describe('getGameData', () => { - it('should return success response with game data', async () => { - const gameData = { - user: { id: 'user-uuid-1', points: 10 }, - completedLevelIds: ['level-1', 'level-2'], - }; - mockAuthService.getGameData.mockResolvedValue(gameData); - - const result = await controller.getGameData(mockUser); - - expect(result.success).toBe(true); - expect(result.data).toEqual(gameData); - expect(mockAuthService.getGameData).toHaveBeenCalledWith('user-uuid-1'); - }); - }); }); diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 1a9bd08..ba44d3f 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -1,31 +1,19 @@ -import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Post } from '@nestjs/common'; import { - ApiBearerAuth, ApiOperation, ApiResponse, ApiTags, } from '@nestjs/swagger'; import { AuthService } from './auth.service'; import { WxLoginRequestDto, WxLoginResponseDto } from './dto/wx-login.dto'; -import { - ConsumePointRequestDto, - EarnPointRequestDto, - GameDataResponseDto, - UserAssetsResponseDto, -} from './dto/user-assets.dto'; import { ApiResponseDto } from '../../common/dto/api-response.dto'; -import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; -import type { JwtPayload } from '../../common/guards/jwt-auth.guard'; -import { CurrentUser } from '../../common/decorators/current-user.decorator'; -@ApiTags('用户认证与资产') -@Controller('v1') +@ApiTags('认证') +@Controller('v1/auth') export class AuthController { constructor(private readonly authService: AuthService) {} - // ==================== 公开接口 ==================== - - @Post('auth/wx-login') + @Post('wx-login') @ApiOperation({ summary: '微信登录', description: '使用微信 wx.login 返回的 code 换取 JWT 令牌', @@ -38,73 +26,4 @@ export class AuthController { const data = await this.authService.wxLogin(dto.code); return ApiResponseDto.success(data); } - - // ==================== 需要鉴权的接口 ==================== - - @Get('user/assets') - @UseGuards(JwtAuthGuard) - @ApiBearerAuth() - @ApiOperation({ - summary: '获取用户积分', - description: '获取当前登录用户的积分信息', - }) - @ApiResponse({ status: 200, description: '成功' }) - @ApiResponse({ status: 401, description: '未授权' }) - async getUserAssets( - @CurrentUser() user: JwtPayload, - ): Promise> { - 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> { - 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> { - 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> { - const data = await this.authService.getGameData(user.sub); - return ApiResponseDto.success(data); - } } diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 088b5e7..daf9ce1 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -23,6 +23,6 @@ import { UserLevelProgressRepository } from './repositories/user-level-progress. ], controllers: [AuthController], providers: [AuthService, UserRepository, UserLevelProgressRepository], - exports: [JwtModule, AuthService], + exports: [JwtModule, AuthService, UserRepository, UserLevelProgressRepository], }) export class AuthModule {} diff --git a/src/modules/auth/auth.service.spec.ts b/src/modules/auth/auth.service.spec.ts index 997b682..4ca934b 100644 --- a/src/modules/auth/auth.service.spec.ts +++ b/src/modules/auth/auth.service.spec.ts @@ -1,16 +1,11 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; -import { - UnauthorizedException, - BadRequestException, -} from '@nestjs/common'; +import { UnauthorizedException } from '@nestjs/common'; import axios from 'axios'; import { AuthService } from './auth.service'; import { UserRepository } from './repositories/user.repository'; -import { UserLevelProgressRepository } from './repositories/user-level-progress.repository'; import { User } from './entities/user.entity'; -import { UserLevelProgress } from './entities/user-level-progress.entity'; jest.mock('axios'); const mockedAxios = axios as jest.Mocked; @@ -24,20 +19,12 @@ describe('AuthService', () => { sessionKey: 'session-key-abc', nickname: 'TestUser', avatarUrl: null, - points: 10, + stamina: 5, + staminaUpdatedAt: null, createdAt: new Date('2026-01-01'), updatedAt: new Date('2026-01-01'), }; - const mockLevelProgress: UserLevelProgress = { - id: 'progress-uuid-1', - userId: 'user-uuid-1', - levelId: 'level-1', - user: mockUser, - timeSpent: 30, - completedAt: new Date('2026-01-02'), - }; - const mockUserRepository = { findById: jest.fn(), findByOpenid: jest.fn(), @@ -45,13 +32,6 @@ describe('AuthService', () => { save: jest.fn(), }; - const mockUserLevelProgressRepository = { - findByUserId: jest.fn(), - findByUserAndLevel: jest.fn(), - create: jest.fn(), - save: jest.fn(), - }; - const mockJwtService = { signAsync: jest.fn(), }; @@ -73,10 +53,6 @@ describe('AuthService', () => { { provide: ConfigService, useValue: mockConfigService }, { provide: JwtService, useValue: mockJwtService }, { provide: UserRepository, useValue: mockUserRepository }, - { - provide: UserLevelProgressRepository, - useValue: mockUserLevelProgressRepository, - }, ], }).compile(); @@ -89,7 +65,7 @@ describe('AuthService', () => { describe('wxLogin', () => { it('should create a new user and return JWT token on first login', async () => { - const newUser = { ...mockUser, points: 10 }; + const newUser = { ...mockUser, stamina: 5 }; mockedAxios.get.mockResolvedValue({ data: { openid: 'wx-openid-123', session_key: 'session-key-abc' }, }); @@ -102,11 +78,11 @@ describe('AuthService', () => { expect(result.token).toBe('jwt-token-xyz'); expect(result.user.id).toBe('user-uuid-1'); - expect(result.user.points).toBe(10); + expect(result.user.stamina).toBe(5); expect(mockUserRepository.create).toHaveBeenCalledWith({ openid: 'wx-openid-123', sessionKey: 'session-key-abc', - points: 10, + stamina: 5, }); expect(mockJwtService.signAsync).toHaveBeenCalledWith({ sub: 'user-uuid-1', @@ -150,144 +126,4 @@ describe('AuthService', () => { ); }); }); - - describe('getUserAssets', () => { - it('should return user points', async () => { - mockUserRepository.findById.mockResolvedValue(mockUser); - - const result = await service.getUserAssets('user-uuid-1'); - - expect(result.points).toBe(10); - expect(mockUserRepository.findById).toHaveBeenCalledWith('user-uuid-1'); - }); - - it('should throw UnauthorizedException when user not found', async () => { - mockUserRepository.findById.mockResolvedValue(null); - - await expect(service.getUserAssets('nonexistent')).rejects.toThrow( - UnauthorizedException, - ); - }); - }); - - describe('consumePoint', () => { - it('should deduct 1 point and return updated points', async () => { - const user = { ...mockUser, points: 5 }; - const savedUser = { ...user, points: 4 }; - mockUserRepository.findById.mockResolvedValue(user); - mockUserRepository.save.mockResolvedValue(savedUser); - - const result = await service.consumePoint('user-uuid-1', { - reason: 'hint_unlock', - levelId: 'level-1', - hintIndex: 2, - }); - - expect(result.points).toBe(4); - expect(mockUserRepository.save).toHaveBeenCalled(); - }); - - it('should throw BadRequestException when points are 0', async () => { - mockUserRepository.findById.mockResolvedValue({ - ...mockUser, - points: 0, - }); - - await expect( - service.consumePoint('user-uuid-1', { - reason: 'hint_unlock', - }), - ).rejects.toThrow(BadRequestException); - }); - - it('should throw UnauthorizedException when user not found', async () => { - mockUserRepository.findById.mockResolvedValue(null); - - await expect( - service.consumePoint('nonexistent', { reason: 'hint_unlock' }), - ).rejects.toThrow(UnauthorizedException); - }); - }); - - describe('earnPoint', () => { - const earnDto = { - reason: 'level_complete' as const, - levelId: 'level-1', - timeSpent: 30, - }; - - it('should award 1 point for first-time level completion', async () => { - const user = { ...mockUser, points: 10 }; - mockUserRepository.findById.mockResolvedValue(user); - mockUserLevelProgressRepository.findByUserAndLevel.mockResolvedValue(null); - mockUserLevelProgressRepository.create.mockReturnValue(mockLevelProgress); - mockUserLevelProgressRepository.save.mockResolvedValue(mockLevelProgress); - mockUserRepository.save.mockResolvedValue({ ...user, points: 11 }); - - const result = await service.earnPoint('user-uuid-1', earnDto); - - expect(result.points).toBe(11); - expect(mockUserLevelProgressRepository.create).toHaveBeenCalledWith({ - userId: 'user-uuid-1', - levelId: 'level-1', - timeSpent: 30, - }); - }); - - it('should not award duplicate points for already completed level', async () => { - const user = { ...mockUser, points: 10 }; - mockUserRepository.findById.mockResolvedValue(user); - mockUserLevelProgressRepository.findByUserAndLevel.mockResolvedValue( - mockLevelProgress, - ); - - const result = await service.earnPoint('user-uuid-1', earnDto); - - expect(result.points).toBe(10); - expect(mockUserLevelProgressRepository.create).not.toHaveBeenCalled(); - expect(mockUserRepository.save).not.toHaveBeenCalled(); - }); - - it('should throw UnauthorizedException when user not found', async () => { - mockUserRepository.findById.mockResolvedValue(null); - - await expect( - service.earnPoint('nonexistent', earnDto), - ).rejects.toThrow(UnauthorizedException); - }); - }); - - describe('getGameData', () => { - it('should return user info and completed level IDs', async () => { - mockUserRepository.findById.mockResolvedValue(mockUser); - mockUserLevelProgressRepository.findByUserId.mockResolvedValue([ - { ...mockLevelProgress, levelId: 'level-1' }, - { ...mockLevelProgress, levelId: 'level-2' }, - ]); - - const result = await service.getGameData('user-uuid-1'); - - expect(result.user.id).toBe('user-uuid-1'); - expect(result.user.points).toBe(10); - expect(result.completedLevelIds).toEqual(['level-1', 'level-2']); - }); - - it('should return empty completedLevelIds when no progress', async () => { - mockUserRepository.findById.mockResolvedValue(mockUser); - mockUserLevelProgressRepository.findByUserId.mockResolvedValue([]); - - const result = await service.getGameData('user-uuid-1'); - - expect(result.completedLevelIds).toEqual([]); - }); - - it('should throw UnauthorizedException when user not found', async () => { - mockUserRepository.findById.mockResolvedValue(null); - mockUserLevelProgressRepository.findByUserId.mockResolvedValue([]); - - await expect(service.getGameData('nonexistent')).rejects.toThrow( - UnauthorizedException, - ); - }); - }); }); diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 204cb0e..813c6bc 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -2,20 +2,12 @@ import { Injectable, Logger, UnauthorizedException, - BadRequestException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import axios from 'axios'; import { UserRepository } from './repositories/user.repository'; -import { UserLevelProgressRepository } from './repositories/user-level-progress.repository'; import { WxLoginResponseDto, UserInfoDto } from './dto/wx-login.dto'; -import { - UserAssetsResponseDto, - ConsumePointRequestDto, - EarnPointRequestDto, - GameDataResponseDto, -} from './dto/user-assets.dto'; import { JwtPayload } from '../../common/guards/jwt-auth.guard'; interface WxSessionResponse { @@ -35,7 +27,6 @@ export class AuthService { private readonly configService: ConfigService, private readonly jwtService: JwtService, private readonly userRepository: UserRepository, - private readonly userLevelProgressRepository: UserLevelProgressRepository, ) { this.wxAppId = this.configService.get('WX_APPID', ''); this.wxSecret = this.configService.get('WX_SECRET', ''); @@ -62,7 +53,7 @@ export class AuthService { user = this.userRepository.create({ openid: wxSession.openid, sessionKey: wxSession.session_key ?? null, - points: 10, // 新用户默认 10 积分 + stamina: 5, // 新用户默认 5 体力值 }); user = await this.userRepository.save(user); this.logger.log(`新用户注册: ${user.id}`); @@ -85,101 +76,12 @@ export class AuthService { const userInfo: UserInfoDto = { id: user.id, nickname: user.nickname, - points: user.points, + stamina: user.stamina, }; return { token, user: userInfo }; } - /** - * 获取用户积分 - */ - async getUserAssets(userId: string): Promise { - const user = await this.findUserOrThrow(userId); - return { points: user.points }; - } - - /** - * 消耗积分(解锁提示) - */ - async consumePoint( - userId: string, - dto: ConsumePointRequestDto, - ): Promise { - 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 { - 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 { - const [user, progressList] = await Promise.all([ - this.findUserOrThrow(userId), - this.userLevelProgressRepository.findByUserId(userId), - ]); - const completedLevelIds = progressList.map((p) => p.levelId); - - return { - user: { - id: user.id, - points: user.points, - }, - completedLevelIds, - }; - } - /** * 调用微信 jscode2session 接口 */ @@ -200,15 +102,4 @@ export class AuthService { throw new UnauthorizedException('微信服务调用失败,请重试'); } } - - /** - * 查找用户,不存在则抛异常 - */ - private async findUserOrThrow(userId: string) { - const user = await this.userRepository.findById(userId); - if (!user) { - throw new UnauthorizedException('用户不存在'); - } - return user; - } } diff --git a/src/modules/auth/dto/user-assets.dto.ts b/src/modules/auth/dto/user-assets.dto.ts deleted file mode 100644 index 0e4708d..0000000 --- a/src/modules/auth/dto/user-assets.dto.ts +++ /dev/null @@ -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[]; -} diff --git a/src/modules/auth/dto/wx-login.dto.ts b/src/modules/auth/dto/wx-login.dto.ts index 3f9a716..fd99da3 100644 --- a/src/modules/auth/dto/wx-login.dto.ts +++ b/src/modules/auth/dto/wx-login.dto.ts @@ -15,8 +15,8 @@ export class UserInfoDto { @ApiProperty({ description: '用户昵称', nullable: true }) nickname!: string | null; - @ApiProperty({ description: '积分' }) - points!: number; + @ApiProperty({ description: '体力值' }) + stamina!: number; } export class WxLoginResponseDto { diff --git a/src/modules/auth/entities/user.entity.ts b/src/modules/auth/entities/user.entity.ts index 8951de6..3c4f8ea 100644 --- a/src/modules/auth/entities/user.entity.ts +++ b/src/modules/auth/entities/user.entity.ts @@ -25,9 +25,13 @@ export class User { @Column({ type: 'text', name: 'avatar_url', nullable: true }) avatarUrl!: string | null; - /** 积分(默认 10) */ - @Column({ type: 'int', default: 10 }) - points!: number; + /** 体力值(默认 5,上限 5) */ + @Column({ type: 'int', default: 5 }) + stamina!: number; + + /** 体力值最后更新时间(用于计算恢复) */ + @Column({ type: 'timestamp', name: 'stamina_updated_at', nullable: true }) + staminaUpdatedAt!: Date | null; @CreateDateColumn({ name: 'created_at' }) createdAt!: Date; diff --git a/src/modules/auth/repositories/user.repository.interface.ts b/src/modules/auth/repositories/user.repository.interface.ts index 49203b2..677748e 100644 --- a/src/modules/auth/repositories/user.repository.interface.ts +++ b/src/modules/auth/repositories/user.repository.interface.ts @@ -5,4 +5,10 @@ export interface IUserRepository { findByOpenid(openid: string): Promise; create(data: Partial): User; save(user: User): Promise; + updateStaminaAtomic( + userId: string, + expectedOldStamina: number, + newStamina: number, + staminaUpdatedAt: Date, + ): Promise<{ affected: number }>; } diff --git a/src/modules/auth/repositories/user.repository.ts b/src/modules/auth/repositories/user.repository.ts index cd33326..af9a3ea 100644 --- a/src/modules/auth/repositories/user.repository.ts +++ b/src/modules/auth/repositories/user.repository.ts @@ -26,4 +26,21 @@ export class UserRepository implements IUserRepository { async save(user: User): Promise { 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 }; + } } diff --git a/src/modules/game-config/game-config.controller.ts b/src/modules/game-config/game-config.controller.ts new file mode 100644 index 0000000..e174fbb --- /dev/null +++ b/src/modules/game-config/game-config.controller.ts @@ -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> { + 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> { + const data = await this.gameConfigService.getConfigByKey(key); + return ApiResponseDto.success(data); + } +} diff --git a/src/modules/game-config/game-config.module.ts b/src/modules/game-config/game-config.module.ts new file mode 100644 index 0000000..c901fc4 --- /dev/null +++ b/src/modules/game-config/game-config.module.ts @@ -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 {} diff --git a/src/modules/game-config/game-config.service.ts b/src/modules/game-config/game-config.service.ts new file mode 100644 index 0000000..1419e29 --- /dev/null +++ b/src/modules/game-config/game-config.service.ts @@ -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 { + const configs = await this.gameConfigRepository.findActiveConfigs(); + return { + configs: configs.map((config) => this.toResponseDto(config)), + total: configs.length, + }; + } + + async getConfigByKey(key: string): Promise { + 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, + }; + } +} diff --git a/src/modules/level/dto/complete-level.dto.ts b/src/modules/level/dto/complete-level.dto.ts new file mode 100644 index 0000000..d32c23c --- /dev/null +++ b/src/modules/level/dto/complete-level.dto.ts @@ -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; +} diff --git a/src/modules/level/dto/enter-level.dto.ts b/src/modules/level/dto/enter-level.dto.ts new file mode 100644 index 0000000..6b7f49c --- /dev/null +++ b/src/modules/level/dto/enter-level.dto.ts @@ -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; +} diff --git a/src/modules/level/dto/level-list.dto.ts b/src/modules/level/dto/level-list.dto.ts new file mode 100644 index 0000000..46a51b6 --- /dev/null +++ b/src/modules/level/dto/level-list.dto.ts @@ -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; +} diff --git a/src/modules/level/level.controller.ts b/src/modules/level/level.controller.ts new file mode 100644 index 0000000..8a7de7c --- /dev/null +++ b/src/modules/level/level.controller.ts @@ -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> { + 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> { + 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> { + const data = await this.levelService.completeLevel(user.sub, id, dto); + return ApiResponseDto.success(data); + } +} diff --git a/src/modules/level/level.module.ts b/src/modules/level/level.module.ts new file mode 100644 index 0000000..41782ea --- /dev/null +++ b/src/modules/level/level.module.ts @@ -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 {} diff --git a/src/modules/level/level.service.ts b/src/modules/level/level.service.ts new file mode 100644 index 0000000..379b528 --- /dev/null +++ b/src/modules/level/level.service.ts @@ -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 { + 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 { + // 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 { + // 并行验证关卡存在和检查通关记录 + 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, + }; + } +} diff --git a/src/modules/user/dto/user-profile.dto.ts b/src/modules/user/dto/user-profile.dto.ts new file mode 100644 index 0000000..df4f2a7 --- /dev/null +++ b/src/modules/user/dto/user-profile.dto.ts @@ -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[]; +} diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts new file mode 100644 index 0000000..2433285 --- /dev/null +++ b/src/modules/user/user.controller.ts @@ -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> { + 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> { + const data = await this.userService.getGameData(user.sub); + return ApiResponseDto.success(data); + } +} diff --git a/src/modules/user/user.module.ts b/src/modules/user/user.module.ts new file mode 100644 index 0000000..9c63193 --- /dev/null +++ b/src/modules/user/user.module.ts @@ -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 {} diff --git a/src/modules/user/user.service.ts b/src/modules/user/user.service.ts new file mode 100644 index 0000000..0e96c52 --- /dev/null +++ b/src/modules/user/user.service.ts @@ -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 { + const user = await this.findUserOrThrow(userId); + const stamina = this.computeStamina(user); + + return { + id: user.id, + nickname: user.nickname, + stamina, + }; + } + + async getGameData(userId: string): Promise { + 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 { + const user = await this.userRepository.findById(userId); + if (!user) { + throw new UnauthorizedException('用户不存在'); + } + return user; + } +} diff --git a/src/modules/wechat-game/wechat-game.controller.spec.ts b/src/modules/wechat-game/wechat-game.controller.spec.ts deleted file mode 100644 index 47835b6..0000000 --- a/src/modules/wechat-game/wechat-game.controller.spec.ts +++ /dev/null @@ -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); - }); - - 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', - ); - }); - }); -}); diff --git a/src/modules/wechat-game/wechat-game.controller.ts b/src/modules/wechat-game/wechat-game.controller.ts deleted file mode 100644 index 0162143..0000000 --- a/src/modules/wechat-game/wechat-game.controller.ts +++ /dev/null @@ -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> { - 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> { - 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> { - 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> { - const data = await this.wechatGameService.getLevelById(id); - return ApiResponseDto.success(data); - } -} diff --git a/src/modules/wechat-game/wechat-game.module.ts b/src/modules/wechat-game/wechat-game.module.ts index d3884d5..584956c 100644 --- a/src/modules/wechat-game/wechat-game.module.ts +++ b/src/modules/wechat-game/wechat-game.module.ts @@ -1,16 +1,17 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { WechatGameController } from './wechat-game.controller'; -import { WechatGameService } from './wechat-game.service'; import { GameConfig } from './entities/game-config.entity'; import { Level } from './entities/level.entity'; import { GameConfigRepository } from './repositories/game-config.repository'; import { LevelRepository } from './repositories/level.repository'; +/** + * 保留此模块仅用于导出 entity/repository,供其他模块使用。 + * 业务逻辑已迁移至 GameConfigModule、LevelModule。 + */ @Module({ imports: [TypeOrmModule.forFeature([GameConfig, Level])], - controllers: [WechatGameController], - providers: [WechatGameService, GameConfigRepository, LevelRepository], - exports: [WechatGameService, LevelRepository], + providers: [GameConfigRepository, LevelRepository], + exports: [LevelRepository, GameConfigRepository], }) export class WechatGameModule {} diff --git a/src/modules/wechat-game/wechat-game.service.spec.ts b/src/modules/wechat-game/wechat-game.service.spec.ts deleted file mode 100644 index 95a8538..0000000 --- a/src/modules/wechat-game/wechat-game.service.spec.ts +++ /dev/null @@ -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); - }); - - 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, - ); - }); - }); -}); diff --git a/src/modules/wechat-game/wechat-game.service.ts b/src/modules/wechat-game/wechat-game.service.ts deleted file mode 100644 index 5671bf9..0000000 --- a/src/modules/wechat-game/wechat-game.service.ts +++ /dev/null @@ -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 { - const configs = await this.gameConfigRepository.findActiveConfigs(); - - return { - configs: configs.map((config) => this.toResponseDto(config)), - total: configs.length, - }; - } - - async getConfigByKey(key: string): Promise { - 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 { - 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 { - 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, - }; - } -}