Compare commits
4 Commits
2dd22b10b1
...
c775d5c6b0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c775d5c6b0 | ||
|
|
df05b7280c | ||
|
|
2d0fee8a9a | ||
|
|
3d52cfe843 |
44
.agents/skills/javascript-typescript-jest/SKILL.md
Normal file
44
.agents/skills/javascript-typescript-jest/SKILL.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
name: javascript-typescript-jest
|
||||||
|
description: 'Best practices for writing JavaScript/TypeScript tests using Jest, including mocking strategies, test structure, and common patterns.'
|
||||||
|
---
|
||||||
|
|
||||||
|
### Test Structure
|
||||||
|
- Name test files with `.test.ts` or `.test.js` suffix
|
||||||
|
- Place test files next to the code they test or in a dedicated `__tests__` directory
|
||||||
|
- Use descriptive test names that explain the expected behavior
|
||||||
|
- Use nested describe blocks to organize related tests
|
||||||
|
- Follow the pattern: `describe('Component/Function/Class', () => { it('should do something', () => {}) })`
|
||||||
|
|
||||||
|
### Effective Mocking
|
||||||
|
- Mock external dependencies (APIs, databases, etc.) to isolate your tests
|
||||||
|
- Use `jest.mock()` for module-level mocks
|
||||||
|
- Use `jest.spyOn()` for specific function mocks
|
||||||
|
- Use `mockImplementation()` or `mockReturnValue()` to define mock behavior
|
||||||
|
- Reset mocks between tests with `jest.resetAllMocks()` in `afterEach`
|
||||||
|
|
||||||
|
### Testing Async Code
|
||||||
|
- Always return promises or use async/await syntax in tests
|
||||||
|
- Use `resolves`/`rejects` matchers for promises
|
||||||
|
- Set appropriate timeouts for slow tests with `jest.setTimeout()`
|
||||||
|
|
||||||
|
### Snapshot Testing
|
||||||
|
- Use snapshot tests for UI components or complex objects that change infrequently
|
||||||
|
- Keep snapshots small and focused
|
||||||
|
- Review snapshot changes carefully before committing
|
||||||
|
|
||||||
|
### Testing React Components
|
||||||
|
- Use React Testing Library over Enzyme for testing components
|
||||||
|
- Test user behavior and component accessibility
|
||||||
|
- Query elements by accessibility roles, labels, or text content
|
||||||
|
- Use `userEvent` over `fireEvent` for more realistic user interactions
|
||||||
|
|
||||||
|
## Common Jest Matchers
|
||||||
|
- Basic: `expect(value).toBe(expected)`, `expect(value).toEqual(expected)`
|
||||||
|
- Truthiness: `expect(value).toBeTruthy()`, `expect(value).toBeFalsy()`
|
||||||
|
- Numbers: `expect(value).toBeGreaterThan(3)`, `expect(value).toBeLessThanOrEqual(3)`
|
||||||
|
- Strings: `expect(value).toMatch(/pattern/)`, `expect(value).toContain('substring')`
|
||||||
|
- Arrays: `expect(array).toContain(item)`, `expect(array).toHaveLength(3)`
|
||||||
|
- Objects: `expect(object).toHaveProperty('key', value)`
|
||||||
|
- Exceptions: `expect(fn).toThrow()`, `expect(fn).toThrow(Error)`
|
||||||
|
- Mock functions: `expect(mockFn).toHaveBeenCalled()`, `expect(mockFn).toHaveBeenCalledWith(arg1, arg2)`
|
||||||
194
.claude/skills/api-doc-maintainer/SKILL.md
Normal file
194
.claude/skills/api-doc-maintainer/SKILL.md
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
---
|
||||||
|
name: api-doc-maintainer
|
||||||
|
description: >
|
||||||
|
当用户修改、新增或删除任何 API 接口时,同步更新 docs/api/ 下的 Markdown 文档。
|
||||||
|
触发场景:修改 Controller、DTO、Service 中的接口逻辑或参数,新增/删除 endpoint,
|
||||||
|
变更响应结构,添加错误码。只要涉及 src/modules/*/ 下的接口代码改动,都应触发此技能。
|
||||||
|
---
|
||||||
|
|
||||||
|
# API 文档维护技能
|
||||||
|
|
||||||
|
## 为什么文档同步如此重要
|
||||||
|
|
||||||
|
MemeMind 的客户端是 Cocos Creator 小游戏,客户端开发者依赖 `docs/api/` 下的文档来接入后端接口。文档滞后意味着客户端开发者要去读源码才能对接,这会严重拖慢开发节奏。所以每次接口变更,文档必须同步更新。
|
||||||
|
|
||||||
|
## 代码结构索引
|
||||||
|
|
||||||
|
在更新文档前,先确认变更涉及哪些文件:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/modules/
|
||||||
|
├── auth/ # 认证模块:wx-login、user assets、game-data
|
||||||
|
│ ├── auth.controller.ts
|
||||||
|
│ ├── dto/
|
||||||
|
│ └── auth.service.ts
|
||||||
|
├── wechat-game/ # 游戏模块:levels、configs
|
||||||
|
│ ├── wechat-game.controller.ts
|
||||||
|
│ ├── dto/
|
||||||
|
│ └── wechat-game.service.ts
|
||||||
|
└── share/ # 分享挑战模块:创建分享、加入、进度上报
|
||||||
|
├── share.controller.ts
|
||||||
|
├── dto/
|
||||||
|
└── share.service.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Controller 定义了路由和参数,DTO 定义了请求/响应的数据结构,Service 包含业务逻辑。三者共同决定文档内容。
|
||||||
|
|
||||||
|
## 文档存放与组织
|
||||||
|
|
||||||
|
文档统一放在 `docs/api/` 目录下,按功能模块分文件:
|
||||||
|
|
||||||
|
| 模块 | 文档文件 | 对应代码 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| 用户认证 | `auth-api.md` | `src/modules/auth/` |
|
||||||
|
| 游戏关卡 | `game-api.md` | `src/modules/wechat-game/` |
|
||||||
|
| 分享挑战 | `share-challenge-api.md` | `src/modules/share/` |
|
||||||
|
| 排行榜 | `leaderboard-api.md` | (预留) |
|
||||||
|
|
||||||
|
新增模块时,创建对应的文档文件并在 `docs/api/README.md` 中注册索引。
|
||||||
|
|
||||||
|
## 文档结构
|
||||||
|
|
||||||
|
每个模块的 API 文档包含以下章节:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 模块名称 API
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
- YYYY-MM-DD: 变更说明
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
功能说明、前置依赖
|
||||||
|
|
||||||
|
## 认证方式
|
||||||
|
(如需要)说明鉴权方式
|
||||||
|
|
||||||
|
## 通用响应格式
|
||||||
|
(引用统一响应格式即可)
|
||||||
|
|
||||||
|
## 接口列表
|
||||||
|
### 1. 接口名称
|
||||||
|
(按下方模板)
|
||||||
|
|
||||||
|
## 错误码说明
|
||||||
|
| 错误码 | HTTP 状态码 | 说明 | 触发条件 |
|
||||||
|
|
||||||
|
## 接入流程
|
||||||
|
典型业务场景的调用顺序
|
||||||
|
|
||||||
|
## 客户端示例
|
||||||
|
Cocos Creator TypeScript 调用代码
|
||||||
|
```
|
||||||
|
|
||||||
|
## 接口文档模板
|
||||||
|
|
||||||
|
根据接口类型智能选择模板,不需要填写不适用的字段。
|
||||||
|
|
||||||
|
### 查询类接口(GET,无请求体)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### N. 接口名称
|
||||||
|
|
||||||
|
**接口地址**:`GET /api/v1/{module}/{endpoint}`
|
||||||
|
|
||||||
|
**是否需要认证**:是/否
|
||||||
|
|
||||||
|
**路径参数**(如有):
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
|
||||||
|
**查询参数**(如有):
|
||||||
|
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||||
|
|------|------|------|--------|------|
|
||||||
|
|
||||||
|
**成功响应示例**:
|
||||||
|
```json
|
||||||
|
{ "success": true, "data": { ... }, "message": null, "timestamp": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
**业务逻辑说明**:
|
||||||
|
- 规则 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 写入类接口(POST/PUT/PATCH/DELETE,有请求体)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### N. 接口名称
|
||||||
|
|
||||||
|
**接口地址**:`POST /api/v1/{module}/{endpoint}`
|
||||||
|
|
||||||
|
**是否需要认证**:是/否
|
||||||
|
|
||||||
|
**请求体**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"field": "value"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**字段说明**:
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
|
||||||
|
**成功响应示例**:
|
||||||
|
```json
|
||||||
|
{ "success": true, "data": { ... }, "message": null, "timestamp": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
**失败响应示例**(如有特殊错误场景):
|
||||||
|
```json
|
||||||
|
{ "success": false, "data": null, "message": "错误描述", "timestamp": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
**业务逻辑说明**:
|
||||||
|
- 规则 1
|
||||||
|
```
|
||||||
|
|
||||||
|
如果某个字段(如路径参数、查询参数)不适用于当前接口,直接省略该小节。
|
||||||
|
|
||||||
|
## 统一响应格式
|
||||||
|
|
||||||
|
所有 API 响应使用统一包装:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
success: boolean; // 请求是否成功
|
||||||
|
data: T | null; // 响应数据
|
||||||
|
message: string | null; // 错误信息(成功时为 null)
|
||||||
|
timestamp: string; // ISO 8601 时间戳
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 更新工作流
|
||||||
|
|
||||||
|
当接口代码发生变更时,按以下步骤操作:
|
||||||
|
|
||||||
|
### 新增接口
|
||||||
|
1. 在对应模块文档中按模板添加接口章节
|
||||||
|
2. 从 Controller 读取路由路径和方法,从 DTO 读取参数定义
|
||||||
|
3. 补充业务逻辑说明(来自 Service)
|
||||||
|
4. 添加 Cocos Creator 调用示例
|
||||||
|
5. 更新 Changelog
|
||||||
|
6. 在 `docs/api/README.md` 索引中确认已注册
|
||||||
|
|
||||||
|
### 修改接口
|
||||||
|
1. 对比代码变更,确定文档中哪些内容需要更新
|
||||||
|
2. 更新受影响的字段:路径、参数、响应结构、业务逻辑
|
||||||
|
3. 更新示例响应(确保与实际代码一致)
|
||||||
|
4. 在 Changelog 中记录变更
|
||||||
|
|
||||||
|
### 删除接口
|
||||||
|
1. 在文档中标记为「已废弃」并注明替代方案,或直接移除
|
||||||
|
2. 更新索引
|
||||||
|
3. 在 Changelog 中记录
|
||||||
|
|
||||||
|
### 错误码变更
|
||||||
|
1. 在错误码表中新增/修改/删除条目
|
||||||
|
2. 注明触发条件
|
||||||
|
|
||||||
|
## 完成后输出
|
||||||
|
|
||||||
|
文档更新完成后,向用户简要报告:
|
||||||
|
1. 哪些文档被修改了
|
||||||
|
2. 具体变更了什么内容
|
||||||
|
3. 是否有需要客户端配合调整的地方
|
||||||
1
.claude/skills/javascript-typescript-jest
Symbolic link
1
.claude/skills/javascript-typescript-jest
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/javascript-typescript-jest
|
||||||
1
.codebuddy/skills/javascript-typescript-jest
Symbolic link
1
.codebuddy/skills/javascript-typescript-jest
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/javascript-typescript-jest
|
||||||
@@ -10,6 +10,6 @@ NODE_ENV=production
|
|||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
|
|
||||||
WX_APPID=wx0f9c909d20d19396
|
WX_APPID=wxaf07f8bb60098991
|
||||||
WX_SECRET=c5635680747cccf351f5f323c01178e6
|
WX_SECRET=b390c318fe83600e60fd4ad5d88b603f
|
||||||
JWT_SECRET=mp-xieyingen
|
JWT_SECRET=mp-xieyingen
|
||||||
1
.kilocode/skills/javascript-typescript-jest
Symbolic link
1
.kilocode/skills/javascript-typescript-jest
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/javascript-typescript-jest
|
||||||
54
docs/api/README.md
Normal file
54
docs/api/README.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# MemeMind API 文档索引
|
||||||
|
|
||||||
|
> 本目录包含 MemeMind 微信小游戏所有 API 接口文档。
|
||||||
|
|
||||||
|
## 文档列表
|
||||||
|
|
||||||
|
| 模块 | 文档文件 | 说明 | 状态 |
|
||||||
|
|------|----------|------|------|
|
||||||
|
| 用户认证 | [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) | 积分获取与消耗 | 待编写 |
|
||||||
|
| 排行榜 | [leaderboard-api.md](./leaderboard-api.md) | 排名、分数上报 | 预留 |
|
||||||
|
|
||||||
|
## 文档维护规则
|
||||||
|
|
||||||
|
每当接口发生变更时,必须同步更新对应文档:
|
||||||
|
|
||||||
|
- **新增接口** → 在对应模块文档中新增章节
|
||||||
|
- **修改接口** → 更新现有章节内容
|
||||||
|
- **删除接口** → 标记废弃或移除并通知客户端
|
||||||
|
|
||||||
|
详见:[API 文档维护技能](../../.claude/skills/api-doc-maintainer/SKILL.md)(项目级 Skill)
|
||||||
|
|
||||||
|
## 通用规范
|
||||||
|
|
||||||
|
### 认证方式
|
||||||
|
所有需要认证的接口通过 JWT Bearer Token 鉴权:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 响应格式
|
||||||
|
所有接口返回统一格式:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": { ... },
|
||||||
|
"message": null,
|
||||||
|
"timestamp": "2026-04-08T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 基础路径
|
||||||
|
```
|
||||||
|
生产环境:https://api.mememind.com/api
|
||||||
|
本地开发:http://localhost:3000/api
|
||||||
|
```
|
||||||
|
|
||||||
|
## 客户端接入
|
||||||
|
|
||||||
|
客户端基于 Cocos Creator 开发,接入示例详见各模块文档。
|
||||||
741
docs/api/share-challenge-api.md
Normal file
741
docs/api/share-challenge-api.md
Normal file
@@ -0,0 +1,741 @@
|
|||||||
|
# MemeMind 分享挑战功能 API 文档
|
||||||
|
|
||||||
|
> 本文档面向微信小游戏客户端(Cocos Creator)开发人员
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- [概述](#概述)
|
||||||
|
- [认证方式](#认证方式)
|
||||||
|
- [通用响应格式](#通用响应格式)
|
||||||
|
- [接口列表](#接口列表)
|
||||||
|
- [错误码说明](#错误码说明)
|
||||||
|
- [接入流程](#接入流程)
|
||||||
|
- [Cocos Creator 调用示例](#cocos-creator-调用示例)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
分享挑战功能允许用户创建包含 6 个关卡的挑战链接,分享给好友。好友通过分享码加入挑战,独立完成关卡并上报进度。
|
||||||
|
|
||||||
|
### 新增功能点(当前分支)
|
||||||
|
|
||||||
|
1. **关卡时间限制**:`levels` 表新增 `time_limit` 字段,支持关卡通关时间限制
|
||||||
|
2. **单关进度上报**:`POST /api/v1/share/progress` 接口,用于上报用户单关通关状态和时间
|
||||||
|
3. **进度查询**:`reportLevelProgress` 返回是否在时间限制内通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 认证方式
|
||||||
|
|
||||||
|
除微信登录接口外,所有接口均需通过 JWT Token 进行身份认证。
|
||||||
|
|
||||||
|
### 请求头格式
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
`token` 为微信登录接口返回的 JWT 令牌。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 通用响应格式
|
||||||
|
|
||||||
|
所有接口均返回以下 JSON 结构:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
"success": boolean, // 请求是否成功
|
||||||
|
"data": T | null, // 成功时返回的数据,失败时为 null
|
||||||
|
"message": string | null,// 错误信息,成功时为 null
|
||||||
|
"timestamp": string // 服务器响应时间(ISO 8601 格式)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 成功响应示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"shareCode": "abc12345",
|
||||||
|
"title": "我的挑战",
|
||||||
|
"levelCount": 6
|
||||||
|
},
|
||||||
|
"message": null,
|
||||||
|
"timestamp": "2026-04-08T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 失败响应示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"data": null,
|
||||||
|
"message": "分享不存在或已过期",
|
||||||
|
"timestamp": "2026-04-08T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 接口列表
|
||||||
|
|
||||||
|
### 1. 微信登录
|
||||||
|
|
||||||
|
获取用户身份令牌。
|
||||||
|
|
||||||
|
**接口地址**:`POST /api/v1/auth/wx-login`
|
||||||
|
|
||||||
|
**是否需要认证**:否
|
||||||
|
|
||||||
|
**请求体**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "微信 wx.login 返回的 code"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应数据**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
token: string; // JWT 访问令牌,有效期 7 天
|
||||||
|
user: {
|
||||||
|
id: string; // 用户 ID
|
||||||
|
nickname: string | null; // 用户昵称(微信昵称)
|
||||||
|
points: number; // 当前积分
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**成功响应示例**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||||
|
"user": {
|
||||||
|
"id": "user_abc123",
|
||||||
|
"nickname": "游戏玩家",
|
||||||
|
"points": 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"message": null,
|
||||||
|
"timestamp": "2026-04-08T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**客户端调用时机**:
|
||||||
|
- 用户首次进入游戏时调用
|
||||||
|
- 小游戏冷启动时调用(建议缓存 token)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 创建分享
|
||||||
|
|
||||||
|
创建一个新的分享挑战。
|
||||||
|
|
||||||
|
**接口地址**:`POST /api/v1/share`
|
||||||
|
|
||||||
|
**是否需要认证**:是(JWT Bearer Token)
|
||||||
|
|
||||||
|
**请求头**:
|
||||||
|
```
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求体**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "我的挑战", // 分享标题,不超过 100 字符
|
||||||
|
"levelIds": [ // 恰好 6 个关卡 ID
|
||||||
|
"level_id_1",
|
||||||
|
"level_id_2",
|
||||||
|
"level_id_3",
|
||||||
|
"level_id_4",
|
||||||
|
"level_id_5",
|
||||||
|
"level_id_6"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应数据**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
shareCode: string; // 8 位分享码,用于分享和加入
|
||||||
|
title: string; // 分享标题
|
||||||
|
levelCount: number; // 关卡数量(固定为 6)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**成功响应示例**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"shareCode": "abc12345",
|
||||||
|
"title": "我的挑战",
|
||||||
|
"levelCount": 6
|
||||||
|
},
|
||||||
|
"message": null,
|
||||||
|
"timestamp": "2026-04-08T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**分享码生成规则**:
|
||||||
|
- 使用 nanoid 生成 8 位字符
|
||||||
|
- 字符集为 a-z, A-Z, 0-9
|
||||||
|
- 发生碰撞时最多重试 3 次
|
||||||
|
|
||||||
|
**客户端调用场景**:
|
||||||
|
- 用户点击「分享挑战」按钮时调用
|
||||||
|
- 用户选择 6 个关卡后,生成分享码
|
||||||
|
- 将分享码拼接为分享链接或二维码
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 加入分享
|
||||||
|
|
||||||
|
通过分享码加入一个分享挑战,获取关卡数据。
|
||||||
|
|
||||||
|
**接口地址**:`POST /api/v1/share/{code}/join`
|
||||||
|
|
||||||
|
**是否需要认证**:是(JWT Bearer Token)
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| code | string | 是 | 分享码(8 位) |
|
||||||
|
|
||||||
|
**响应数据**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
shareCode: string; // 分享码
|
||||||
|
title: string; // 分享标题
|
||||||
|
levels: [
|
||||||
|
{
|
||||||
|
id: string; // 关卡 ID
|
||||||
|
level: number; // 关卡序号(1-6)
|
||||||
|
imageUrl: string; // 关卡图片 URL
|
||||||
|
answer: string; // 正确答案
|
||||||
|
hint1: string | null; // 提示 1
|
||||||
|
hint2: string | null; // 提示 2
|
||||||
|
hint3: string | null; // 提示 3
|
||||||
|
sortOrder: number; // 排序顺序
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**成功响应示例**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"shareCode": "abc12345",
|
||||||
|
"title": "我的挑战",
|
||||||
|
"levels": [
|
||||||
|
{
|
||||||
|
"id": "level_001",
|
||||||
|
"level": 1,
|
||||||
|
"imageUrl": "https://example.com/levels/1.png",
|
||||||
|
"answer": "答案1",
|
||||||
|
"hint1": "提示1",
|
||||||
|
"hint2": null,
|
||||||
|
"hint3": null,
|
||||||
|
"sortOrder": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"message": null,
|
||||||
|
"timestamp": "2026-04-08T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**特殊逻辑**:
|
||||||
|
- 如果 `userId` 与分享创建者相同,不会创建 `ShareParticipant` 记录
|
||||||
|
- 返回的关卡列表按 `levelIds` 创建时的顺序排列
|
||||||
|
|
||||||
|
**客户端调用场景**:
|
||||||
|
- 用户通过分享码/链接进入游戏时调用
|
||||||
|
- 解析 URL 参数中的分享码,调用此接口获取关卡数据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 上报单关进度
|
||||||
|
|
||||||
|
用户在分享挑战中完成单关后,上报进度。
|
||||||
|
|
||||||
|
**接口地址**:`POST /api/v1/share/progress`
|
||||||
|
|
||||||
|
**是否需要认证**:是(JWT Bearer Token)
|
||||||
|
|
||||||
|
**请求体**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"shareCode": "abc12345", // 分享码
|
||||||
|
"levelId": "level_001", // 关卡 ID
|
||||||
|
"passed": true, // 是否通过(true/false)
|
||||||
|
"timeSpent": 30 // 通关时间(秒)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**字段说明**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| shareCode | string | 是 | 分享码 |
|
||||||
|
| levelId | string | 是 | 关卡 ID |
|
||||||
|
| passed | boolean | 是 | 是否通过 |
|
||||||
|
| timeSpent | number | 是 | 通关时间(秒),最小值为 0 |
|
||||||
|
|
||||||
|
**响应数据**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
passed: boolean; // 是否通过
|
||||||
|
timeLimit: number | null; // 该关卡时间限制(秒),null 表示无限制
|
||||||
|
withinTimeLimit: boolean; // 是否在时间限制内通过
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**成功响应示例**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"passed": true,
|
||||||
|
"timeLimit": 60,
|
||||||
|
"withinTimeLimit": true
|
||||||
|
},
|
||||||
|
"message": null,
|
||||||
|
"timestamp": "2026-04-08T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**业务逻辑说明**:
|
||||||
|
|
||||||
|
1. **首次通关记录**:只有首次 `passed=true` 才会记录通关时间
|
||||||
|
2. **重复通关**:如果用户再次通关同一关卡(且之前已通过),返回之前记录的时间判断结果,不会覆盖
|
||||||
|
3. **未通过**:可以多次上报 `passed=false`,更新通关时间记录
|
||||||
|
4. **时间限制判断**:
|
||||||
|
- 如果关卡 `timeLimit` 为 `null`,`withinTimeLimit` 始终为 `true`
|
||||||
|
- 如果 `timeLimit` 不为 `null`,只有 `timeSpent <= timeLimit` 时 `withinTimeLimit` 才为 `true`
|
||||||
|
|
||||||
|
**客户端调用场景**:
|
||||||
|
- 用户完成一个关卡后调用
|
||||||
|
- 无论通关还是失败都需要调用
|
||||||
|
- 失败时 `passed=false`,`timeSpent` 可以传入实际用时或关卡时间上限
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 错误码说明
|
||||||
|
|
||||||
|
| HTTP Status | message | 说明 |
|
||||||
|
|-------------|---------|------|
|
||||||
|
| 400 | 关卡ID不能重复,需要恰好6个不同的关卡 | 创建分享时 levelIds 格式错误 |
|
||||||
|
| 400 | 以下关卡不存在: xxx | 创建分享时关卡 ID 不存在 |
|
||||||
|
| 400 | 生成分享码失败,请重试 | 服务器生成分享码失败 |
|
||||||
|
| 401 | 未提供访问令牌 | 请求头缺少 Authorization |
|
||||||
|
| 401 | 访问令牌无效或已过期 | JWT Token 无效或过期 |
|
||||||
|
| 404 | 分享不存在或已过期 | 分享码不存在或已被删除 |
|
||||||
|
| 404 | 关卡不存在 | levelId 不存在于 levels 表 |
|
||||||
|
| 500 | Internal server error | 服务器内部错误 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 接入流程
|
||||||
|
|
||||||
|
### 整体流程
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 分享发起方 │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ 1. 用户选择 6 个关卡 │
|
||||||
|
│ 2. 调用 POST /api/v1/share 创建分享 │
|
||||||
|
│ 3. 获取 8 位分享码 │
|
||||||
|
│ 4. 生成分享链接/二维码,分享给好友 │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 分享接收方 │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ 1. 通过分享链接/二维码进入游戏 │
|
||||||
|
│ 2. 解析分享码 │
|
||||||
|
│ 3. 调用 POST /api/v1/auth/wx-login 获取/确认身份 │
|
||||||
|
│ 4. 调用 POST /api/v1/share/{code}/join 加入挑战 │
|
||||||
|
│ 5. 获取 6 个关卡数据,开始挑战 │
|
||||||
|
│ 6. 每完成一关,调用 POST /api/v1/share/progress 上报进度 │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 客户端状态管理建议
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 建议在客户端维护以下状态
|
||||||
|
|
||||||
|
interface ShareChallengeState {
|
||||||
|
isInChallenge: boolean; // 是否正在参与分享挑战
|
||||||
|
shareCode: string | null; // 当前分享码
|
||||||
|
currentLevelIndex: number; // 当前关卡索引(0-5)
|
||||||
|
levels: LevelData[]; // 关卡数据
|
||||||
|
progress: {
|
||||||
|
[levelId: string]: {
|
||||||
|
passed: boolean;
|
||||||
|
timeSpent: number;
|
||||||
|
withinTimeLimit: boolean;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 分享链接格式建议
|
||||||
|
|
||||||
|
```
|
||||||
|
wechatminiprogram://pages/challenge?code=abc12345
|
||||||
|
```
|
||||||
|
|
||||||
|
或在微信中使用 `wx.openUrl` 打开 H5 页面,H5 页面再跳转到小程序。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cocos Creator 调用示例
|
||||||
|
|
||||||
|
### 1. HTTP 请求工具类
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// HttpManager.ts
|
||||||
|
import { Color } from 'cc';
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data: T | null;
|
||||||
|
message: string | null;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 = () => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
resolve(JSON.parse(xhr.responseText));
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const errorResp = JSON.parse(xhr.responseText);
|
||||||
|
reject(new Error(errorResp.message || '请求失败'));
|
||||||
|
} catch {
|
||||||
|
reject(new Error(`请求失败: ${xhr.status}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = () => {
|
||||||
|
reject(new Error('网络错误'));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
xhr.send(JSON.stringify(body));
|
||||||
|
} else {
|
||||||
|
xhr.send();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET 请求
|
||||||
|
async get<T>(url: string): Promise<ApiResponse<T>> {
|
||||||
|
return this.request<T>('GET', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST 请求
|
||||||
|
async post<T>(url: string, body: object): Promise<ApiResponse<T>> {
|
||||||
|
return this.request<T>('POST', url, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const httpManager = new HttpManager();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 微信登录
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 调用时机:小游戏启动时,或缓存的 token 过期时
|
||||||
|
async function wxLogin() {
|
||||||
|
// 1. 调用微信 wx.login 获取 code
|
||||||
|
const wxLoginRes = await new Promise<{ code: string }>((resolve, reject) => {
|
||||||
|
wx.login({
|
||||||
|
success: (res) => resolve({ code: res.code }),
|
||||||
|
fail: reject
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 将 code 发送到服务器换取 token
|
||||||
|
try {
|
||||||
|
const response = await httpManager.post<{
|
||||||
|
token: string;
|
||||||
|
user: { id: string; nickname: string | null; points: number };
|
||||||
|
}>('/v1/auth/wx-login', {
|
||||||
|
code: wxLoginRes.code
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
// 3. 缓存 token
|
||||||
|
httpManager.setToken(response.data.token);
|
||||||
|
wx.setStorageSync('jwt_token', response.data.token);
|
||||||
|
|
||||||
|
console.log('登录成功,用户信息:', response.data.user);
|
||||||
|
return response.data;
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || '登录失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('登录失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 创建分享
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CreateShareResponse {
|
||||||
|
shareCode: string;
|
||||||
|
title: string;
|
||||||
|
levelCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createShare(title: string, levelIds: string[]): Promise<CreateShareResponse> {
|
||||||
|
// 确保已登录
|
||||||
|
if (!httpManager.getToken()) {
|
||||||
|
await wxLogin();
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await httpManager.post<CreateShareResponse>('/v1/share', {
|
||||||
|
title,
|
||||||
|
levelIds
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
console.log('分享创建成功:', response.data);
|
||||||
|
return response.data;
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || '创建分享失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用示例
|
||||||
|
const levelIds = ['level_1', 'level_2', 'level_3', 'level_4', 'level_5', 'level_6'];
|
||||||
|
const share = await createShare('一起来挑战!', levelIds);
|
||||||
|
console.log('分享码:', share.shareCode);
|
||||||
|
// 生成分享链接: `https://your-game.com/invite?code=${share.shareCode}`
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 加入分享
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface LevelData {
|
||||||
|
id: string;
|
||||||
|
level: number;
|
||||||
|
imageUrl: string;
|
||||||
|
answer: string;
|
||||||
|
hint1: string | null;
|
||||||
|
hint2: string | null;
|
||||||
|
hint3: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JoinShareResponse {
|
||||||
|
shareCode: string;
|
||||||
|
title: string;
|
||||||
|
levels: LevelData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function joinShare(shareCode: string): Promise<JoinShareResponse> {
|
||||||
|
// 确保已登录
|
||||||
|
if (!httpManager.getToken()) {
|
||||||
|
await wxLogin();
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await httpManager.post<JoinShareResponse>(
|
||||||
|
`/v1/share/${shareCode}/join`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
console.log('加入分享成功:', response.data);
|
||||||
|
return response.data;
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || '加入分享失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用示例
|
||||||
|
// 从分享链接中获取分享码
|
||||||
|
const shareCode = 'abc12345'; // 从 URL 参数解析
|
||||||
|
const shareData = await joinShare(shareCode);
|
||||||
|
// 保存关卡数据,开始游戏
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 上报关卡进度
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ReportProgressResponse {
|
||||||
|
passed: boolean;
|
||||||
|
timeLimit: number | null;
|
||||||
|
withinTimeLimit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reportLevelProgress(
|
||||||
|
shareCode: string,
|
||||||
|
levelId: string,
|
||||||
|
passed: boolean,
|
||||||
|
timeSpent: number
|
||||||
|
): Promise<ReportProgressResponse> {
|
||||||
|
// 确保已登录
|
||||||
|
if (!httpManager.getToken()) {
|
||||||
|
await wxLogin();
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await httpManager.post<ReportProgressResponse>(
|
||||||
|
'/v1/share/progress',
|
||||||
|
{
|
||||||
|
shareCode,
|
||||||
|
levelId,
|
||||||
|
passed,
|
||||||
|
timeSpent
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
console.log('进度上报成功:', response.data);
|
||||||
|
return response.data;
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || '进度上报失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用示例
|
||||||
|
async function onLevelComplete(levelId: string, timeSpent: number) {
|
||||||
|
const passed = true; // 根据游戏逻辑判断是否通过
|
||||||
|
const result = await reportLevelProgress(this.shareCode, levelId, passed, timeSpent);
|
||||||
|
|
||||||
|
if (result.passed) {
|
||||||
|
console.log(`通关成功!${result.withinTimeLimit ? '在' : '超出'}时间限制内完成`);
|
||||||
|
if (result.timeLimit) {
|
||||||
|
console.log(`本关时间限制: ${result.timeLimit}秒`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 启动流程示例
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// GameEntry.ts - 游戏入口脚本
|
||||||
|
import { _decorator, Component } from 'cc';
|
||||||
|
const { ccclass } = _decorator;
|
||||||
|
|
||||||
|
@ccclass('GameEntry')
|
||||||
|
export class GameEntry extends Component {
|
||||||
|
async start() {
|
||||||
|
// 1. 检查本地缓存的 token
|
||||||
|
const cachedToken = wx.getStorageSync('jwt_token');
|
||||||
|
if (cachedToken) {
|
||||||
|
httpManager.setToken(cachedToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 尝试登录(无论是否有缓存 token)
|
||||||
|
try {
|
||||||
|
await wxLogin();
|
||||||
|
console.log('登录成功');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('登录失败:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 检查是否有分享码(从启动参数或 URL 获取)
|
||||||
|
const launchOptions = wx.getLaunchOptionsSync();
|
||||||
|
console.log('启动参数:', launchOptions);
|
||||||
|
|
||||||
|
// 解析分享码(具体解析方式根据你的分享链接格式)
|
||||||
|
// if (launchOptions.query && launchOptions.query.code) {
|
||||||
|
// await joinShare(launchOptions.query.code);
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关卡数据结构
|
||||||
|
|
||||||
|
### LevelData 完整字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | string | 关卡唯一标识 |
|
||||||
|
| level | number | 关卡序号(1-6) |
|
||||||
|
| imageUrl | string | 关卡图片 URL |
|
||||||
|
| answer | string | 正确答案 |
|
||||||
|
| hint1 | string \| null | 第 1 个提示 |
|
||||||
|
| hint2 | string \| null | 第 2 个提示 |
|
||||||
|
| hint3 | string \| null | 第 3 个提示 |
|
||||||
|
| sortOrder | number | 排序顺序 |
|
||||||
|
|
||||||
|
### 关卡时间限制说明
|
||||||
|
|
||||||
|
`timeLimit` 字段在关卡数据结构中**不直接返回**,而是通过上报进度接口返回。
|
||||||
|
|
||||||
|
如果需要在前端判断时间限制:
|
||||||
|
1. 用户完成关卡后调用 `reportLevelProgress`
|
||||||
|
2. 从返回的 `timeLimit` 字段获取当前关卡的时间限制
|
||||||
|
3. 从返回的 `withinTimeLimit` 字段判断是否在时间内完成
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **Token 有效期**:JWT Token 有效期为 7 天,客户端应缓存并在启动时使用
|
||||||
|
2. **重复通关**:首次通关记录会被保留,重复通关不会覆盖之前的记录
|
||||||
|
3. **分享码格式**:8 位字母数字组合,大小写敏感
|
||||||
|
4. **关卡数量**:每次分享挑战固定包含 6 个关卡
|
||||||
|
5. **网络异常**:建议在调用接口时显示 loading 状态,并处理网络异常情况
|
||||||
|
6. **hint 字段**:`hint1/hint2/hint3` 可能为 `null`,表示该提示未配置
|
||||||
|
7. **时间限制**:`timeLimit` 为 `null` 表示该关卡没有时间限制
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
# 分享挑战关卡进度记录功能实现计划
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 实现用户在分享挑战中的单关通关进度上报功能,支持记录通关时间、是否通过、时间限制判断。
|
||||||
|
|
||||||
|
**Architecture:** 在现有 Share 模块基础上,新增 ShareLevelProgress 实体和 Repository,通过 ShareService.reportLevelProgress 方法处理业务逻辑,在 ShareController 新增 POST /v1/share/progress 接口。
|
||||||
|
|
||||||
|
**Tech Stack:** NestJS, TypeORM, MySQL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件清单
|
||||||
|
|
||||||
|
| 操作 | 文件路径 |
|
||||||
|
|------|----------|
|
||||||
|
| 新增 | src/modules/share/entities/share-level-progress.entity.ts |
|
||||||
|
| 新增 | src/modules/share/repositories/share-level-progress.repository.ts |
|
||||||
|
| 新增 | src/modules/share/dto/report-level-progress.dto.ts |
|
||||||
|
| 新增 | src/modules/share/dto/share-level-progress-response.dto.ts |
|
||||||
|
| 修改 | src/modules/share/repositories/share-participant.repository.ts |
|
||||||
|
| 修改 | src/modules/share/share.service.ts |
|
||||||
|
| 修改 | src/modules/share/share.controller.ts |
|
||||||
|
| 修改 | src/modules/share/share.module.ts |
|
||||||
|
| 修改 | src/modules/wechat-game/entities/level.entity.ts |
|
||||||
|
| 修改 | src/database/migrations/*.sql |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Level 实体增加 timeLimit 字段
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/modules/wechat-game/entities/level.entity.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 修改 Level 实体**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/modules/wechat-game/entities/level.entity.ts
|
||||||
|
// 在 sortOrder 字段后添加
|
||||||
|
|
||||||
|
@Column({ type: 'int', name: 'time_limit', nullable: true, default: null })
|
||||||
|
timeLimit!: number | null;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/modules/wechat-game/entities/level.entity.ts
|
||||||
|
git commit -m "feat(level): add timeLimit field for level time restriction"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: 创建 ShareLevelProgress 实体
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/modules/share/entities/share-level-progress.entity.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 创建实体文件**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
Index,
|
||||||
|
Unique,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { ShareParticipant } from './share-participant.entity';
|
||||||
|
import { Level } from '../../wechat-game/entities/level.entity';
|
||||||
|
|
||||||
|
@Entity('share_level_progress')
|
||||||
|
@Unique('uq_participant_level', ['participantId', 'levelId'])
|
||||||
|
export class ShareLevelProgress {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Index('idx_slp_participant_id')
|
||||||
|
@Column({ type: 'char', length: 36, name: 'participant_id' })
|
||||||
|
participantId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => ShareParticipant)
|
||||||
|
@JoinColumn({ name: 'participant_id' })
|
||||||
|
participant!: ShareParticipant;
|
||||||
|
|
||||||
|
@Index('idx_slp_level_id')
|
||||||
|
@Column({ type: 'char', length: 191, name: 'level_id' })
|
||||||
|
levelId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Level)
|
||||||
|
@JoinColumn({ name: 'level_id' })
|
||||||
|
level!: Level;
|
||||||
|
|
||||||
|
@Column({ type: 'tinyint', width: 1, default: 0 })
|
||||||
|
passed!: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'int', default: 0, name: 'time_spent' })
|
||||||
|
timeSpent!: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'completed_at' })
|
||||||
|
completedAt!: Date;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/modules/share/entities/share-level-progress.entity.ts
|
||||||
|
git commit -m "feat(share): add ShareLevelProgress entity"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: 创建 ShareLevelProgressRepository
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/modules/share/repositories/share-level-progress.repository.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 创建 Repository**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { ShareLevelProgress } from '../entities/share-level-progress.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ShareLevelProgressRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ShareLevelProgress)
|
||||||
|
private readonly repository: Repository<ShareLevelProgress>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findByParticipantId(participantId: string): Promise<ShareLevelProgress[]> {
|
||||||
|
return this.repository.find({ where: { participantId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByParticipantAndLevel(
|
||||||
|
participantId: string,
|
||||||
|
levelId: string,
|
||||||
|
): Promise<ShareLevelProgress | null> {
|
||||||
|
return this.repository.findOne({ where: { participantId, levelId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
create(data: Partial<ShareLevelProgress>): ShareLevelProgress {
|
||||||
|
return this.repository.create(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(progress: ShareLevelProgress): Promise<ShareLevelProgress> {
|
||||||
|
return this.repository.save(progress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/modules/share/repositories/share-level-progress.repository.ts
|
||||||
|
git commit -m "feat(share): add ShareLevelProgressRepository"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: 创建 DTO 文件
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/modules/share/dto/report-level-progress.dto.ts`
|
||||||
|
- Create: `src/modules/share/dto/share-level-progress-response.dto.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 创建 ReportLevelProgressDto**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsBoolean, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class ReportLevelProgressDto {
|
||||||
|
@ApiProperty({ description: '分享码' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
shareCode!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '关卡 ID' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
levelId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '是否通过' })
|
||||||
|
@IsBoolean()
|
||||||
|
passed!: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '通关时间(秒)' })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
timeSpent!: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 创建 ReportLevelProgressResponseDto**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class ReportLevelProgressResponseDto {
|
||||||
|
@ApiProperty({ description: '是否通过' })
|
||||||
|
passed!: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '该关卡时间限制(秒),null 表示无限制' })
|
||||||
|
timeLimit!: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '是否在时间限制内通过' })
|
||||||
|
withinTimeLimit!: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/modules/share/dto/report-level-progress.dto.ts src/modules/share/dto/share-level-progress-response.dto.ts
|
||||||
|
git commit -m "feat(share): add DTOs for level progress reporting"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: ShareParticipantRepository 补充方法
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/modules/share/repositories/share-participant.repository.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 读取现有文件确认内容**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/modules/share/repositories/share-participant.repository.ts
|
||||||
|
// 在现有方法后添加 findByShareConfigAndParticipant 方法
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 添加新方法**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async findByShareConfigAndParticipant(
|
||||||
|
shareConfigId: string,
|
||||||
|
participantId: string,
|
||||||
|
): Promise<ShareParticipant | null> {
|
||||||
|
return this.repository.findOne({ where: { shareConfigId, participantId } });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/modules/share/repositories/share-participant.repository.ts
|
||||||
|
git commit -m "feat(share): add findByShareConfigAndParticipant to ShareParticipantRepository"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: ShareService 新增 reportLevelProgress 方法
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/modules/share/share.service.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 读取现有文件确认 import 和 constructor**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 需要新增的 imports
|
||||||
|
import { ShareLevelProgressRepository } from './repositories/share-level-progress.repository';
|
||||||
|
import { ReportLevelProgressDto } from './dto/report-level-progress.dto';
|
||||||
|
import { ReportLevelProgressResponseDto } from './dto/share-level-progress-response.dto';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 在 ShareService 中添加方法**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async reportLevelProgress(
|
||||||
|
userId: string,
|
||||||
|
dto: ReportLevelProgressDto,
|
||||||
|
): Promise<ReportLevelProgressResponseDto> {
|
||||||
|
// 1. 查找分享配置
|
||||||
|
const config = await this.shareConfigRepository.findByShareCode(dto.shareCode);
|
||||||
|
if (!config) {
|
||||||
|
throw new NotFoundException('分享不存在或已过期');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 查找或创建 ShareParticipant
|
||||||
|
let participant = await this.shareParticipantRepository.findByShareConfigAndParticipant(
|
||||||
|
config.id,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
if (!participant) {
|
||||||
|
participant = await this.shareParticipantRepository.create({
|
||||||
|
shareConfigId: config.id,
|
||||||
|
participantId: userId,
|
||||||
|
});
|
||||||
|
participant = await this.shareParticipantRepository.save(participant);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 如果 passed=true,检查是否已有通关记录
|
||||||
|
if (dto.passed) {
|
||||||
|
const existing = await this.shareLevelProgressRepository.findByParticipantAndLevel(
|
||||||
|
participant.id,
|
||||||
|
dto.levelId,
|
||||||
|
);
|
||||||
|
if (existing?.passed) {
|
||||||
|
return {
|
||||||
|
passed: true,
|
||||||
|
timeLimit: null,
|
||||||
|
withinTimeLimit: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 查找关卡获取时间限制
|
||||||
|
const level = await this.levelRepository.findById(dto.levelId);
|
||||||
|
if (!level) {
|
||||||
|
throw new NotFoundException('关卡不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 判断是否在时间限制内通过
|
||||||
|
const withinTimeLimit = dto.passed
|
||||||
|
? level.timeLimit === null || dto.timeSpent <= level.timeLimit
|
||||||
|
: false;
|
||||||
|
|
||||||
|
// 6. 创建或更新进度
|
||||||
|
let progress = await this.shareLevelProgressRepository.findByParticipantAndLevel(
|
||||||
|
participant.id,
|
||||||
|
dto.levelId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (progress) {
|
||||||
|
progress.passed = dto.passed;
|
||||||
|
progress.timeSpent = dto.timeSpent;
|
||||||
|
if (dto.passed) {
|
||||||
|
progress.completedAt = new Date();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
progress = this.shareLevelProgressRepository.create({
|
||||||
|
participantId: participant.id,
|
||||||
|
levelId: dto.levelId,
|
||||||
|
passed: dto.passed,
|
||||||
|
timeSpent: dto.timeSpent,
|
||||||
|
completedAt: dto.passed ? new Date() : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.shareLevelProgressRepository.save(progress);
|
||||||
|
|
||||||
|
return {
|
||||||
|
passed: dto.passed,
|
||||||
|
timeLimit: level.timeLimit,
|
||||||
|
withinTimeLimit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/modules/share/share.service.ts
|
||||||
|
git commit -m "feat(share): add reportLevelProgress method"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: ShareController 新增接口
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/modules/share/share.controller.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 添加 import**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ReportLevelProgressDto } from './dto/report-level-progress.dto';
|
||||||
|
import { ReportLevelProgressResponseDto } from './dto/share-level-progress-response.dto';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 添加 Controller 方法**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Post('progress')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '上报单关进度',
|
||||||
|
description: '用户在分享挑战中通关后上报进度,仅首次通关(passed=true)有效',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: '成功' })
|
||||||
|
@ApiResponse({ status: 404, description: '分享或关卡不存在' })
|
||||||
|
async reportLevelProgress(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Body() dto: ReportLevelProgressDto,
|
||||||
|
): Promise<ApiResponseDto<ReportLevelProgressResponseDto>> {
|
||||||
|
const data = await this.shareService.reportLevelProgress(user.sub, dto);
|
||||||
|
return ApiResponseDto.success(data);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/modules/share/share.controller.ts
|
||||||
|
git commit -m "feat(share): add POST /v1/share/progress endpoint"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: ShareModule 更新
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/modules/share/share.module.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 修改 import 和 providers**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ShareLevelProgress } from './entities/share-level-progress.entity';
|
||||||
|
import { ShareLevelProgressRepository } from './repositories/share-level-progress.repository';
|
||||||
|
|
||||||
|
// TypeOrmModule.forFeature 中添加 ShareLevelProgress
|
||||||
|
TypeOrmModule.forFeature([ShareConfig, ShareParticipant, ShareLevelProgress]),
|
||||||
|
|
||||||
|
// providers 中添加 ShareLevelProgressRepository
|
||||||
|
providers: [
|
||||||
|
ShareService,
|
||||||
|
ShareConfigRepository,
|
||||||
|
ShareParticipantRepository,
|
||||||
|
ShareLevelProgressRepository,
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/modules/share/share.module.ts
|
||||||
|
git commit -m "feat(share): register ShareLevelProgress in ShareModule"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: 数据库迁移 SQL
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/database/migrations/002_add_share_level_progress.sql`(或按项目规范)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 创建迁移 SQL**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Level 表增加 time_limit 字段
|
||||||
|
ALTER TABLE levels
|
||||||
|
ADD COLUMN time_limit INT DEFAULT NULL COMMENT '通关时间限制(秒),NULL 表示无限制'
|
||||||
|
AFTER sort_order;
|
||||||
|
|
||||||
|
-- 新建 share_level_progress 表
|
||||||
|
CREATE TABLE IF NOT EXISTS share_level_progress (
|
||||||
|
id CHAR(36) PRIMARY KEY,
|
||||||
|
participant_id CHAR(36) NOT NULL COMMENT '关联 share_participants.id',
|
||||||
|
level_id CHAR(191) NOT NULL COMMENT '关卡ID',
|
||||||
|
passed TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否通过',
|
||||||
|
time_spent INT NOT NULL DEFAULT 0 COMMENT '通关时间(秒)',
|
||||||
|
completed_at DATETIME DEFAULT NULL COMMENT '通关时间戳',
|
||||||
|
|
||||||
|
UNIQUE KEY uq_participant_level (participant_id, level_id),
|
||||||
|
INDEX idx_slp_participant_id (participant_id),
|
||||||
|
INDEX idx_slp_level_id (level_id),
|
||||||
|
|
||||||
|
FOREIGN KEY (participant_id) REFERENCES share_participants(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/database/migrations/002_add_share_level_progress.sql
|
||||||
|
git commit -m "chore: add migration for share level progress tables"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10: 编译验证
|
||||||
|
|
||||||
|
- [ ] **Step 1: 运行 TypeScript 编译检查**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/richard/Documents/code/xieyingeng/MemeMind-Server && npx tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:无编译错误
|
||||||
|
|
||||||
|
- [ ] **Step 2: 如果有错误,修复后重新编译**
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit(如果有代码修改)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 自检清单
|
||||||
|
|
||||||
|
完成实现后,对照设计文档检查:
|
||||||
|
|
||||||
|
- [ ] Level 实体有 `timeLimit` 字段
|
||||||
|
- [ ] ShareLevelProgress 实体有 `participantId`, `levelId`, `passed`, `timeSpent`, `completedAt` 字段
|
||||||
|
- [ ] ShareLevelProgress 有唯一索引 `(participantId, levelId)`
|
||||||
|
- [ ] ShareLevelProgressRepository 有 `findByParticipantId`, `findByParticipantAndLevel`, `create`, `save` 方法
|
||||||
|
- [ ] ShareParticipantRepository 有 `findByShareConfigAndParticipant` 方法
|
||||||
|
- [ ] ShareService.reportLevelProgress 实现了完整业务逻辑
|
||||||
|
- [ ] Controller 接口为 `POST /v1/share/progress`
|
||||||
|
- [ ] DTO 包含 `shareCode`, `levelId`, `passed`, `timeSpent`
|
||||||
|
- [ ] 响应 DTO 包含 `passed`, `timeLimit`, `withinTimeLimit`
|
||||||
|
- [ ] ShareModule 注册了 ShareLevelProgress 实体和 Repository
|
||||||
|
- [ ] 数据库迁移 SQL 包含 Level 表修改和 share_level_progress 表创建
|
||||||
174
docs/superpowers/specs/2026-04-07-share-level-progress-design.md
Normal file
174
docs/superpowers/specs/2026-04-07-share-level-progress-design.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# 分享挑战关卡进度记录功能设计
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
现有好友挑战分享能力支持用户加入分享挑战并获取6个关卡的数据。需要在分享挑战场景下记录用户的单关通关进度,为后续的排行统计和规定时间内通关统计提供数据基础。
|
||||||
|
|
||||||
|
## 需求理解
|
||||||
|
|
||||||
|
1. **记录用户在分享挑战下的单关通关数据**(通关时间、是否通过)
|
||||||
|
2. **不包含积分**(积分已有独立体系)
|
||||||
|
3. **仅首次通关有效**(passed=true 时不可覆盖)
|
||||||
|
4. **每个关卡有独立时间限制**(存储在 Level 表的 `timeLimit` 字段)
|
||||||
|
5. **同一关卡在不同分享挑战中分别记录**
|
||||||
|
|
||||||
|
## 数据库设计
|
||||||
|
|
||||||
|
### 1. Level 表扩展
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE levels ADD COLUMN time_limit INT DEFAULT NULL COMMENT '通关时间限制(秒),NULL 表示无限制';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 新建 ShareLevelProgress 表
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE share_level_progress (
|
||||||
|
id CHAR(36) PRIMARY KEY,
|
||||||
|
participant_id CHAR(36) NOT NULL COMMENT '关联 share_participants.id',
|
||||||
|
level_id CHAR(191) NOT NULL COMMENT '关卡ID',
|
||||||
|
passed TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否通过',
|
||||||
|
time_spent INT NOT NULL DEFAULT 0 COMMENT '通关时间(秒)',
|
||||||
|
completed_at DATETIME DEFAULT NULL COMMENT '通关时间戳',
|
||||||
|
|
||||||
|
UNIQUE KEY uq_participant_level (participant_id, level_id),
|
||||||
|
INDEX idx_participant_id (participant_id),
|
||||||
|
INDEX idx_level_id (level_id),
|
||||||
|
|
||||||
|
FOREIGN KEY (participant_id) REFERENCES share_participants(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. ER 关系
|
||||||
|
|
||||||
|
```
|
||||||
|
ShareConfig (1) ──< ShareParticipant (多)
|
||||||
|
│
|
||||||
|
└─< ShareLevelProgress (多) >── Level (1)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 实体设计
|
||||||
|
|
||||||
|
### ShareLevelProgress 实体
|
||||||
|
|
||||||
|
文件:`src/modules/share/entities/share-level-progress.entity.ts`
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | uuid | 主键 |
|
||||||
|
| participantId | varchar | 关联 ShareParticipant |
|
||||||
|
| levelId | varchar | 关卡ID |
|
||||||
|
| passed | boolean | 是否通过 |
|
||||||
|
| timeSpent | int | 通关时间(秒) |
|
||||||
|
| completedAt | datetime | 通关时间戳 |
|
||||||
|
|
||||||
|
唯一索引:`uq_participant_level(participant_id, level_id)`
|
||||||
|
|
||||||
|
## Repository 设计
|
||||||
|
|
||||||
|
### IShareLevelProgressRepository 接口
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IShareLevelProgressRepository {
|
||||||
|
findByParticipantId(participantId: string): Promise<ShareLevelProgress[]>;
|
||||||
|
findByParticipantAndLevel(participantId: string, levelId: string): Promise<ShareLevelProgress | null>;
|
||||||
|
create(data: Partial<ShareLevelProgress>): ShareLevelProgress;
|
||||||
|
save(progress: ShareLevelProgress): Promise<ShareLevelProgress>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
实现:`src/modules/share/repositories/share-level-progress.repository.ts`
|
||||||
|
|
||||||
|
### ShareParticipantRepository 补充方法
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
findByShareConfigAndParticipant(
|
||||||
|
shareConfigId: string,
|
||||||
|
participantId: string,
|
||||||
|
): Promise<ShareParticipant | null>;
|
||||||
|
```
|
||||||
|
|
||||||
|
## DTO 设计
|
||||||
|
|
||||||
|
### ReportLevelProgressDto(请求)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 校验 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| shareCode | string | @IsString, @IsNotEmpty | 分享码 |
|
||||||
|
| levelId | string | @IsString, @IsNotEmpty | 关卡ID |
|
||||||
|
| passed | boolean | @IsBoolean | 是否通过 |
|
||||||
|
| timeSpent | number | @IsNumber, @Min(0) | 通关时间(秒) |
|
||||||
|
|
||||||
|
### ReportLevelProgressResponseDto(响应)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| passed | boolean | 是否通过 |
|
||||||
|
| timeLimit | number \| null | 该关卡时间限制(秒),null 表示无限制 |
|
||||||
|
| withinTimeLimit | boolean | 是否在时间限制内通过(仅 passed=true 时有效) |
|
||||||
|
|
||||||
|
## Service 层设计
|
||||||
|
|
||||||
|
### 新增方法:reportLevelProgress
|
||||||
|
|
||||||
|
业务逻辑:
|
||||||
|
|
||||||
|
1. 根据 shareCode 查找 ShareConfig
|
||||||
|
2. 查找或创建 ShareParticipant(分享者和参与者都需要)
|
||||||
|
3. 如果 passed=true,检查是否已有通关记录(存在则直接返回,不覆盖)
|
||||||
|
4. 查找关卡获取 timeLimit
|
||||||
|
5. 判断是否在时间限制内通过
|
||||||
|
6. 创建或更新进度记录
|
||||||
|
|
||||||
|
### 核心规则
|
||||||
|
|
||||||
|
- `passed=true` 时仅首次有效,不可覆盖
|
||||||
|
- `passed=false` 时允许重复上报(记录用户重玩)
|
||||||
|
- `withinTimeLimit = passed && (timeLimit === null || timeSpent <= timeLimit)`
|
||||||
|
|
||||||
|
## Controller 设计
|
||||||
|
|
||||||
|
### 接口
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /v1/share/progress
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async reportLevelProgress(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Body() dto: ReportLevelProgressDto,
|
||||||
|
): Promise<ApiResponseDto<ReportLevelProgressResponseDto>>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Module 变更
|
||||||
|
|
||||||
|
### ShareModule
|
||||||
|
|
||||||
|
- 导入 `ShareLevelProgress` 实体
|
||||||
|
- 注册 `ShareLevelProgressRepository`
|
||||||
|
|
||||||
|
## 后续扩展
|
||||||
|
|
||||||
|
以下功能不在本次范围内,仅记录设计预留:
|
||||||
|
|
||||||
|
1. **排行查询**:按分享挑战统计用户总通关时间排名
|
||||||
|
2. **规定时间内通过关数统计**:统计某用户在挑战中的 withinTimeLimit 关数
|
||||||
|
3. **历史记录**:支持查看用户的重玩记录
|
||||||
|
|
||||||
|
## 文件清单
|
||||||
|
|
||||||
|
| 操作 | 文件路径 |
|
||||||
|
|------|----------|
|
||||||
|
| 新增 | src/modules/share/entities/share-level-progress.entity.ts |
|
||||||
|
| 新增 | src/modules/share/repositories/share-level-progress.repository.interface.ts |
|
||||||
|
| 新增 | src/modules/share/repositories/share-level-progress.repository.ts |
|
||||||
|
| 新增 | src/modules/share/dto/report-level-progress.dto.ts |
|
||||||
|
| 新增 | src/modules/share/dto/share-level-progress-response.dto.ts |
|
||||||
|
| 修改 | src/modules/share/repositories/share-participant.repository.ts |
|
||||||
|
| 修改 | src/modules/share/share.service.ts |
|
||||||
|
| 修改 | src/modules/share/share.controller.ts |
|
||||||
|
| 修改 | src/modules/share/share.module.ts |
|
||||||
|
| 修改 | src/modules/wechat-game/entities/level.entity.ts |
|
||||||
|
| SQL | 数据库迁移脚本(Level 表 + ShareLevelProgress 表) |
|
||||||
10
skills-lock.json
Normal file
10
skills-lock.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"skills": {
|
||||||
|
"javascript-typescript-jest": {
|
||||||
|
"source": "github/awesome-copilot",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "0073d19ab0275eb6a8200aced6789a53744667bd717b5d1f44ce708a1740e5d4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
skills/javascript-typescript-jest
Symbolic link
1
skills/javascript-typescript-jest
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../.agents/skills/javascript-typescript-jest
|
||||||
65
src/common/dto/api-response.dto.spec.ts
Normal file
65
src/common/dto/api-response.dto.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { ApiResponseDto } from './api-response.dto';
|
||||||
|
|
||||||
|
describe('ApiResponseDto', () => {
|
||||||
|
describe('success', () => {
|
||||||
|
it('should create a success response with data', () => {
|
||||||
|
const data = { id: '1', name: 'test' };
|
||||||
|
const response = ApiResponseDto.success(data);
|
||||||
|
|
||||||
|
expect(response.success).toBe(true);
|
||||||
|
expect(response.data).toEqual(data);
|
||||||
|
expect(response.message).toBeNull();
|
||||||
|
expect(response.timestamp).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a success response with null data', () => {
|
||||||
|
const response = ApiResponseDto.success(null);
|
||||||
|
|
||||||
|
expect(response.success).toBe(true);
|
||||||
|
expect(response.data).toBeNull();
|
||||||
|
expect(response.message).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a success response with array data', () => {
|
||||||
|
const data = [1, 2, 3];
|
||||||
|
const response = ApiResponseDto.success(data);
|
||||||
|
|
||||||
|
expect(response.success).toBe(true);
|
||||||
|
expect(response.data).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error', () => {
|
||||||
|
it('should create an error response with message', () => {
|
||||||
|
const response = ApiResponseDto.error('Something went wrong');
|
||||||
|
|
||||||
|
expect(response.success).toBe(false);
|
||||||
|
expect(response.data).toBeNull();
|
||||||
|
expect(response.message).toBe('Something went wrong');
|
||||||
|
expect(response.timestamp).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create an error response with Chinese message', () => {
|
||||||
|
const response = ApiResponseDto.error('积分不足');
|
||||||
|
|
||||||
|
expect(response.success).toBe(false);
|
||||||
|
expect(response.message).toBe('积分不足');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('should set default message to null', () => {
|
||||||
|
const dto = new ApiResponseDto(true, 'data');
|
||||||
|
|
||||||
|
expect(dto.message).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set all properties correctly', () => {
|
||||||
|
const dto = new ApiResponseDto(false, null, 'error message');
|
||||||
|
|
||||||
|
expect(dto.success).toBe(false);
|
||||||
|
expect(dto.data).toBeNull();
|
||||||
|
expect(dto.message).toBe('error message');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
97
src/common/filters/http-exception.filter.spec.ts
Normal file
97
src/common/filters/http-exception.filter.spec.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { HttpExceptionFilter } from './http-exception.filter';
|
||||||
|
import {
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
ArgumentsHost,
|
||||||
|
BadRequestException,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
|
||||||
|
describe('HttpExceptionFilter', () => {
|
||||||
|
let filter: HttpExceptionFilter;
|
||||||
|
|
||||||
|
const mockJson = jest.fn();
|
||||||
|
const mockStatus = jest.fn().mockReturnValue({ json: mockJson });
|
||||||
|
const mockGetResponse = jest.fn().mockReturnValue({ status: mockStatus });
|
||||||
|
const mockGetRequest = jest
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue({ url: '/api/v1/test' });
|
||||||
|
|
||||||
|
const mockHost: ArgumentsHost = {
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getResponse: mockGetResponse,
|
||||||
|
getRequest: mockGetRequest,
|
||||||
|
}),
|
||||||
|
} as unknown as ArgumentsHost;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
filter = new HttpExceptionFilter();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockStatus.mockReturnValue({ json: mockJson });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle HttpException with string response', () => {
|
||||||
|
const exception = new HttpException('Not Found', HttpStatus.NOT_FOUND);
|
||||||
|
|
||||||
|
filter.catch(exception, mockHost);
|
||||||
|
|
||||||
|
expect(mockStatus).toHaveBeenCalledWith(HttpStatus.NOT_FOUND);
|
||||||
|
expect(mockJson).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
success: false,
|
||||||
|
data: null,
|
||||||
|
path: '/api/v1/test',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle BadRequestException with object response', () => {
|
||||||
|
const exception = new BadRequestException('参数错误');
|
||||||
|
|
||||||
|
filter.catch(exception, mockHost);
|
||||||
|
|
||||||
|
expect(mockStatus).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
|
||||||
|
expect(mockJson).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
success: false,
|
||||||
|
path: '/api/v1/test',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle NotFoundException', () => {
|
||||||
|
const exception = new NotFoundException('资源不存在');
|
||||||
|
|
||||||
|
filter.catch(exception, mockHost);
|
||||||
|
|
||||||
|
expect(mockStatus).toHaveBeenCalledWith(HttpStatus.NOT_FOUND);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle generic Error with 500 status', () => {
|
||||||
|
const exception = new Error('Unexpected error');
|
||||||
|
|
||||||
|
filter.catch(exception, mockHost);
|
||||||
|
|
||||||
|
expect(mockStatus).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
|
expect(mockJson).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
success: false,
|
||||||
|
message: 'Unexpected error',
|
||||||
|
path: '/api/v1/test',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle unknown exception with 500 status', () => {
|
||||||
|
filter.catch('unexpected string error', mockHost);
|
||||||
|
|
||||||
|
expect(mockStatus).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
|
expect(mockJson).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
success: false,
|
||||||
|
message: 'Internal server error',
|
||||||
|
path: '/api/v1/test',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
79
src/common/guards/jwt-auth.guard.spec.ts
Normal file
79
src/common/guards/jwt-auth.guard.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { JwtAuthGuard, JwtPayload } from './jwt-auth.guard';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||||
|
|
||||||
|
describe('JwtAuthGuard', () => {
|
||||||
|
let guard: JwtAuthGuard;
|
||||||
|
|
||||||
|
const mockJwtService = {
|
||||||
|
verifyAsync: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
guard = new JwtAuthGuard(mockJwtService as unknown as JwtService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function createMockContext(authHeader?: string): ExecutionContext {
|
||||||
|
const mockRequest: Record<string, unknown> = {
|
||||||
|
headers: authHeader ? { authorization: authHeader } : {},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => mockRequest,
|
||||||
|
}),
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should allow access with valid Bearer token', async () => {
|
||||||
|
const payload: JwtPayload = { sub: 'user-1', openid: 'wx-openid' };
|
||||||
|
mockJwtService.verifyAsync.mockResolvedValue(payload);
|
||||||
|
|
||||||
|
const context = createMockContext('Bearer valid-token');
|
||||||
|
const result = await guard.canActivate(context);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(mockJwtService.verifyAsync).toHaveBeenCalledWith('valid-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should attach user payload to request', async () => {
|
||||||
|
const payload: JwtPayload = { sub: 'user-1', openid: 'wx-openid' };
|
||||||
|
mockJwtService.verifyAsync.mockResolvedValue(payload);
|
||||||
|
|
||||||
|
const context = createMockContext('Bearer valid-token');
|
||||||
|
const request = context.switchToHttp().getRequest() as Record<string, unknown>;
|
||||||
|
await guard.canActivate(context);
|
||||||
|
|
||||||
|
expect(request.user).toEqual(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw UnauthorizedException when no Authorization header', async () => {
|
||||||
|
const context = createMockContext();
|
||||||
|
|
||||||
|
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw UnauthorizedException when token type is not Bearer', async () => {
|
||||||
|
const context = createMockContext('Basic some-token');
|
||||||
|
|
||||||
|
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw UnauthorizedException when token is invalid', async () => {
|
||||||
|
mockJwtService.verifyAsync.mockRejectedValue(new Error('invalid token'));
|
||||||
|
|
||||||
|
const context = createMockContext('Bearer invalid-token');
|
||||||
|
|
||||||
|
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
20
src/database/migrations/002_add_share_level_progress.sql
Normal file
20
src/database/migrations/002_add_share_level_progress.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
-- Level 表增加 time_limit 字段
|
||||||
|
ALTER TABLE levels
|
||||||
|
ADD COLUMN time_limit INT DEFAULT NULL COMMENT '通关时间限制(秒),NULL 表示无限制'
|
||||||
|
AFTER sort_order;
|
||||||
|
|
||||||
|
-- 新建 share_level_progress 表
|
||||||
|
CREATE TABLE IF NOT EXISTS share_level_progress (
|
||||||
|
id CHAR(36) PRIMARY KEY,
|
||||||
|
participant_id CHAR(36) NOT NULL COMMENT '关联 share_participants.id',
|
||||||
|
level_id CHAR(191) NOT NULL COMMENT '关卡ID',
|
||||||
|
passed TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否通过',
|
||||||
|
time_spent INT NOT NULL DEFAULT 0 COMMENT '通关时间(秒)',
|
||||||
|
completed_at DATETIME DEFAULT NULL COMMENT '通关时间戳',
|
||||||
|
|
||||||
|
UNIQUE KEY uq_participant_level (participant_id, level_id),
|
||||||
|
INDEX idx_slp_participant_id (participant_id),
|
||||||
|
INDEX idx_slp_level_id (level_id),
|
||||||
|
|
||||||
|
FOREIGN KEY (participant_id) REFERENCES share_participants(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
120
src/modules/auth/auth.controller.spec.ts
Normal file
120
src/modules/auth/auth.controller.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
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 () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [
|
||||||
|
{ provide: AuthService, useValue: mockAuthService },
|
||||||
|
{ provide: JwtService, useValue: { verifyAsync: jest.fn() } },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<AuthController>(AuthController);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('wxLogin', () => {
|
||||||
|
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 },
|
||||||
|
};
|
||||||
|
mockAuthService.wxLogin.mockResolvedValue(loginResponse);
|
||||||
|
|
||||||
|
const result = await controller.wxLogin({ code: 'wx-code-123' });
|
||||||
|
|
||||||
|
expect(result).toBeInstanceOf(ApiResponseDto);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toEqual(loginResponse);
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
293
src/modules/auth/auth.service.spec.ts
Normal file
293
src/modules/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import {
|
||||||
|
UnauthorizedException,
|
||||||
|
BadRequestException,
|
||||||
|
} 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<typeof axios>;
|
||||||
|
|
||||||
|
describe('AuthService', () => {
|
||||||
|
let service: AuthService;
|
||||||
|
|
||||||
|
const mockUser: User = {
|
||||||
|
id: 'user-uuid-1',
|
||||||
|
openid: 'wx-openid-123',
|
||||||
|
sessionKey: 'session-key-abc',
|
||||||
|
nickname: 'TestUser',
|
||||||
|
avatarUrl: null,
|
||||||
|
points: 10,
|
||||||
|
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(),
|
||||||
|
create: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockUserLevelProgressRepository = {
|
||||||
|
findByUserId: jest.fn(),
|
||||||
|
findByUserAndLevel: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockJwtService = {
|
||||||
|
signAsync: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockConfigService = {
|
||||||
|
get: jest.fn((key: string, defaultValue?: string) => {
|
||||||
|
const config: Record<string, string> = {
|
||||||
|
WX_APPID: 'test-appid',
|
||||||
|
WX_SECRET: 'test-secret',
|
||||||
|
};
|
||||||
|
return config[key] ?? defaultValue ?? '';
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
|
{ provide: JwtService, useValue: mockJwtService },
|
||||||
|
{ provide: UserRepository, useValue: mockUserRepository },
|
||||||
|
{
|
||||||
|
provide: UserLevelProgressRepository,
|
||||||
|
useValue: mockUserLevelProgressRepository,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<AuthService>(AuthService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('wxLogin', () => {
|
||||||
|
it('should create a new user and return JWT token on first login', async () => {
|
||||||
|
const newUser = { ...mockUser, points: 10 };
|
||||||
|
mockedAxios.get.mockResolvedValue({
|
||||||
|
data: { openid: 'wx-openid-123', session_key: 'session-key-abc' },
|
||||||
|
});
|
||||||
|
mockUserRepository.findByOpenid.mockResolvedValue(null);
|
||||||
|
mockUserRepository.create.mockReturnValue(newUser);
|
||||||
|
mockUserRepository.save.mockResolvedValue(newUser);
|
||||||
|
mockJwtService.signAsync.mockResolvedValue('jwt-token-xyz');
|
||||||
|
|
||||||
|
const result = await service.wxLogin('wx-code-123');
|
||||||
|
|
||||||
|
expect(result.token).toBe('jwt-token-xyz');
|
||||||
|
expect(result.user.id).toBe('user-uuid-1');
|
||||||
|
expect(result.user.points).toBe(10);
|
||||||
|
expect(mockUserRepository.create).toHaveBeenCalledWith({
|
||||||
|
openid: 'wx-openid-123',
|
||||||
|
sessionKey: 'session-key-abc',
|
||||||
|
points: 10,
|
||||||
|
});
|
||||||
|
expect(mockJwtService.signAsync).toHaveBeenCalledWith({
|
||||||
|
sub: 'user-uuid-1',
|
||||||
|
openid: 'wx-openid-123',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return existing user and update session_key on repeat login', async () => {
|
||||||
|
const existingUser = { ...mockUser };
|
||||||
|
const updatedUser = { ...existingUser, sessionKey: 'new-session-key' };
|
||||||
|
mockedAxios.get.mockResolvedValue({
|
||||||
|
data: { openid: 'wx-openid-123', session_key: 'new-session-key' },
|
||||||
|
});
|
||||||
|
mockUserRepository.findByOpenid.mockResolvedValue(existingUser);
|
||||||
|
mockUserRepository.save.mockResolvedValue(updatedUser);
|
||||||
|
mockJwtService.signAsync.mockResolvedValue('jwt-token-abc');
|
||||||
|
|
||||||
|
const result = await service.wxLogin('wx-code-456');
|
||||||
|
|
||||||
|
expect(result.token).toBe('jwt-token-abc');
|
||||||
|
expect(result.user.id).toBe('user-uuid-1');
|
||||||
|
expect(mockUserRepository.create).not.toHaveBeenCalled();
|
||||||
|
expect(mockUserRepository.save).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw UnauthorizedException when WeChat API returns error', async () => {
|
||||||
|
mockedAxios.get.mockResolvedValue({
|
||||||
|
data: { errcode: 40029, errmsg: 'invalid code' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.wxLogin('invalid-code')).rejects.toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw UnauthorizedException when WeChat API call fails', async () => {
|
||||||
|
mockedAxios.get.mockRejectedValue(new Error('Network error'));
|
||||||
|
|
||||||
|
await expect(service.wxLogin('any-code')).rejects.toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
23
src/modules/share/dto/report-level-progress.dto.ts
Normal file
23
src/modules/share/dto/report-level-progress.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsBoolean, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class ReportLevelProgressDto {
|
||||||
|
@ApiProperty({ description: '分享码' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
shareCode!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '关卡 ID' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
levelId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '是否通过' })
|
||||||
|
@IsBoolean()
|
||||||
|
passed!: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '通关时间(秒)' })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
timeSpent!: number;
|
||||||
|
}
|
||||||
12
src/modules/share/dto/share-level-progress-response.dto.ts
Normal file
12
src/modules/share/dto/share-level-progress-response.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class ReportLevelProgressResponseDto {
|
||||||
|
@ApiProperty({ description: '是否通过' })
|
||||||
|
passed!: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '该关卡时间限制(秒),null 表示无限制' })
|
||||||
|
timeLimit!: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '是否在时间限制内通过' })
|
||||||
|
withinTimeLimit!: boolean;
|
||||||
|
}
|
||||||
43
src/modules/share/entities/share-level-progress.entity.ts
Normal file
43
src/modules/share/entities/share-level-progress.entity.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
Index,
|
||||||
|
Unique,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { ShareParticipant } from './share-participant.entity';
|
||||||
|
import { Level } from '../../wechat-game/entities/level.entity';
|
||||||
|
|
||||||
|
@Entity('share_level_progress')
|
||||||
|
@Unique('uq_participant_level', ['participantId', 'levelId'])
|
||||||
|
export class ShareLevelProgress {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Index('idx_slp_participant_id')
|
||||||
|
@Column({ type: 'char', length: 36, name: 'participant_id' })
|
||||||
|
participantId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => ShareParticipant)
|
||||||
|
@JoinColumn({ name: 'participant_id' })
|
||||||
|
participant!: ShareParticipant;
|
||||||
|
|
||||||
|
@Index('idx_slp_level_id')
|
||||||
|
@Column({ type: 'char', length: 191, name: 'level_id' })
|
||||||
|
levelId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Level)
|
||||||
|
@JoinColumn({ name: 'level_id' })
|
||||||
|
level!: Level;
|
||||||
|
|
||||||
|
@Column({ type: 'tinyint', width: 1, default: 0 })
|
||||||
|
passed!: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'int', default: 0, name: 'time_spent' })
|
||||||
|
timeSpent!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', name: 'completed_at', nullable: true, default: null })
|
||||||
|
completedAt!: Date | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { ShareLevelProgress } from '../entities/share-level-progress.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ShareLevelProgressRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ShareLevelProgress)
|
||||||
|
private readonly repository: Repository<ShareLevelProgress>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findByParticipantId(participantId: string): Promise<ShareLevelProgress[]> {
|
||||||
|
return this.repository.find({ where: { participantId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByParticipantAndLevel(
|
||||||
|
participantId: string,
|
||||||
|
levelId: string,
|
||||||
|
): Promise<ShareLevelProgress | null> {
|
||||||
|
return this.repository.findOne({ where: { participantId, levelId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
create(data: Partial<ShareLevelProgress>): ShareLevelProgress {
|
||||||
|
return this.repository.create(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(progress: ShareLevelProgress): Promise<ShareLevelProgress> {
|
||||||
|
return this.repository.save(progress);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,4 +27,19 @@ export class ShareParticipantRepository {
|
|||||||
async countByShareConfigId(shareConfigId: string): Promise<number> {
|
async countByShareConfigId(shareConfigId: string): Promise<number> {
|
||||||
return this.repository.count({ where: { shareConfigId } });
|
return this.repository.count({ where: { shareConfigId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findByShareConfigAndParticipant(
|
||||||
|
shareConfigId: string,
|
||||||
|
participantId: string,
|
||||||
|
): Promise<ShareParticipant | null> {
|
||||||
|
return this.repository.findOne({ where: { shareConfigId, participantId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
create(data: Partial<ShareParticipant>): ShareParticipant {
|
||||||
|
return this.repository.create(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(participant: ShareParticipant): Promise<ShareParticipant> {
|
||||||
|
return this.repository.save(participant);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
119
src/modules/share/share.controller.spec.ts
Normal file
119
src/modules/share/share.controller.spec.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ShareController } from './share.controller';
|
||||||
|
import { ShareService } from './share.service';
|
||||||
|
import { ApiResponseDto } from '../../common/dto/api-response.dto';
|
||||||
|
import type { JwtPayload } from '../../common/guards/jwt-auth.guard';
|
||||||
|
|
||||||
|
describe('ShareController', () => {
|
||||||
|
let controller: ShareController;
|
||||||
|
|
||||||
|
const mockUser: JwtPayload = {
|
||||||
|
sub: 'user-uuid-1',
|
||||||
|
openid: 'wx-openid-123',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockShareService = {
|
||||||
|
createShare: jest.fn(),
|
||||||
|
joinShare: jest.fn(),
|
||||||
|
reportLevelProgress: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [ShareController],
|
||||||
|
providers: [
|
||||||
|
{ provide: ShareService, useValue: mockShareService },
|
||||||
|
{ provide: JwtService, useValue: { verifyAsync: jest.fn() } },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<ShareController>(ShareController);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createShare', () => {
|
||||||
|
it('should return success response with share details', async () => {
|
||||||
|
const shareResponse = {
|
||||||
|
shareCode: 'ABCD1234',
|
||||||
|
title: '我的挑战',
|
||||||
|
levelCount: 6,
|
||||||
|
};
|
||||||
|
mockShareService.createShare.mockResolvedValue(shareResponse);
|
||||||
|
|
||||||
|
const dto = {
|
||||||
|
title: '我的挑战',
|
||||||
|
levelIds: ['l1', 'l2', 'l3', 'l4', 'l5', 'l6'],
|
||||||
|
};
|
||||||
|
const result = await controller.createShare(mockUser, dto);
|
||||||
|
|
||||||
|
expect(result).toBeInstanceOf(ApiResponseDto);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toEqual(shareResponse);
|
||||||
|
expect(mockShareService.createShare).toHaveBeenCalledWith(
|
||||||
|
'user-uuid-1',
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('joinShare', () => {
|
||||||
|
it('should return success response with share levels', async () => {
|
||||||
|
const joinResponse = {
|
||||||
|
shareCode: 'ABCD1234',
|
||||||
|
title: '我的挑战',
|
||||||
|
levels: [
|
||||||
|
{
|
||||||
|
id: 'l1',
|
||||||
|
level: 1,
|
||||||
|
imageUrl: 'https://example.com/1.jpg',
|
||||||
|
answer: '答案',
|
||||||
|
hint1: null,
|
||||||
|
hint2: null,
|
||||||
|
hint3: null,
|
||||||
|
sortOrder: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockShareService.joinShare.mockResolvedValue(joinResponse);
|
||||||
|
|
||||||
|
const result = await controller.joinShare(mockUser, 'ABCD1234');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toEqual(joinResponse);
|
||||||
|
expect(mockShareService.joinShare).toHaveBeenCalledWith(
|
||||||
|
'user-uuid-1',
|
||||||
|
'ABCD1234',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reportLevelProgress', () => {
|
||||||
|
it('should return success response with progress result', async () => {
|
||||||
|
const progressResponse = {
|
||||||
|
passed: true,
|
||||||
|
timeLimit: 60,
|
||||||
|
withinTimeLimit: true,
|
||||||
|
};
|
||||||
|
mockShareService.reportLevelProgress.mockResolvedValue(progressResponse);
|
||||||
|
|
||||||
|
const dto = {
|
||||||
|
shareCode: 'ABCD1234',
|
||||||
|
levelId: 'level-1',
|
||||||
|
passed: true,
|
||||||
|
timeSpent: 30,
|
||||||
|
};
|
||||||
|
const result = await controller.reportLevelProgress(mockUser, dto);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toEqual(progressResponse);
|
||||||
|
expect(mockShareService.reportLevelProgress).toHaveBeenCalledWith(
|
||||||
|
'user-uuid-1',
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,6 +15,8 @@ import { ApiResponseDto } from '../../common/dto/api-response.dto';
|
|||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
import type { JwtPayload } from '../../common/guards/jwt-auth.guard';
|
import type { JwtPayload } from '../../common/guards/jwt-auth.guard';
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { ReportLevelProgressDto } from './dto/report-level-progress.dto';
|
||||||
|
import { ReportLevelProgressResponseDto } from './dto/share-level-progress-response.dto';
|
||||||
|
|
||||||
@ApiTags('分享挑战')
|
@ApiTags('分享挑战')
|
||||||
@Controller('v1/share')
|
@Controller('v1/share')
|
||||||
@@ -54,4 +56,21 @@ export class ShareController {
|
|||||||
const data = await this.shareService.joinShare(user.sub, code);
|
const data = await this.shareService.joinShare(user.sub, code);
|
||||||
return ApiResponseDto.success(data);
|
return ApiResponseDto.success(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('progress')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '上报单关进度',
|
||||||
|
description: '用户在分享挑战中通关后上报进度,仅首次通关(passed=true)有效',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: '成功' })
|
||||||
|
@ApiResponse({ status: 404, description: '分享或关卡不存在' })
|
||||||
|
async reportLevelProgress(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Body() dto: ReportLevelProgressDto,
|
||||||
|
): Promise<ApiResponseDto<ReportLevelProgressResponseDto>> {
|
||||||
|
const data = await this.shareService.reportLevelProgress(user.sub, dto);
|
||||||
|
return ApiResponseDto.success(data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,18 +4,29 @@ import { ShareController } from './share.controller';
|
|||||||
import { ShareService } from './share.service';
|
import { ShareService } from './share.service';
|
||||||
import { ShareConfig } from './entities/share-config.entity';
|
import { ShareConfig } from './entities/share-config.entity';
|
||||||
import { ShareParticipant } from './entities/share-participant.entity';
|
import { ShareParticipant } from './entities/share-participant.entity';
|
||||||
|
import { ShareLevelProgress } from './entities/share-level-progress.entity';
|
||||||
import { ShareConfigRepository } from './repositories/share-config.repository';
|
import { ShareConfigRepository } from './repositories/share-config.repository';
|
||||||
import { ShareParticipantRepository } from './repositories/share-participant.repository';
|
import { ShareParticipantRepository } from './repositories/share-participant.repository';
|
||||||
|
import { ShareLevelProgressRepository } from './repositories/share-level-progress.repository';
|
||||||
import { WechatGameModule } from '../wechat-game/wechat-game.module';
|
import { WechatGameModule } from '../wechat-game/wechat-game.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([ShareConfig, ShareParticipant]),
|
TypeOrmModule.forFeature([
|
||||||
|
ShareConfig,
|
||||||
|
ShareParticipant,
|
||||||
|
ShareLevelProgress,
|
||||||
|
]),
|
||||||
WechatGameModule,
|
WechatGameModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
],
|
],
|
||||||
controllers: [ShareController],
|
controllers: [ShareController],
|
||||||
providers: [ShareService, ShareConfigRepository, ShareParticipantRepository],
|
providers: [
|
||||||
|
ShareService,
|
||||||
|
ShareConfigRepository,
|
||||||
|
ShareParticipantRepository,
|
||||||
|
ShareLevelProgressRepository,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class ShareModule {}
|
export class ShareModule {}
|
||||||
|
|||||||
409
src/modules/share/share.service.spec.ts
Normal file
409
src/modules/share/share.service.spec.ts
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import {
|
||||||
|
NotFoundException,
|
||||||
|
BadRequestException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ShareService } from './share.service';
|
||||||
|
import { ShareConfigRepository } from './repositories/share-config.repository';
|
||||||
|
import { ShareParticipantRepository } from './repositories/share-participant.repository';
|
||||||
|
import { ShareLevelProgressRepository } from './repositories/share-level-progress.repository';
|
||||||
|
import { LevelRepository } from '../wechat-game/repositories/level.repository';
|
||||||
|
import { Level } from '../wechat-game/entities/level.entity';
|
||||||
|
import { ShareConfig } from './entities/share-config.entity';
|
||||||
|
import { ShareParticipant } from './entities/share-participant.entity';
|
||||||
|
import { ShareLevelProgress } from './entities/share-level-progress.entity';
|
||||||
|
|
||||||
|
// Mock nanoid to return predictable values
|
||||||
|
jest.mock('nanoid', () => ({
|
||||||
|
nanoid: jest.fn(() => 'ABCD1234'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ShareService', () => {
|
||||||
|
let service: ShareService;
|
||||||
|
|
||||||
|
const mockLevels: Level[] = Array.from({ length: 6 }, (_, i) => ({
|
||||||
|
id: `level-${i + 1}`,
|
||||||
|
imageUrl: `https://example.com/meme${i + 1}.jpg`,
|
||||||
|
answer: `答案${i + 1}`,
|
||||||
|
hint1: `提示${i + 1}`,
|
||||||
|
hint2: null,
|
||||||
|
hint3: null,
|
||||||
|
sortOrder: i,
|
||||||
|
timeLimit: i === 0 ? 60 : null,
|
||||||
|
createdAt: new Date('2026-01-01'),
|
||||||
|
updatedAt: new Date('2026-01-01'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockShareConfig: ShareConfig = {
|
||||||
|
id: 'share-uuid-1',
|
||||||
|
shareCode: 'ABCD1234',
|
||||||
|
title: '我的挑战',
|
||||||
|
sharerId: 'user-uuid-1',
|
||||||
|
levelIds: mockLevels.map((l) => l.id),
|
||||||
|
sharer: {} as any,
|
||||||
|
participants: [],
|
||||||
|
createdAt: new Date('2026-01-01'),
|
||||||
|
updatedAt: new Date('2026-01-01'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockParticipant: ShareParticipant = {
|
||||||
|
id: 'participant-uuid-1',
|
||||||
|
shareConfigId: 'share-uuid-1',
|
||||||
|
participantId: 'user-uuid-2',
|
||||||
|
shareConfig: {} as any,
|
||||||
|
participant: {} as any,
|
||||||
|
createdAt: new Date('2026-01-01'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockShareConfigRepository = {
|
||||||
|
create: jest.fn(),
|
||||||
|
findByShareCode: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockShareParticipantRepository = {
|
||||||
|
addParticipant: jest.fn(),
|
||||||
|
countByShareConfigId: jest.fn(),
|
||||||
|
findByShareConfigAndParticipant: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockShareLevelProgressRepository = {
|
||||||
|
findByParticipantAndLevel: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockLevelRepository = {
|
||||||
|
findByIds: jest.fn(),
|
||||||
|
findById: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
ShareService,
|
||||||
|
{ provide: ShareConfigRepository, useValue: mockShareConfigRepository },
|
||||||
|
{
|
||||||
|
provide: ShareParticipantRepository,
|
||||||
|
useValue: mockShareParticipantRepository,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ShareLevelProgressRepository,
|
||||||
|
useValue: mockShareLevelProgressRepository,
|
||||||
|
},
|
||||||
|
{ provide: LevelRepository, useValue: mockLevelRepository },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<ShareService>(ShareService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createShare', () => {
|
||||||
|
const createDto = {
|
||||||
|
title: '我的挑战',
|
||||||
|
levelIds: ['level-1', 'level-2', 'level-3', 'level-4', 'level-5', 'level-6'],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should create a share with valid 6 unique levels', async () => {
|
||||||
|
mockLevelRepository.findByIds.mockResolvedValue(mockLevels);
|
||||||
|
mockShareConfigRepository.findByShareCode.mockResolvedValue(null);
|
||||||
|
mockShareConfigRepository.create.mockResolvedValue(mockShareConfig);
|
||||||
|
|
||||||
|
const result = await service.createShare('user-uuid-1', createDto);
|
||||||
|
|
||||||
|
expect(result.shareCode).toBe('ABCD1234');
|
||||||
|
expect(result.title).toBe('我的挑战');
|
||||||
|
expect(result.levelCount).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw BadRequestException when level IDs have duplicates', async () => {
|
||||||
|
const duplicateDto = {
|
||||||
|
title: '测试',
|
||||||
|
levelIds: ['level-1', 'level-1', 'level-2', 'level-3', 'level-4', 'level-5'],
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createShare('user-uuid-1', duplicateDto),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundException when some levels do not exist', async () => {
|
||||||
|
mockLevelRepository.findByIds.mockResolvedValue(mockLevels.slice(0, 4));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createShare('user-uuid-1', createDto),
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw BadRequestException when share code generation fails', async () => {
|
||||||
|
mockLevelRepository.findByIds.mockResolvedValue(mockLevels);
|
||||||
|
// All 3 attempts find existing codes
|
||||||
|
mockShareConfigRepository.findByShareCode.mockResolvedValue(
|
||||||
|
mockShareConfig,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createShare('user-uuid-1', createDto),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('joinShare', () => {
|
||||||
|
it('should return share details with ordered levels for a participant', async () => {
|
||||||
|
mockShareConfigRepository.findByShareCode.mockResolvedValue(
|
||||||
|
mockShareConfig,
|
||||||
|
);
|
||||||
|
mockShareParticipantRepository.addParticipant.mockResolvedValue(undefined);
|
||||||
|
mockLevelRepository.findByIds.mockResolvedValue(mockLevels);
|
||||||
|
|
||||||
|
const result = await service.joinShare('user-uuid-2', 'ABCD1234');
|
||||||
|
|
||||||
|
expect(result.shareCode).toBe('ABCD1234');
|
||||||
|
expect(result.title).toBe('我的挑战');
|
||||||
|
expect(result.levels).toHaveLength(6);
|
||||||
|
expect(result.levels[0].level).toBe(1);
|
||||||
|
expect(result.levels[0].id).toBe('level-1');
|
||||||
|
expect(mockShareParticipantRepository.addParticipant).toHaveBeenCalledWith(
|
||||||
|
'share-uuid-1',
|
||||||
|
'user-uuid-2',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not add participant when user is the sharer', async () => {
|
||||||
|
mockShareConfigRepository.findByShareCode.mockResolvedValue(
|
||||||
|
mockShareConfig,
|
||||||
|
);
|
||||||
|
mockLevelRepository.findByIds.mockResolvedValue(mockLevels);
|
||||||
|
|
||||||
|
const result = await service.joinShare('user-uuid-1', 'ABCD1234');
|
||||||
|
|
||||||
|
expect(result.shareCode).toBe('ABCD1234');
|
||||||
|
expect(
|
||||||
|
mockShareParticipantRepository.addParticipant,
|
||||||
|
).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundException when share code not found', async () => {
|
||||||
|
mockShareConfigRepository.findByShareCode.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.joinShare('user-uuid-2', 'INVALID'),
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reportLevelProgress', () => {
|
||||||
|
const reportDto = {
|
||||||
|
shareCode: 'ABCD1234',
|
||||||
|
levelId: 'level-1',
|
||||||
|
passed: true,
|
||||||
|
timeSpent: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should create new progress record for first attempt', async () => {
|
||||||
|
const levelWithTimeLimit = { ...mockLevels[0], timeLimit: 60 };
|
||||||
|
mockShareConfigRepository.findByShareCode.mockResolvedValue(
|
||||||
|
mockShareConfig,
|
||||||
|
);
|
||||||
|
mockLevelRepository.findById.mockResolvedValue(levelWithTimeLimit);
|
||||||
|
mockShareParticipantRepository.findByShareConfigAndParticipant.mockResolvedValue(
|
||||||
|
mockParticipant,
|
||||||
|
);
|
||||||
|
mockShareLevelProgressRepository.findByParticipantAndLevel.mockResolvedValue(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const newProgress: Partial<ShareLevelProgress> = {
|
||||||
|
participantId: 'participant-uuid-1',
|
||||||
|
levelId: 'level-1',
|
||||||
|
passed: true,
|
||||||
|
timeSpent: 30,
|
||||||
|
};
|
||||||
|
mockShareLevelProgressRepository.create.mockReturnValue(newProgress);
|
||||||
|
mockShareLevelProgressRepository.save.mockResolvedValue(newProgress);
|
||||||
|
|
||||||
|
const result = await service.reportLevelProgress(
|
||||||
|
'user-uuid-2',
|
||||||
|
reportDto,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.passed).toBe(true);
|
||||||
|
expect(result.timeLimit).toBe(60);
|
||||||
|
expect(result.withinTimeLimit).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return existing result when already passed (idempotent)', async () => {
|
||||||
|
const levelWithTimeLimit = { ...mockLevels[0], timeLimit: 60 };
|
||||||
|
mockShareConfigRepository.findByShareCode.mockResolvedValue(
|
||||||
|
mockShareConfig,
|
||||||
|
);
|
||||||
|
mockLevelRepository.findById.mockResolvedValue(levelWithTimeLimit);
|
||||||
|
mockShareParticipantRepository.findByShareConfigAndParticipant.mockResolvedValue(
|
||||||
|
mockParticipant,
|
||||||
|
);
|
||||||
|
mockShareLevelProgressRepository.findByParticipantAndLevel.mockResolvedValue(
|
||||||
|
{
|
||||||
|
id: 'progress-uuid-1',
|
||||||
|
participantId: 'participant-uuid-1',
|
||||||
|
levelId: 'level-1',
|
||||||
|
passed: true,
|
||||||
|
timeSpent: 25,
|
||||||
|
completedAt: new Date(),
|
||||||
|
} as ShareLevelProgress,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.reportLevelProgress(
|
||||||
|
'user-uuid-2',
|
||||||
|
reportDto,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.passed).toBe(true);
|
||||||
|
expect(result.withinTimeLimit).toBe(true);
|
||||||
|
expect(mockShareLevelProgressRepository.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report withinTimeLimit=false when time exceeds limit', async () => {
|
||||||
|
const levelWithTimeLimit = { ...mockLevels[0], timeLimit: 20 };
|
||||||
|
mockShareConfigRepository.findByShareCode.mockResolvedValue(
|
||||||
|
mockShareConfig,
|
||||||
|
);
|
||||||
|
mockLevelRepository.findById.mockResolvedValue(levelWithTimeLimit);
|
||||||
|
mockShareParticipantRepository.findByShareConfigAndParticipant.mockResolvedValue(
|
||||||
|
mockParticipant,
|
||||||
|
);
|
||||||
|
mockShareLevelProgressRepository.findByParticipantAndLevel.mockResolvedValue(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const newProgress: Partial<ShareLevelProgress> = {
|
||||||
|
participantId: 'participant-uuid-1',
|
||||||
|
levelId: 'level-1',
|
||||||
|
passed: true,
|
||||||
|
timeSpent: 30,
|
||||||
|
};
|
||||||
|
mockShareLevelProgressRepository.create.mockReturnValue(newProgress);
|
||||||
|
mockShareLevelProgressRepository.save.mockResolvedValue(newProgress);
|
||||||
|
|
||||||
|
const result = await service.reportLevelProgress('user-uuid-2', {
|
||||||
|
...reportDto,
|
||||||
|
timeSpent: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.passed).toBe(true);
|
||||||
|
expect(result.withinTimeLimit).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report withinTimeLimit=true when level has no time limit', async () => {
|
||||||
|
const levelNoTimeLimit = { ...mockLevels[1], timeLimit: null };
|
||||||
|
mockShareConfigRepository.findByShareCode.mockResolvedValue(
|
||||||
|
mockShareConfig,
|
||||||
|
);
|
||||||
|
mockLevelRepository.findById.mockResolvedValue(levelNoTimeLimit);
|
||||||
|
mockShareParticipantRepository.findByShareConfigAndParticipant.mockResolvedValue(
|
||||||
|
mockParticipant,
|
||||||
|
);
|
||||||
|
mockShareLevelProgressRepository.findByParticipantAndLevel.mockResolvedValue(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const newProgress: Partial<ShareLevelProgress> = {
|
||||||
|
participantId: 'participant-uuid-1',
|
||||||
|
levelId: 'level-2',
|
||||||
|
passed: true,
|
||||||
|
timeSpent: 999,
|
||||||
|
};
|
||||||
|
mockShareLevelProgressRepository.create.mockReturnValue(newProgress);
|
||||||
|
mockShareLevelProgressRepository.save.mockResolvedValue(newProgress);
|
||||||
|
|
||||||
|
const result = await service.reportLevelProgress('user-uuid-2', {
|
||||||
|
shareCode: 'ABCD1234',
|
||||||
|
levelId: 'level-2',
|
||||||
|
passed: true,
|
||||||
|
timeSpent: 999,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.withinTimeLimit).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create participant if not exists when reporting progress', async () => {
|
||||||
|
mockShareConfigRepository.findByShareCode.mockResolvedValue(
|
||||||
|
mockShareConfig,
|
||||||
|
);
|
||||||
|
mockLevelRepository.findById.mockResolvedValue(mockLevels[0]);
|
||||||
|
mockShareParticipantRepository.findByShareConfigAndParticipant.mockResolvedValue(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const createdParticipant = { ...mockParticipant };
|
||||||
|
mockShareParticipantRepository.create.mockReturnValue(createdParticipant);
|
||||||
|
mockShareParticipantRepository.save.mockResolvedValue(createdParticipant);
|
||||||
|
mockShareLevelProgressRepository.findByParticipantAndLevel.mockResolvedValue(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
mockShareLevelProgressRepository.create.mockReturnValue({} as any);
|
||||||
|
mockShareLevelProgressRepository.save.mockResolvedValue({} as any);
|
||||||
|
|
||||||
|
await service.reportLevelProgress('user-uuid-2', reportDto);
|
||||||
|
|
||||||
|
expect(mockShareParticipantRepository.create).toHaveBeenCalledWith({
|
||||||
|
shareConfigId: 'share-uuid-1',
|
||||||
|
participantId: 'user-uuid-2',
|
||||||
|
});
|
||||||
|
expect(mockShareParticipantRepository.save).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundException when share not found', async () => {
|
||||||
|
mockShareConfigRepository.findByShareCode.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.reportLevelProgress('user-uuid-2', reportDto),
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundException when level not found', async () => {
|
||||||
|
mockShareConfigRepository.findByShareCode.mockResolvedValue(
|
||||||
|
mockShareConfig,
|
||||||
|
);
|
||||||
|
mockLevelRepository.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.reportLevelProgress('user-uuid-2', reportDto),
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update existing progress when not yet passed', async () => {
|
||||||
|
mockShareConfigRepository.findByShareCode.mockResolvedValue(
|
||||||
|
mockShareConfig,
|
||||||
|
);
|
||||||
|
mockLevelRepository.findById.mockResolvedValue(mockLevels[0]);
|
||||||
|
mockShareParticipantRepository.findByShareConfigAndParticipant.mockResolvedValue(
|
||||||
|
mockParticipant,
|
||||||
|
);
|
||||||
|
const existingProgress = {
|
||||||
|
id: 'progress-uuid-1',
|
||||||
|
participantId: 'participant-uuid-1',
|
||||||
|
levelId: 'level-1',
|
||||||
|
passed: false,
|
||||||
|
timeSpent: 15,
|
||||||
|
completedAt: new Date('2026-01-01'),
|
||||||
|
} as ShareLevelProgress;
|
||||||
|
mockShareLevelProgressRepository.findByParticipantAndLevel.mockResolvedValue(
|
||||||
|
existingProgress,
|
||||||
|
);
|
||||||
|
mockShareLevelProgressRepository.save.mockResolvedValue({
|
||||||
|
...existingProgress,
|
||||||
|
passed: true,
|
||||||
|
timeSpent: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.reportLevelProgress(
|
||||||
|
'user-uuid-2',
|
||||||
|
reportDto,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.passed).toBe(true);
|
||||||
|
expect(mockShareLevelProgressRepository.save).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,13 +6,16 @@ import {
|
|||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { ShareConfigRepository } from './repositories/share-config.repository';
|
import { ShareConfigRepository } from './repositories/share-config.repository';
|
||||||
import { ShareParticipantRepository } from './repositories/share-participant.repository';
|
import { ShareParticipantRepository } from './repositories/share-participant.repository';
|
||||||
|
import { ShareLevelProgressRepository } from './repositories/share-level-progress.repository';
|
||||||
import { LevelRepository } from '../wechat-game/repositories/level.repository';
|
import { LevelRepository } from '../wechat-game/repositories/level.repository';
|
||||||
import { CreateShareDto } from './dto/create-share.dto';
|
import { CreateShareDto } from './dto/create-share.dto';
|
||||||
|
import { ReportLevelProgressDto } from './dto/report-level-progress.dto';
|
||||||
import {
|
import {
|
||||||
CreateShareResponseDto,
|
CreateShareResponseDto,
|
||||||
JoinShareResponseDto,
|
JoinShareResponseDto,
|
||||||
ShareLevelDto,
|
ShareLevelDto,
|
||||||
} from './dto/share-response.dto';
|
} from './dto/share-response.dto';
|
||||||
|
import { ReportLevelProgressResponseDto } from './dto/share-level-progress-response.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ShareService {
|
export class ShareService {
|
||||||
@@ -20,6 +23,7 @@ export class ShareService {
|
|||||||
private readonly shareConfigRepository: ShareConfigRepository,
|
private readonly shareConfigRepository: ShareConfigRepository,
|
||||||
private readonly shareParticipantRepository: ShareParticipantRepository,
|
private readonly shareParticipantRepository: ShareParticipantRepository,
|
||||||
private readonly levelRepository: LevelRepository,
|
private readonly levelRepository: LevelRepository,
|
||||||
|
private readonly shareLevelProgressRepository: ShareLevelProgressRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createShare(
|
async createShare(
|
||||||
@@ -105,4 +109,85 @@ export class ShareService {
|
|||||||
levels,
|
levels,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reportLevelProgress(
|
||||||
|
userId: string,
|
||||||
|
dto: ReportLevelProgressDto,
|
||||||
|
): Promise<ReportLevelProgressResponseDto> {
|
||||||
|
const [config, level] = await Promise.all([
|
||||||
|
this.shareConfigRepository.findByShareCode(dto.shareCode),
|
||||||
|
this.levelRepository.findById(dto.levelId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
throw new NotFoundException('分享不存在或已过期');
|
||||||
|
}
|
||||||
|
if (!level) {
|
||||||
|
throw new NotFoundException('关卡不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.levelIds.includes(dto.levelId)) {
|
||||||
|
throw new BadRequestException('该关卡不属于此分享挑战');
|
||||||
|
}
|
||||||
|
|
||||||
|
let participant =
|
||||||
|
await this.shareParticipantRepository.findByShareConfigAndParticipant(
|
||||||
|
config.id,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
if (!participant) {
|
||||||
|
participant = this.shareParticipantRepository.create({
|
||||||
|
shareConfigId: config.id,
|
||||||
|
participantId: userId,
|
||||||
|
});
|
||||||
|
participant = await this.shareParticipantRepository.save(participant);
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress =
|
||||||
|
await this.shareLevelProgressRepository.findByParticipantAndLevel(
|
||||||
|
participant.id,
|
||||||
|
dto.levelId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dto.passed && progress?.passed) {
|
||||||
|
return {
|
||||||
|
passed: true,
|
||||||
|
timeLimit: level.timeLimit,
|
||||||
|
withinTimeLimit: this.isWithinTimeLimit(level.timeLimit, progress.timeSpent),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const withinTimeLimit = dto.passed
|
||||||
|
? this.isWithinTimeLimit(level.timeLimit, dto.timeSpent)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const updatedProgress = progress
|
||||||
|
? Object.assign(this.shareLevelProgressRepository.create(progress), {
|
||||||
|
passed: dto.passed,
|
||||||
|
timeSpent: dto.timeSpent,
|
||||||
|
completedAt: dto.passed ? new Date() : progress.completedAt,
|
||||||
|
})
|
||||||
|
: this.shareLevelProgressRepository.create({
|
||||||
|
participantId: participant.id,
|
||||||
|
levelId: dto.levelId,
|
||||||
|
passed: dto.passed,
|
||||||
|
timeSpent: dto.timeSpent,
|
||||||
|
completedAt: dto.passed ? new Date() : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.shareLevelProgressRepository.save(updatedProgress);
|
||||||
|
|
||||||
|
return {
|
||||||
|
passed: dto.passed,
|
||||||
|
timeLimit: level.timeLimit,
|
||||||
|
withinTimeLimit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private isWithinTimeLimit(
|
||||||
|
timeLimit: number | null,
|
||||||
|
timeSpent: number,
|
||||||
|
): boolean {
|
||||||
|
return timeLimit === null || timeSpent <= timeLimit;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export class Level {
|
|||||||
@Column({ type: 'int', name: 'sort_order', default: 0 })
|
@Column({ type: 'int', name: 'sort_order', default: 0 })
|
||||||
sortOrder!: number;
|
sortOrder!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'int', name: 'time_limit', nullable: true, default: null })
|
||||||
|
timeLimit!: number | null;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
|
|||||||
133
src/modules/wechat-game/wechat-game.controller.spec.ts
Normal file
133
src/modules/wechat-game/wechat-game.controller.spec.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
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,41 +1,69 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { NotFoundException } from '@nestjs/common';
|
||||||
import { WechatGameService } from './wechat-game.service';
|
import { WechatGameService } from './wechat-game.service';
|
||||||
import { GameConfigRepository } from './repositories/game-config.repository';
|
import { GameConfigRepository } from './repositories/game-config.repository';
|
||||||
import { NotFoundException } from '@nestjs/common';
|
import { LevelRepository } from './repositories/level.repository';
|
||||||
import { GameConfig } from './entities/game-config.entity';
|
import { GameConfig } from './entities/game-config.entity';
|
||||||
|
import { Level } from './entities/level.entity';
|
||||||
|
|
||||||
describe('WechatGameService', () => {
|
describe('WechatGameService', () => {
|
||||||
let service: WechatGameService;
|
let service: WechatGameService;
|
||||||
let repository: GameConfigRepository;
|
|
||||||
|
|
||||||
const mockGameConfig: GameConfig = {
|
const mockGameConfig: GameConfig = {
|
||||||
id: 'test-uuid',
|
id: 'config-uuid-1',
|
||||||
configKey: 'game_speed',
|
configKey: 'game_speed',
|
||||||
configValue: '1.5',
|
configValue: '1.5',
|
||||||
description: 'Game speed multiplier',
|
description: 'Game speed multiplier',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
createdAt: new Date(),
|
createdAt: new Date('2026-01-01'),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date('2026-01-01'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockRepository = {
|
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(),
|
findActiveConfigs: jest.fn(),
|
||||||
findByKey: jest.fn(),
|
findByKey: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockLevelRepository = {
|
||||||
|
findAllOrdered: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
WechatGameService,
|
WechatGameService,
|
||||||
{
|
{ provide: GameConfigRepository, useValue: mockGameConfigRepository },
|
||||||
provide: GameConfigRepository,
|
{ provide: LevelRepository, useValue: mockLevelRepository },
|
||||||
useValue: mockRepository,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<WechatGameService>(WechatGameService);
|
service = module.get<WechatGameService>(WechatGameService);
|
||||||
repository = module.get<GameConfigRepository>(GameConfigRepository);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -44,18 +72,20 @@ describe('WechatGameService', () => {
|
|||||||
|
|
||||||
describe('getAllConfigs', () => {
|
describe('getAllConfigs', () => {
|
||||||
it('should return all active configs', async () => {
|
it('should return all active configs', async () => {
|
||||||
mockRepository.findActiveConfigs.mockResolvedValue([mockGameConfig]);
|
mockGameConfigRepository.findActiveConfigs.mockResolvedValue([
|
||||||
|
mockGameConfig,
|
||||||
|
]);
|
||||||
|
|
||||||
const result = await service.getAllConfigs();
|
const result = await service.getAllConfigs();
|
||||||
|
|
||||||
expect(result.configs).toHaveLength(1);
|
expect(result.configs).toHaveLength(1);
|
||||||
expect(result.total).toBe(1);
|
expect(result.total).toBe(1);
|
||||||
expect(result.configs[0].configKey).toBe('game_speed');
|
expect(result.configs[0].configKey).toBe('game_speed');
|
||||||
expect(mockRepository.findActiveConfigs).toHaveBeenCalled();
|
expect(result.configs[0].configValue).toBe('1.5');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return empty array when no configs found', async () => {
|
it('should return empty array when no configs found', async () => {
|
||||||
mockRepository.findActiveConfigs.mockResolvedValue([]);
|
mockGameConfigRepository.findActiveConfigs.mockResolvedValue([]);
|
||||||
|
|
||||||
const result = await service.getAllConfigs();
|
const result = await service.getAllConfigs();
|
||||||
|
|
||||||
@@ -66,21 +96,74 @@ describe('WechatGameService', () => {
|
|||||||
|
|
||||||
describe('getConfigByKey', () => {
|
describe('getConfigByKey', () => {
|
||||||
it('should return config by key', async () => {
|
it('should return config by key', async () => {
|
||||||
mockRepository.findByKey.mockResolvedValue(mockGameConfig);
|
mockGameConfigRepository.findByKey.mockResolvedValue(mockGameConfig);
|
||||||
|
|
||||||
const result = await service.getConfigByKey('game_speed');
|
const result = await service.getConfigByKey('game_speed');
|
||||||
|
|
||||||
expect(result.configKey).toBe('game_speed');
|
expect(result.configKey).toBe('game_speed');
|
||||||
expect(result.configValue).toBe('1.5');
|
expect(result.configValue).toBe('1.5');
|
||||||
expect(mockRepository.findByKey).toHaveBeenCalledWith('game_speed');
|
expect(mockGameConfigRepository.findByKey).toHaveBeenCalledWith(
|
||||||
|
'game_speed',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw NotFoundException when config not found', async () => {
|
it('should throw NotFoundException when config not found', async () => {
|
||||||
mockRepository.findByKey.mockResolvedValue(null);
|
mockGameConfigRepository.findByKey.mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(service.getConfigByKey('nonexistent')).rejects.toThrow(
|
await expect(service.getConfigByKey('nonexistent')).rejects.toThrow(
|
||||||
NotFoundException,
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user