Compare commits
10 Commits
2dd22b10b1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f8db6c473 | ||
|
|
25d196263b | ||
|
|
e5d6c3a674 | ||
|
|
e6079e4345 | ||
|
|
1d6cd0cdc0 | ||
|
|
fe2c13258e | ||
|
|
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)`
|
||||
6
.claude/settings.json
Normal file
6
.claude/settings.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"code-review@claude-plugins-official": true,
|
||||
"claude-md-management@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
|
||||
WX_APPID=wx0f9c909d20d19396
|
||||
WX_SECRET=c5635680747cccf351f5f323c01178e6
|
||||
WX_APPID=wxaf07f8bb60098991
|
||||
WX_SECRET=b390c318fe83600e60fd4ad5d88b603f
|
||||
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
|
||||
42
AGENTS.md
Normal file
42
AGENTS.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 仓库协作指南
|
||||
|
||||
## 项目结构与模块组织
|
||||
|
||||
本仓库是 MemeMind 的 NestJS 后端服务。应用代码位于 `src/`。通用守卫、过滤器、装饰器和基础 DTO 位于 `src/common/`。运行时配置和 TypeORM 配置位于 `src/config/` 与 `src/database/`。业务代码按领域划分在 `src/modules/` 下,包括 `auth/`、`level/`、`share/`、`user/` 和 `game-config/`。新增代码应放入对应模块,并遵循 Nest 的常规结构:`*.controller.ts`、`*.service.ts`、`*.module.ts`,以及按需补充 `dto/`、`entities/`、`repositories/`。单元测试与源码相邻,命名为 `*.spec.ts`;端到端测试位于 `test/`。
|
||||
|
||||
## 构建、测试与开发命令
|
||||
|
||||
使用 `pnpm`,仓库已包含 `pnpm-lock.yaml`。
|
||||
|
||||
- `pnpm install`:安装依赖。
|
||||
- `pnpm run start:dev`:以 watch 模式启动本地开发服务。
|
||||
- `pnpm run build`:将 TypeScript 编译到 `dist/`。
|
||||
- `pnpm run start:prod`:从 `dist/main` 启动生产构建。
|
||||
- `pnpm run lint`:运行 ESLint 并自动修复可修复问题。
|
||||
- `pnpm run format`:使用 Prettier 格式化 `src/` 与 `test/`。
|
||||
- `pnpm run test`、`pnpm run test:cov`、`pnpm run test:e2e`:运行单测、覆盖率测试和 e2e 测试。
|
||||
|
||||
## 编码风格与命名规范
|
||||
|
||||
使用严格类型的 TypeScript;除非处于明确的边界场景,否则避免新增 `any`。Prettier 约束为单引号和尾随逗号。遵循现有的 2 空格缩进和 NestJS 命名习惯:类使用 `PascalCase`,方法与变量使用 `camelCase`,目录使用 kebab-case,DTO 文件名要具备明确语义,例如 `wx-login.dto.ts` 或 `share-response.dto.ts`。
|
||||
|
||||
## 测试规范
|
||||
|
||||
单元测试和 e2e 测试均使用 Jest。单元测试文件命名为 `*.spec.ts`,并与被测源码放在一起。只要 controller 契约、service 逻辑、repository 行为或鉴权流程发生变化,就需要新增或更新测试。提交 PR 前应运行 `pnpm run test:cov`,覆盖率结果输出到 `coverage/`。
|
||||
|
||||
## 接口文档规范
|
||||
|
||||
只要接口发生改动,必须同步更新 `docs/api/` 目录中的对应文档,确保客户端可以直接使用该目录下的文档进行联调。
|
||||
|
||||
- 新增接口:在对应模块文档中新增接口章节,并补充请求参数、响应结构、示例和调用场景。
|
||||
- 修改接口:同步更新字段、鉴权方式、错误码、业务规则和示例。
|
||||
- 删除或废弃接口:在文档中明确标记,并说明客户端迁移方式。
|
||||
- 如果一次改动涉及多个模块接口,相关文档都要一并更新,不能只改代码不改文档。
|
||||
|
||||
## 提交与合并请求规范
|
||||
|
||||
最近的提交历史使用 Conventional Commit 前缀,例如 `feat:`、`perf:`、`refactor:`,也包含带作用域的形式,例如 `feat(share): ...`。请保持提交聚焦,并沿用同样格式。PR 需要说明行为变更、配置或迁移影响、关联的 issue(如果有),并在请求或响应结构发生变化时附上 API 示例。
|
||||
|
||||
## 配置与部署说明
|
||||
|
||||
环境变量在 `src/config/env.validation.ts` 中做校验。敏感信息应保存在 `.env.local` 或部署环境专用配置中,不能写入源码。无论本地还是生产环境,API 统一暴露在 `/api` 下,Swagger 暴露在 `/api/docs`。`pnpm run deploy` 会调用 `deploy.sh`、`rsync` 和 PM2,因此执行前需要先检查其中的服务器相关配置。
|
||||
@@ -1,706 +0,0 @@
|
||||
# MemeMind-Server Architecture Diagrams & Flows
|
||||
|
||||
## 1. System Architecture Overview
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ WeChat Mini-Game Client │
|
||||
│ (Cocos Creator 3.8.8) │
|
||||
│ │
|
||||
│ • PageLoading.ts (startup) │
|
||||
│ • LevelDataManager.ts (API calls) │
|
||||
│ • PageLevel.ts (gameplay) │
|
||||
│ • StorageManager.ts (localStorage) │
|
||||
└────────────────────┬────────────────────────────────────────┘
|
||||
│
|
||||
│ HTTP Requests
|
||||
│ GET /api/v1/wechat-game/levels
|
||||
│ GET /api/v1/wechat-game/configs
|
||||
│
|
||||
┌────────────────────▼────────────────────────────────────────┐
|
||||
│ MemeMind-Server (NestJS) │
|
||||
│ http://ilookai.cn:3000/api │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ HTTP Layer │ │
|
||||
│ │ • GlobalPrefix: /api │ │
|
||||
│ │ • CORS: Enabled │ │
|
||||
│ │ • ValidationPipe: Global validation │ │
|
||||
│ │ • Swagger: /api/docs │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────▼──────────────────────────────┐ │
|
||||
│ │ WechatGameController │ │
|
||||
│ │ POST /v1/wechat-game/configs │ │
|
||||
│ │ GET /v1/wechat-game/configs/:key │ │
|
||||
│ │ GET /v1/wechat-game/levels │ │
|
||||
│ │ GET /v1/wechat-game/levels/:id │ │
|
||||
│ └──────────────────────┬──────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────▼──────────────────────────────┐ │
|
||||
│ │ WechatGameService │ │
|
||||
│ │ • getAllConfigs() │ │
|
||||
│ │ • getConfigByKey(key) │ │
|
||||
│ │ • getAllLevels() │ │
|
||||
│ │ • getLevelById(id) │ │
|
||||
│ │ • toResponseDto() │ │
|
||||
│ │ • toLevelResponseDto() │ │
|
||||
│ └──────────────────────┬──────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────▼──────────────────────────────┐ │
|
||||
│ │ Repository Layer │ │
|
||||
│ │ ├─ LevelRepository │ │
|
||||
│ │ └─ GameConfigRepository │ │
|
||||
│ └──────────────────────┬──────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────▼──────────────────────────────┐ │
|
||||
│ │ TypeORM / MySQL │ │
|
||||
│ │ ├─ levels table │ │
|
||||
│ │ └─ game_configs table │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ Response (JSON)
|
||||
│ {success, data, message, timestamp}
|
||||
│
|
||||
┌────────────────────▼────────────────────────────────────────┐
|
||||
│ WeChat Mini-Game Client │
|
||||
│ • LevelDataManager stores in _apiData │
|
||||
│ • PageLevel reads _apiData │
|
||||
│ • Images preloaded via assetManager.loadRemote() │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Request-Response Flow
|
||||
|
||||
### Scenario 1: Get All Levels
|
||||
|
||||
```
|
||||
Client Server
|
||||
│ │
|
||||
├─ GET /api/v1/wechat-game/levels
|
||||
│─────────────────────────────>│
|
||||
│ │
|
||||
│ [Controller]
|
||||
│ getAllLevels()
|
||||
│ │
|
||||
│ [Service]
|
||||
│ levelRepository.findAllOrdered()
|
||||
│ │
|
||||
│ [Repository]
|
||||
│ SELECT * FROM levels ORDER BY sort_order
|
||||
│ │
|
||||
│ [MySQL]
|
||||
│ Returns Level[]
|
||||
│ │
|
||||
│ [Service]
|
||||
│ Map to LevelResponseDto
|
||||
│ Add level numbers
|
||||
│ │
|
||||
│ [Filter]
|
||||
│ Wrap in ApiResponseDto.success()
|
||||
│ │
|
||||
│ <─────────────────────────────│
|
||||
│ { │
|
||||
│ "success": true, │
|
||||
│ "data": { │
|
||||
│ "levels": [...], │
|
||||
│ "total": 50 │
|
||||
│ }, │
|
||||
│ "message": null, │
|
||||
│ "timestamp": "2026-04-05..." │
|
||||
│ } │
|
||||
│ │
|
||||
├─ Store in _apiData
|
||||
├─ Preload images
|
||||
└─ Ready for gameplay
|
||||
```
|
||||
|
||||
### Scenario 2: Get Config by Key
|
||||
|
||||
```
|
||||
Client Server
|
||||
│ │
|
||||
├─ GET /api/v1/wechat-game/configs/HINT_COST
|
||||
│─────────────────────────────>│
|
||||
│ │
|
||||
│ [Controller]
|
||||
│ getConfigByKey("HINT_COST")
|
||||
│ │
|
||||
│ [Service]
|
||||
│ gameConfigRepository.findByKey()
|
||||
│ │
|
||||
│ [Repository]
|
||||
│ SELECT * FROM game_configs
|
||||
│ WHERE config_key = 'HINT_COST'
|
||||
│ │
|
||||
│ [MySQL]
|
||||
│ Returns GameConfig or null
|
||||
│ │
|
||||
│ ┌─────────┴─────────┐
|
||||
│ │ │
|
||||
│ FOUND NOT FOUND
|
||||
│ │ │
|
||||
│ [Service] [Service]
|
||||
│ Map to DTO throw NotFoundException
|
||||
│ │ │
|
||||
│ [Filter] [Filter]
|
||||
│ success() catch exception
|
||||
│ │ │
|
||||
│ <─────────────────┤ │
|
||||
│ { │ │
|
||||
│ "success": true,│ │
|
||||
│ "data": {...} │ │
|
||||
│ } │ ┌────────────────┘
|
||||
│ │ │
|
||||
│ │ └─> {
|
||||
│ │ "success": false,
|
||||
│ │ "data": null,
|
||||
│ │ "message": "Game config... not found",
|
||||
│ │ "path": "/api/v1/..."
|
||||
│ │ }
|
||||
│ │
|
||||
└──────────────────────────────────
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Module Dependency Graph
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ AppModule (root) │
|
||||
├──────────────────────────────┤
|
||||
│ │
|
||||
│ imports: [ │
|
||||
│ AppConfigModule, │
|
||||
│ TypeOrmModule, │
|
||||
│ WechatGameModule │
|
||||
│ ] │
|
||||
│ │
|
||||
└──┬───────────────────────────┘
|
||||
│
|
||||
├─────────────────────────────────────┐
|
||||
│ │
|
||||
│ │
|
||||
┌──▼─────────────────┐ ┌──────────────▼──────────────┐
|
||||
│ AppConfigModule │ │ WechatGameModule │
|
||||
├────────────────────┤ ├─────────────────────────────┤
|
||||
│ @Global() │ │ imports: [ │
|
||||
│ │ │ TypeOrmModule.forFeature( │
|
||||
│ imports: [ │ │ [GameConfig, Level] │
|
||||
│ ConfigModule │ │ ) │
|
||||
│ ] │ │ ] │
|
||||
│ │ │ │
|
||||
│ exports: [ │ │ controllers: [ │
|
||||
│ ConfigModule │ │ WechatGameController │
|
||||
│ ] │ │ ] │
|
||||
│ │ │ │
|
||||
│ │ │ providers: [ │
|
||||
│ │ │ WechatGameService, │
|
||||
│ │ │ LevelRepository, │
|
||||
│ │ │ GameConfigRepository │
|
||||
│ │ │ ] │
|
||||
│ │ │ │
|
||||
│ │ │ exports: [ │
|
||||
│ │ │ WechatGameService │
|
||||
│ │ │ ] │
|
||||
│ │ │ │
|
||||
└────────────────────┘ └─────────────────────────────┘
|
||||
│
|
||||
│
|
||||
┌──────────▼───────────────┐
|
||||
│ TypeOrmModule.forFeature│
|
||||
├───────────────────────────┤
|
||||
│ Registers: │
|
||||
│ • Level entity │
|
||||
│ • GameConfig entity │
|
||||
│ • Auto-creates repos │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Data Model Relationships
|
||||
|
||||
```
|
||||
┌────────────────────────────────┐
|
||||
│ levels │
|
||||
├────────────────────────────────┤
|
||||
│ PK: id (VARCHAR 191) │
|
||||
├────────────────────────────────┤
|
||||
│ id (PK) │
|
||||
│ image_url (VARCHAR) │
|
||||
│ answer (VARCHAR) │
|
||||
│ hint1 (VARCHAR) │
|
||||
│ hint2 (VARCHAR) │
|
||||
│ hint3 (VARCHAR) │
|
||||
│ sort_order (INT) │
|
||||
│ created_at (DATETIME)│
|
||||
│ updated_at (DATETIME)│
|
||||
├────────────────────────────────┤
|
||||
│ Indexes: │
|
||||
│ • PK: id │
|
||||
│ • idx_sort_order: sort_order │
|
||||
├────────────────────────────────┤
|
||||
│ Used by: │
|
||||
│ • LevelRepository │
|
||||
│ • WechatGameService │
|
||||
└────────────────────────────────┘
|
||||
|
||||
┌────────────────────────────────┐
|
||||
│ game_configs │
|
||||
├────────────────────────────────┤
|
||||
│ PK: id (UUID) │
|
||||
├────────────────────────────────┤
|
||||
│ id (PK) │
|
||||
│ config_key (VARCHAR) │
|
||||
│ config_value (TEXT) │
|
||||
│ description (VARCHAR) │
|
||||
│ is_active (BOOLEAN) │
|
||||
│ created_at (DATETIME)│
|
||||
│ updated_at (DATETIME)│
|
||||
├────────────────────────────────┤
|
||||
│ Indexes: │
|
||||
│ • PK: id │
|
||||
│ • UNIQUE: config_key │
|
||||
│ • idx_active: is_active │
|
||||
├────────────────────────────────┤
|
||||
│ Used by: │
|
||||
│ • GameConfigRepository │
|
||||
│ • WechatGameService │
|
||||
└────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Service Method Call Chain
|
||||
|
||||
### GET /api/v1/wechat-game/levels
|
||||
|
||||
```
|
||||
Controller.getAllLevels()
|
||||
│
|
||||
├─> service.getAllLevels()
|
||||
│ │
|
||||
│ ├─> levelRepository.findAllOrdered()
|
||||
│ │ │
|
||||
│ │ └─> repository.find({ order: { sortOrder: 'ASC' } })
|
||||
│ │ │
|
||||
│ │ └─> [SELECT * FROM levels ORDER BY sort_order ASC]
|
||||
│ │
|
||||
│ ├─> LOOP levels array:
|
||||
│ │ ├─> toLevelResponseDto(level, index + 1)
|
||||
│ │ │ │
|
||||
│ │ │ └─> {
|
||||
│ │ │ level: 1 (or 2, 3, ...)
|
||||
│ │ │ id: level.id
|
||||
│ │ │ imageUrl: level.imageUrl
|
||||
│ │ │ answer: level.answer
|
||||
│ │ │ hint1: level.hint1
|
||||
│ │ │ hint2: level.hint2
|
||||
│ │ │ hint3: level.hint3
|
||||
│ │ │ sortOrder: level.sortOrder
|
||||
│ │ │ createdAt: level.createdAt
|
||||
│ │ │ updatedAt: level.updatedAt
|
||||
│ │ │ }
|
||||
│ │
|
||||
│ └─> return {
|
||||
│ levels: [LevelResponseDto[], ...]
|
||||
│ total: count
|
||||
│ }
|
||||
│
|
||||
└─> ApiResponseDto.success(data)
|
||||
│
|
||||
└─> {
|
||||
success: true
|
||||
data: { levels, total }
|
||||
message: null
|
||||
timestamp: new Date()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Error Handling Flow
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ HTTP Request │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────▼─────────────┐
|
||||
│ ValidationPipe │
|
||||
│ (Global) │
|
||||
└────┬─────────────┘
|
||||
│
|
||||
┌───┴────────────────────────┐
|
||||
│ │
|
||||
▼ Valid ▼ Invalid
|
||||
Continue ValidationException
|
||||
│
|
||||
[ExceptionFilter]
|
||||
catches @Catch()
|
||||
│
|
||||
ApiResponseDto.error()
|
||||
│
|
||||
{success: false,
|
||||
message: "...",
|
||||
path: "..."}
|
||||
│
|
||||
▼ Valid data
|
||||
[Controller]
|
||||
[Service]
|
||||
[Repository]
|
||||
[Database]
|
||||
│
|
||||
├─ Success ──┐
|
||||
│ │
|
||||
│ ▼
|
||||
│ [Service] returns data
|
||||
│ │
|
||||
│ [Controller]
|
||||
│ │
|
||||
│ ApiResponseDto.success(data)
|
||||
│ │
|
||||
│ Return to client
|
||||
│
|
||||
└─ Exception ──┐
|
||||
│
|
||||
▼
|
||||
NotFoundException
|
||||
BadRequestException
|
||||
(or any HttpException)
|
||||
│
|
||||
▼
|
||||
[HttpExceptionFilter]
|
||||
@Catch() catches exception
|
||||
│
|
||||
▼
|
||||
Extract status & message
|
||||
│
|
||||
▼
|
||||
ApiResponseDto.error(message)
|
||||
│
|
||||
▼
|
||||
response.status(code).json(errorDto)
|
||||
│
|
||||
▼
|
||||
Return to client with error
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Request Validation Pipeline
|
||||
|
||||
```
|
||||
HTTP Request
|
||||
│
|
||||
├─ Path params extracted
|
||||
│ @Param('id') id: string
|
||||
│ @Param('key') key: string
|
||||
│
|
||||
├─ Query params extracted
|
||||
│ @Query() dto: QueryDto
|
||||
│
|
||||
├─ Body params extracted (for POST/PUT)
|
||||
│ @Body() dto: CreateDto
|
||||
│
|
||||
├─ Global ValidationPipe processes:
|
||||
│ │
|
||||
│ ├─ whitelist: true
|
||||
│ │ └─ Remove unknown properties
|
||||
│ │
|
||||
│ ├─ forbidNonWhitelisted: true
|
||||
│ │ └─ Throw if unknown properties found
|
||||
│ │
|
||||
│ └─ transform: true
|
||||
│ └─ Transform strings to appropriate types
|
||||
│ (e.g., "123" → 123)
|
||||
│
|
||||
├─ class-validator decorators checked
|
||||
│ @IsString()
|
||||
│ @IsNumber()
|
||||
│ @IsEmail()
|
||||
│ etc.
|
||||
│
|
||||
├─ If validation fails
|
||||
│ └─> BadRequestException
|
||||
│ └─> ExceptionFilter catches
|
||||
│ └─> 400 status + error message
|
||||
│
|
||||
└─ If validation passes
|
||||
└─> Continue to controller
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Data Transformation Chain
|
||||
|
||||
```
|
||||
HTTP Request JSON
|
||||
│
|
||||
├─ Parse JSON
|
||||
├─ Extract into DTO object
|
||||
│ {
|
||||
│ "@Type(() => Number)" hint: "5" ──> 5 (number)
|
||||
│ "@Transform()" name: "JOHN" ──> "john" (lowercased)
|
||||
│ }
|
||||
│
|
||||
├─ Validate against DTO decorators
|
||||
│ @IsNotEmpty()
|
||||
│ @IsNumber()
|
||||
│ @Min(0)
|
||||
│ @Max(100)
|
||||
│
|
||||
├─ Pass to Service
|
||||
│
|
||||
├─ Service transforms to Entity
|
||||
│ DTO ──> Entity
|
||||
│ {id, name} {id, name, timestamp}
|
||||
│
|
||||
├─ Database operations
|
||||
│ Entity ──> SQL
|
||||
│ TypeORM handles serialization
|
||||
│
|
||||
├─ Result from Database
|
||||
│ Entity[] ──> Entity[]
|
||||
│
|
||||
├─ Service transforms Entity to ResponseDto
|
||||
│ Entity ──> ResponseDto
|
||||
│ Remove sensitive fields
|
||||
│ Add computed fields
|
||||
│
|
||||
├─ Wrap in ApiResponseDto
|
||||
│
|
||||
└─ Send as JSON Response
|
||||
{
|
||||
success: true,
|
||||
data: [...ResponseDtos...],
|
||||
message: null,
|
||||
timestamp: "..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Database Connection Lifecycle
|
||||
|
||||
```
|
||||
[Application Start]
|
||||
│
|
||||
▼
|
||||
[config.module.ts loads]
|
||||
• validateEnvironment()
|
||||
• Reads .env, .env.local, .env.production
|
||||
│
|
||||
▼
|
||||
[app.module.ts initializes]
|
||||
• TypeOrmModule.forRootAsync()
|
||||
• Uses ConfigService to get DB params
|
||||
│
|
||||
├─ DB_HOST: localhost
|
||||
├─ DB_PORT: 3306
|
||||
├─ DB_USERNAME: meme_user
|
||||
├─ DB_PASSWORD: (from env)
|
||||
├─ DB_DATABASE: meme_mind
|
||||
│
|
||||
▼
|
||||
[TypeORM connects to MySQL]
|
||||
mysql2 driver establishes connection
|
||||
│
|
||||
├─ If NODE_ENV === 'development'
|
||||
│ └─ synchronize: true
|
||||
│ └─ Auto-create/update tables
|
||||
│
|
||||
├─ If NODE_ENV === 'production'
|
||||
│ └─ synchronize: false
|
||||
│ └─ Use migrations instead
|
||||
│
|
||||
▼
|
||||
[Repositories instantiated]
|
||||
@InjectRepository(Level)
|
||||
@InjectRepository(GameConfig)
|
||||
│
|
||||
▼
|
||||
[Ready to accept requests]
|
||||
• findAllOrdered() ──> SELECT ...
|
||||
• findByKey() ──> SELECT ...
|
||||
│
|
||||
▼
|
||||
[Application shutdown]
|
||||
TypeORM closes connection
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Environment to Runtime Configuration
|
||||
|
||||
```
|
||||
.env.local / .env file
|
||||
│
|
||||
├─ NODE_ENV=development
|
||||
├─ PORT=3000
|
||||
├─ DB_HOST=localhost
|
||||
├─ DB_PORT=3306
|
||||
├─ DB_USERNAME=meme_user
|
||||
├─ DB_PASSWORD=secret
|
||||
├─ DB_DATABASE=meme_mind
|
||||
│
|
||||
▼
|
||||
env.validation.ts
|
||||
• plainToInstance(EnvironmentVariables, config)
|
||||
• validateSync()
|
||||
• Throws if validation fails
|
||||
│
|
||||
▼
|
||||
config.module.ts
|
||||
• ConfigModule.forRoot()
|
||||
• isGlobal: true ──> Available everywhere
|
||||
• validate: validateEnvironment
|
||||
│
|
||||
▼
|
||||
database.config.ts
|
||||
registerAs('database', () => ({
|
||||
type: 'mysql',
|
||||
host: configService.get('DB_HOST'),
|
||||
port: configService.get('DB_PORT'),
|
||||
username: configService.get('DB_USERNAME'),
|
||||
password: configService.get('DB_PASSWORD'),
|
||||
database: configService.get('DB_DATABASE'),
|
||||
...
|
||||
}))
|
||||
│
|
||||
▼
|
||||
app.module.ts
|
||||
TypeOrmModule.forRootAsync({
|
||||
useFactory: (configService) => ({
|
||||
...configService.get('database')
|
||||
})
|
||||
})
|
||||
│
|
||||
▼
|
||||
main.ts
|
||||
port = process.env.PORT ?? 3000
|
||||
app.listen(port)
|
||||
│
|
||||
▼
|
||||
Application Running
|
||||
• Connected to MySQL
|
||||
• Listening on port
|
||||
• Ready for requests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. API Response Mapping Example
|
||||
|
||||
### Request:
|
||||
```
|
||||
GET /api/v1/wechat-game/levels
|
||||
```
|
||||
|
||||
### Database Results:
|
||||
```sql
|
||||
SELECT * FROM levels ORDER BY sort_order ASC LIMIT 2;
|
||||
|
||||
Results:
|
||||
┌─────────────┬──────────────────────┬────────┬───────┬───────┬───────┬────────────┬─────────────┬─────────────┐
|
||||
│ id │ image_url │ answer │ hint1 │ hint2 │ hint3 │ sort_order │ created_at │ updated_at │
|
||||
├─────────────┼──────────────────────┼────────┼───────┼───────┼───────┼────────────┼─────────────┼─────────────┤
|
||||
│ level-001 │ http://...img1.jpg │ meme │ image │ funny │ null │ 0 │ 2026-04-01 │ 2026-04-05 │
|
||||
│ level-002 │ http://...img2.jpg │ code │ tech │ null │ null │ 1 │ 2026-04-02 │ 2026-04-05 │
|
||||
└─────────────┴──────────────────────┴────────┴───────┴───────┴───────┴────────────┴─────────────┴─────────────┘
|
||||
```
|
||||
|
||||
### Service Transformation:
|
||||
```javascript
|
||||
levels.map((level, index) => toLevelResponseDto(level, index + 1))
|
||||
|
||||
Result:
|
||||
[
|
||||
{
|
||||
level: 1, // Computed: index + 1
|
||||
id: "level-001",
|
||||
imageUrl: "http://...img1.jpg",
|
||||
answer: "meme",
|
||||
hint1: "image",
|
||||
hint2: "funny",
|
||||
hint3: null,
|
||||
sortOrder: 0,
|
||||
createdAt: "2026-04-01T...",
|
||||
updatedAt: "2026-04-05T..."
|
||||
},
|
||||
{
|
||||
level: 2,
|
||||
id: "level-002",
|
||||
imageUrl: "http://...img2.jpg",
|
||||
answer: "code",
|
||||
hint1: "tech",
|
||||
hint2: null,
|
||||
hint3: null,
|
||||
sortOrder: 1,
|
||||
createdAt: "2026-04-02T...",
|
||||
updatedAt: "2026-04-05T..."
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Final HTTP Response:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"levels": [
|
||||
{
|
||||
"level": 1,
|
||||
"id": "level-001",
|
||||
"imageUrl": "http://...img1.jpg",
|
||||
"answer": "meme",
|
||||
"hint1": "image",
|
||||
"hint2": "funny",
|
||||
"hint3": null,
|
||||
"sortOrder": 0,
|
||||
"createdAt": "2026-04-01T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-05T12:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"level": 2,
|
||||
"id": "level-002",
|
||||
"imageUrl": "http://...img2.jpg",
|
||||
"answer": "code",
|
||||
"hint1": "tech",
|
||||
"hint2": null,
|
||||
"hint3": null,
|
||||
"sortOrder": 1,
|
||||
"createdAt": "2026-04-02T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-05T12:00:00.000Z"
|
||||
}
|
||||
],
|
||||
"total": 2
|
||||
},
|
||||
"message": null,
|
||||
"timestamp": "2026-04-05T12:34:56.789Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary of Diagrams
|
||||
|
||||
1. **System Architecture**: High-level components (Client, Server, Database)
|
||||
2. **Request-Response Flow**: Detailed flow for GET and error scenarios
|
||||
3. **Module Dependency Graph**: How modules depend on each other
|
||||
4. **Data Model Relationships**: Database table structures
|
||||
5. **Service Method Call Chain**: Stack of calls from Controller to DB
|
||||
6. **Error Handling Flow**: Exception catching and wrapping
|
||||
7. **Request Validation Pipeline**: Validation process
|
||||
8. **Data Transformation Chain**: DTO → Entity → DB → Entity → ResponseDto
|
||||
9. **Database Connection Lifecycle**: Connection initialization
|
||||
10. **Environment to Runtime**: How .env becomes runtime config
|
||||
11. **API Response Mapping**: Real example of transformation
|
||||
|
||||
*Generated: 2026-04-05*
|
||||
266
CLAUDE.md
Normal file
266
CLAUDE.md
Normal file
@@ -0,0 +1,266 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**MemeMind-Server** is a NestJS backend for a WeChat mini-game called MemeMind. The server handles user authentication via WeChat login, manages game levels and progress, supports social sharing/challenges, and maintains user profiles. All API responses use a standardized format and are exposed under `/api` with Swagger docs at `/api/docs`.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: NestJS 11 with TypeScript 5.7
|
||||
- **Database**: MySQL (via TypeORM)
|
||||
- **Authentication**: JWT (7-day expiration)
|
||||
- **Package Manager**: pnpm (with pnpm-lock.yaml)
|
||||
- **Testing**: Jest (unit tests alongside source, e2e tests in `test/`)
|
||||
- **Code Quality**: ESLint + Prettier (single quotes, trailing commas)
|
||||
- **Deployment**: PM2 with rsync to remote server
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.ts # App bootstrap (CORS, validation, Swagger)
|
||||
├── app.module.ts # Root module importing all features
|
||||
├── config/
|
||||
│ ├── config.module.ts # Global config provider (env validation)
|
||||
│ ├── env.validation.ts # Environment variable schema validation
|
||||
│ └── database.config.ts # TypeORM configuration
|
||||
├── database/
|
||||
│ └── migrations/ # TypeORM migrations (not heavily used yet)
|
||||
├── common/
|
||||
│ ├── dto/api-response.dto.ts # Unified response wrapper (success/error)
|
||||
│ ├── filters/http-exception.filter.ts # Global error handling
|
||||
│ ├── guards/jwt-auth.guard.ts # JWT verification & payload extraction
|
||||
│ └── decorators/current-user.decorator.ts # Param decorator for @CurrentUser()
|
||||
└── modules/
|
||||
├── auth/ # WeChat login, user creation, JWT issuance
|
||||
├── user/ # User profile and stamina management
|
||||
├── level/ # Game level progression tracking
|
||||
├── share/ # Social challenge/share features
|
||||
├── game-config/ # Game configuration endpoints
|
||||
└── wechat-game/ # Shared game entities and repositories
|
||||
|
||||
docs/
|
||||
├── api/ # API documentation (Markdown, auto-synced with code)
|
||||
├── api/README.md # Index of all API modules
|
||||
└── superpowers/ # Legacy or undocumented features
|
||||
```
|
||||
|
||||
## Key Commands
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
pnpm install # Install dependencies
|
||||
pnpm run start:dev # Run with file watching (http://localhost:3000/api)
|
||||
pnpm run start:debug # Debug mode with Node inspector
|
||||
pnpm run build # Compile TypeScript to dist/
|
||||
pnpm run start:prod # Run compiled production build
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
```bash
|
||||
pnpm run lint # ESLint with auto-fix
|
||||
pnpm run format # Prettier format (src/ and test/)
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
pnpm run test # All unit tests (*.spec.ts in src/)
|
||||
pnpm run test:watch # Watch mode
|
||||
pnpm run test:cov # Coverage report (outputs to coverage/)
|
||||
pnpm run test:debug # Debug unit tests
|
||||
pnpm run test:e2e # E2E tests (test/*.e2e-spec.ts)
|
||||
```
|
||||
|
||||
### Deployment
|
||||
|
||||
```bash
|
||||
pnpm run deploy # Build, rsync to server, restart PM2 cluster
|
||||
```
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Module Structure
|
||||
|
||||
Each feature module follows NestJS conventions:
|
||||
|
||||
```typescript
|
||||
// module.ts: Declares imports (other modules, TypeORM entities, services)
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Entity]), AuthModule],
|
||||
controllers: [FeatureController],
|
||||
providers: [FeatureService, CustomRepository],
|
||||
exports: [FeatureService], // For cross-module injection
|
||||
})
|
||||
export class FeatureModule {}
|
||||
|
||||
// controller.ts: HTTP routing, DTO validation, Swagger decorators
|
||||
@Controller('v1/feature')
|
||||
export class FeatureController {
|
||||
@Post() async create(@Body() dto: CreateDto): Promise<ApiResponseDto<ResponseDto>>
|
||||
@UseGuards(JwtAuthGuard) // Applied per-endpoint
|
||||
@Get(':id') async getOne(@Param('id') id: string, @CurrentUser() user: JwtPayload)
|
||||
}
|
||||
|
||||
// service.ts: Business logic, repository calls, external API calls
|
||||
@Injectable()
|
||||
export class FeatureService {
|
||||
constructor(private readonly repo: CustomRepository) {}
|
||||
async create(data: CreateDto): Promise<ResponseDto> { ... }
|
||||
}
|
||||
|
||||
// repositories/: Custom data access logic (extend TypeORM repositories)
|
||||
@Injectable()
|
||||
export class CustomRepository extends Repository<Entity> {
|
||||
async customQuery(): Promise<Entity[]> { ... }
|
||||
}
|
||||
|
||||
// dto/: Request/response schemas with class-validator decorators
|
||||
export class CreateDto {
|
||||
@IsString() @MinLength(1) name: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- **No global guard**: JWT validation is per-endpoint via `@UseGuards(JwtAuthGuard)`
|
||||
- **User extraction**: Use `@CurrentUser()` to inject `JwtPayload { sub: userId, openid }`
|
||||
- **WeChat integration**: `AuthService.wxLogin(code)` calls WeChat API, creates user on first login, issues JWT
|
||||
|
||||
### Request/Response Contract
|
||||
|
||||
All endpoints return `ApiResponseDto<T>`:
|
||||
|
||||
```typescript
|
||||
{
|
||||
success: boolean, // true on success, false on error
|
||||
data: T | null, // Response payload or null
|
||||
message: string | null, // Error message (null on success)
|
||||
timestamp: Date // ISO 8601 timestamp
|
||||
}
|
||||
```
|
||||
|
||||
Errors are caught by `HttpExceptionFilter`, which formats exceptions as failed responses.
|
||||
|
||||
### Database
|
||||
|
||||
- **ORM**: TypeORM with MySQL
|
||||
- **Entities**: Auto-loaded from `**/*.entity.ts`, sync mode in dev
|
||||
- **Migrations**: Located in `src/database/migrations/` (minimal usage)
|
||||
- **Connection**: Configured via `ConfigService` (env vars: DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_DATABASE)
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables are validated via `env.validation.ts` using class-validator:
|
||||
|
||||
```
|
||||
NODE_ENV # development|production|test (default: development)
|
||||
PORT # Server port (default: 3000)
|
||||
DB_HOST # MySQL host (default: localhost)
|
||||
DB_PORT # MySQL port (default: 3306)
|
||||
DB_USERNAME # MySQL user (default: meme_user)
|
||||
DB_PASSWORD # MySQL password (default: '')
|
||||
DB_DATABASE # MySQL database (default: meme_mind)
|
||||
WX_APPID # WeChat mini-game app ID
|
||||
WX_SECRET # WeChat mini-game secret
|
||||
JWT_SECRET # JWT signing secret
|
||||
```
|
||||
|
||||
Config files loaded in order: `.env.local` → `.env` → `.env.production`. Use `.env.local` for local overrides (never committed).
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit tests**: Colocated with source as `*.spec.ts` (e.g., `auth.service.spec.ts`)
|
||||
- **E2E tests**: In `test/` directory (e.g., `test/app.e2e-spec.ts`)
|
||||
- **Framework**: Jest with ts-jest compiler
|
||||
- **Coverage**: Run `pnpm run test:cov` to generate coverage report in `coverage/`
|
||||
- **Individual tests**: `pnpm run test -- --testNamePattern="test name"` or `pnpm run test:debug`
|
||||
|
||||
## API Documentation
|
||||
|
||||
API docs are maintained in `docs/api/` and **must be updated whenever controller/DTO changes occur**. The `api-doc-maintainer` skill automates this when editing `src/modules/*/`.
|
||||
|
||||
**File mapping**:
|
||||
- `docs/api/auth-api.md` → `src/modules/auth/`
|
||||
- `docs/api/game-api.md` → `src/modules/wechat-game/` & `src/modules/level/`
|
||||
- `docs/api/share-challenge-api.md` → `src/modules/share/`
|
||||
|
||||
**When to update docs**:
|
||||
- New endpoint added
|
||||
- Request/response DTO fields changed
|
||||
- Error codes or business logic modified
|
||||
- Authentication requirements changed
|
||||
|
||||
Use the template in `AGENTS.md` (api-doc-maintainer skill) for consistent formatting.
|
||||
|
||||
## Deployment
|
||||
|
||||
**Local to production**:
|
||||
|
||||
1. Ensure `.env.production` has correct credentials (WX_APPID, WX_SECRET, JWT_SECRET, DB credentials)
|
||||
2. Run `pnpm run deploy` (triggers `deploy.sh`)
|
||||
3. Script: builds locally → rsyncs `dist/`, `package.json`, `pnpm-lock.yaml` to `/var/www/MemeMind-Server/` on server → installs deps → restarts PM2 cluster (2 instances)
|
||||
|
||||
**PM2 config** (`ecosystem.config.js`): Cluster mode (2 instances), auto-restart, 1GB memory limit, logs to `logs/*.log`
|
||||
|
||||
**Important**: Before deploying, verify deploy.sh credentials (SERVER_IP, SERVER_USER, REMOTE_DIR, APP_NAME) match your target environment.
|
||||
|
||||
## Code Style & Conventions
|
||||
|
||||
- **TypeScript**: Strict mode enabled, no implicit `any`, explicit types on public APIs
|
||||
- **Naming**: Classes PascalCase, methods/variables camelCase, directories kebab-case
|
||||
- **DTO files**: Semantic names (e.g., `wx-login.dto.ts`, `share-response.dto.ts`)
|
||||
- **Quotes**: Single quotes (enforced by Prettier)
|
||||
- **Indentation**: 2 spaces
|
||||
- **Commits**: Use Conventional Commits (e.g., `feat(auth):`, `fix(level):`, `docs(api):`)
|
||||
|
||||
## Key Integration Points
|
||||
|
||||
### Auth Module
|
||||
|
||||
- **Controller**: `POST /api/v1/auth/wx-login`
|
||||
- **Flow**: WeChat code → `AuthService.wxLogin()` → calls WeChat API (`jscode2session`) → creates/updates user → signs JWT
|
||||
- **Exports**: `AuthService`, `UserRepository`, `UserLevelProgressRepository` (used by other modules)
|
||||
|
||||
### User Module
|
||||
|
||||
- **Depends on**: AuthModule (for JWT guard, UserRepository)
|
||||
- **Usage**: Profile endpoints, stamina management
|
||||
|
||||
### Level Module
|
||||
|
||||
- **Depends on**: AuthModule, UserModule, WechatGameModule
|
||||
- **Entities**: Reused from WechatGameModule (Level, UserLevelProgress)
|
||||
|
||||
### Share Module
|
||||
|
||||
- **Entities**: ShareConfig, ShareParticipant, ShareLevelProgress (independent tables)
|
||||
- **Depends on**: WechatGameModule, AuthModule
|
||||
- **Pattern**: Multi-table repository pattern for complex queries
|
||||
|
||||
## Common Development Tasks
|
||||
|
||||
- **Add a new API endpoint**: Create DTO in `modules/{feature}/dto/`, add method to controller, implement in service, call `pnpm run lint && pnpm run test` to verify
|
||||
- **Add a new entity**: Create `*.entity.ts` in module folder, add to TypeOrmModule.forFeature in module, update relevant repository
|
||||
- **Modify API response format**: Update DTO/entity, regenerate Swagger docs, update `docs/api/*.md` manually if not auto-synced
|
||||
- **Debug a failing test**: Run `pnpm run test:debug -- --testNamePattern="specific test"` and use Node inspector
|
||||
- **Run migrations**: TypeORM in-dev synchronize mode auto-creates tables; for production, use TypeORM CLI or manual SQL
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Port 3000 in use**: `lsof -i :3000` and `kill -9 <PID>`, or change PORT env var
|
||||
- **Database connection fails**: Verify DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD in `.env.local` match your MySQL setup
|
||||
- **JWT verification fails**: Check JWT_SECRET is consistent across app instances and `.env` files
|
||||
- **Swagger not loading**: Ensure app.listen() completes; check browser console for CORS errors
|
||||
- **Tests hanging**: Check for open database connections; run `pnpm run test:debug` to inspect
|
||||
- **Prettier/ESLint conflicts**: Run `pnpm run format && pnpm run lint` in sequence (format first, then lint)
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- **NestJS docs**: https://docs.nestjs.com
|
||||
- **TypeORM docs**: https://typeorm.io
|
||||
- **Project-specific guidance**: See `AGENTS.md` for multi-agent collaboration patterns
|
||||
@@ -1,836 +0,0 @@
|
||||
# MemeMind Client-Server Integration Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This document explains how the Cocos Creator client communicates with the MemeMind-Server backend and what extensions would be needed to support the full game flow including user authentication, progress tracking, and point/life management.
|
||||
|
||||
---
|
||||
|
||||
## Part 1: Current Integration (Read-Only)
|
||||
|
||||
### Current API Call: Get All Levels
|
||||
|
||||
**Client File**: `/Users/richard/Documents/code/cocosProject/mp-xieyingeng/assets/scripts/managers/LevelDataManager.ts`
|
||||
|
||||
**Current Implementation**:
|
||||
```typescript
|
||||
async initialize(): Promise<void> {
|
||||
try {
|
||||
// Initialize() is called by PageLoading during startup
|
||||
const response = await HttpUtil.get<ApiResponse>(
|
||||
'https://ilookai.cn/api/v1/wechat-game/levels'
|
||||
);
|
||||
|
||||
if (response.success && response.data?.levels) {
|
||||
this._apiData = response.data.levels;
|
||||
this._levelDataCache.clear();
|
||||
|
||||
// Preload next level images asynchronously
|
||||
this.preloadNextLevel(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load level data', error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Server Endpoint**:
|
||||
```
|
||||
GET /api/v1/wechat-game/levels
|
||||
Status: 200
|
||||
Response: ApiResponseDto<LevelListResponseDto>
|
||||
```
|
||||
|
||||
**Response Format**:
|
||||
```typescript
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
levels: [
|
||||
{
|
||||
level: 1, // Level number (1-indexed)
|
||||
id: "level-001", // Unique ID
|
||||
imageUrl: "https://...", // Level image URL
|
||||
answer: "meme", // Correct answer
|
||||
hint1: "image", // First hint (free)
|
||||
hint2: "funny", // Second hint (costs 1 life)
|
||||
hint3: null, // Third hint (costs 1 life)
|
||||
sortOrder: 0, // Display order
|
||||
createdAt: "2026-04-01T...",
|
||||
updatedAt: "2026-04-05T..."
|
||||
},
|
||||
...
|
||||
],
|
||||
total: 50
|
||||
},
|
||||
message: null,
|
||||
timestamp: "2026-04-05T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
Client Startup
|
||||
│
|
||||
▼
|
||||
PageLoading.ts: _startPreload()
|
||||
│
|
||||
├─ LevelDataManager.initialize()
|
||||
│ │
|
||||
│ └─> HttpUtil.get('/api/v1/wechat-game/levels')
|
||||
│ │
|
||||
│ ▼
|
||||
│ MemeMind-Server
|
||||
│ │
|
||||
│ ├─> WechatGameController.getAllLevels()
|
||||
│ ├─> WechatGameService.getAllLevels()
|
||||
│ ├─> LevelRepository.findAllOrdered()
|
||||
│ └─> MySQL: SELECT * FROM levels ORDER BY sort_order
|
||||
│
|
||||
│ Response returned
|
||||
│ │
|
||||
│ ▼
|
||||
│ LevelDataManager._apiData = levels
|
||||
│ Preload images
|
||||
│
|
||||
├─ Progress: 80% -> 100%
|
||||
│
|
||||
▼
|
||||
PageHome displayed
|
||||
│
|
||||
▼
|
||||
User clicks "Start Game"
|
||||
│
|
||||
▼
|
||||
PageLevel loaded
|
||||
│
|
||||
├─> Reads from _apiData
|
||||
├─> Displays level image, hints, input
|
||||
└─> Ready for gameplay
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Missing Features for Full Integration
|
||||
|
||||
### 1. User Authentication
|
||||
|
||||
**What's needed**:
|
||||
- WeChat OpenID extraction via `wx.login()`
|
||||
- Backend user registration/login endpoint
|
||||
- JWT token generation and validation
|
||||
- User context in requests
|
||||
|
||||
**Implementation Plan**:
|
||||
|
||||
**Server Addition**:
|
||||
```typescript
|
||||
// src/modules/users/users.module.ts
|
||||
@Module({
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService, UsersRepository],
|
||||
imports: [TypeOrmModule.forFeature([User])],
|
||||
})
|
||||
export class UsersModule {}
|
||||
|
||||
// src/modules/users/entities/user.entity.ts
|
||||
@Entity('users')
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ unique: true })
|
||||
wxOpenId: string; // WeChat OpenID
|
||||
|
||||
@Column()
|
||||
nickname: string;
|
||||
|
||||
@Column({ default: 10 })
|
||||
currentLives: number;
|
||||
|
||||
@Column({ default: 0 })
|
||||
currentLevelIndex: number;
|
||||
|
||||
@Column({ default: 0 })
|
||||
totalPoints: number;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// Endpoint: POST /api/v1/users/login
|
||||
@Post('login')
|
||||
async login(@Body() dto: LoginDto): Promise<ApiResponseDto<LoginResponseDto>> {
|
||||
// dto.code from wx.login()
|
||||
// Exchange code for wxOpenId via WeChat API
|
||||
// Create or fetch user
|
||||
// Generate JWT token
|
||||
// Return user + token
|
||||
}
|
||||
```
|
||||
|
||||
**Client Addition**:
|
||||
```typescript
|
||||
// In PageLoading.ts or main.ts
|
||||
async function initializeUser() {
|
||||
try {
|
||||
const code = await wx.login();
|
||||
const response = await HttpUtil.post('/api/v1/users/login', {
|
||||
code: code.code
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
const { user, token } = response.data;
|
||||
StorageManager.setToken(token); // New: Store JWT
|
||||
StorageManager.setUserId(user.id); // New: Store user ID
|
||||
return user;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login failed', error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Level Submission & Answer Validation
|
||||
|
||||
**What's needed**:
|
||||
- POST endpoint for level submissions
|
||||
- Answer validation (case-insensitive, trim whitespace)
|
||||
- Update user progress
|
||||
- Award points/lives
|
||||
- Handle wrong answers
|
||||
|
||||
**Implementation Plan**:
|
||||
|
||||
**Server Addition**:
|
||||
```typescript
|
||||
// src/modules/levels/level-submission.module.ts
|
||||
@Module({
|
||||
controllers: [LevelSubmissionController],
|
||||
providers: [LevelSubmissionService],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([UserProgress, Level]),
|
||||
UsersModule,
|
||||
WechatGameModule,
|
||||
],
|
||||
})
|
||||
export class LevelSubmissionModule {}
|
||||
|
||||
// Endpoint: POST /api/v1/levels/:levelId/submit
|
||||
@Post(':levelId/submit')
|
||||
@UseGuards(JwtAuthGuard) // Require authentication
|
||||
async submitAnswer(
|
||||
@Param('levelId') levelId: string,
|
||||
@Body() dto: SubmitAnswerDto,
|
||||
@Req() request: any,
|
||||
): Promise<ApiResponseDto<SubmitAnswerResponseDto>> {
|
||||
// request.user.id from JWT
|
||||
// Validate answer (case-insensitive, trim)
|
||||
// If correct:
|
||||
// - Award 1 life
|
||||
// - Update currentLevelIndex
|
||||
// - Record submission
|
||||
// If wrong:
|
||||
// - Record wrong attempt
|
||||
// - Maybe deduct lives?
|
||||
// Return result
|
||||
}
|
||||
|
||||
// src/modules/levels/dto/submit-answer.dto.ts
|
||||
export class SubmitAnswerDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
answer: string;
|
||||
|
||||
@IsNumber()
|
||||
timeTaken: number; // Seconds to solve
|
||||
|
||||
@IsNumber()
|
||||
hintsUsed: number; // How many hints revealed
|
||||
}
|
||||
|
||||
export class SubmitAnswerResponseDto {
|
||||
success: boolean; // Correct answer?
|
||||
message: string; // "Correct!" or "Wrong!"
|
||||
newLives: number; // Updated lives
|
||||
newLevel: number; // Next level index
|
||||
pointsEarned: number; // Points for this solve
|
||||
totalPoints: number; // Total accumulated points
|
||||
}
|
||||
```
|
||||
|
||||
**Client Change**:
|
||||
```typescript
|
||||
// In PageLevel.ts: showSuccess() method
|
||||
private async showSuccess(): void {
|
||||
this.stopCountdown();
|
||||
this.playSuccessSound();
|
||||
|
||||
// NEW: Submit to server
|
||||
try {
|
||||
const token = StorageManager.getToken();
|
||||
const response = await HttpUtil.post(
|
||||
`/api/v1/levels/${this._currentLevel.id}/submit`,
|
||||
{
|
||||
answer: this._userAnswer,
|
||||
timeTaken: this._elapsedTime,
|
||||
hintsUsed: this._hintsUsed
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
// Update local storage with new progress
|
||||
StorageManager.setLives(response.data.newLives);
|
||||
StorageManager.onLevelCompleted(response.data.newLevel - 1);
|
||||
|
||||
// Show points earned
|
||||
this.showPointsNotification(response.data.pointsEarned);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Submission failed', error);
|
||||
}
|
||||
|
||||
this._showPassModal();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. User Progress Tracking
|
||||
|
||||
**What's needed**:
|
||||
- Endpoint to get user progress
|
||||
- Endpoint to update progress
|
||||
- Sync between client localStorage and server
|
||||
- Handle offline mode
|
||||
|
||||
**Implementation Plan**:
|
||||
|
||||
**Server Addition**:
|
||||
```typescript
|
||||
// Endpoint: GET /api/v1/users/me/progress
|
||||
@Get('me/progress')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async getProgress(
|
||||
@Req() request: any,
|
||||
): Promise<ApiResponseDto<UserProgressDto>> {
|
||||
// request.user.id from JWT
|
||||
// Return user progress
|
||||
}
|
||||
|
||||
export class UserProgressDto {
|
||||
id: string;
|
||||
userId: string;
|
||||
currentLevelIndex: number;
|
||||
maxLevelUnlocked: number;
|
||||
totalPoints: number;
|
||||
currentLives: number;
|
||||
completedLevels: {
|
||||
levelId: string;
|
||||
completedAt: Date;
|
||||
timeTaken: number;
|
||||
hintsUsed: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
// Endpoint: POST /api/v1/users/me/progress/sync
|
||||
@Post('me/progress/sync')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async syncProgress(
|
||||
@Req() request: any,
|
||||
@Body() dto: SyncProgressDto,
|
||||
): Promise<ApiResponseDto<SyncProgressResponseDto>> {
|
||||
// Merge client progress with server
|
||||
// Handle conflicts (prefer latest)
|
||||
// Return merged progress
|
||||
}
|
||||
```
|
||||
|
||||
**Client Update**:
|
||||
```typescript
|
||||
// In StorageManager.ts: Add sync methods
|
||||
static async syncWithServer(): Promise<void> {
|
||||
try {
|
||||
const token = this.getToken();
|
||||
const localProgress = this.getCurrentLevelIndex();
|
||||
const localLives = this.getLives();
|
||||
|
||||
const response = await HttpUtil.post(
|
||||
'/api/v1/users/me/progress/sync',
|
||||
{
|
||||
currentLevelIndex: localProgress,
|
||||
currentLives: localLives,
|
||||
},
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
// Update local with server's merged data
|
||||
this.setLives(response.data.currentLives);
|
||||
// Update progress for each completed level
|
||||
response.data.completedLevels.forEach((level) => {
|
||||
this.onLevelCompleted(level.levelIndex);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Sync failed', error);
|
||||
// Continue with local data
|
||||
}
|
||||
}
|
||||
|
||||
// Call on app startup and periodically
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Hint Usage & Cost Management
|
||||
|
||||
**What's needed**:
|
||||
- Track which hints have been used
|
||||
- Deduct lives when using premium hints
|
||||
- Prevent excessive hint usage
|
||||
|
||||
**Implementation Plan**:
|
||||
|
||||
**Server Addition**:
|
||||
```typescript
|
||||
// Track hint usage per level per user
|
||||
@Entity('level_hints')
|
||||
export class LevelHint {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
user: User;
|
||||
|
||||
@Column()
|
||||
levelId: string;
|
||||
|
||||
@Column()
|
||||
hint1Revealed: boolean; // Always free
|
||||
|
||||
@Column()
|
||||
hint2Revealed: boolean; // Costs 1 life
|
||||
|
||||
@Column()
|
||||
hint3Revealed: boolean; // Costs 1 life
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// Endpoint: POST /api/v1/levels/:levelId/reveal-hint
|
||||
@Post(':levelId/reveal-hint')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async revealHint(
|
||||
@Param('levelId') levelId: string,
|
||||
@Body() dto: RevealHintDto, // { hintNumber: 2 }
|
||||
@Req() request: any,
|
||||
): Promise<ApiResponseDto<RevealHintResponseDto>> {
|
||||
// Validate hint number
|
||||
// Check if already revealed
|
||||
// If hint 2 or 3: deduct 1 life
|
||||
// Update hint tracking
|
||||
// Return hint text
|
||||
}
|
||||
```
|
||||
|
||||
**Client Change**:
|
||||
```typescript
|
||||
// In PageLevel.ts: onUnlockClue() method
|
||||
private async onUnlockClue(clueIndex: number): void {
|
||||
const token = StorageManager.getToken();
|
||||
|
||||
// Hint index 1 is always free (hint1)
|
||||
// Hints 2 and 3 cost 1 life each
|
||||
if (clueIndex > 0 && !this._freeCluePassed) {
|
||||
const currentLives = StorageManager.getLives();
|
||||
if (currentLives <= 0) {
|
||||
this._showToast('没有生命了!(No lives left!)');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await HttpUtil.post(
|
||||
`/api/v1/levels/${this._currentLevel.id}/reveal-hint`,
|
||||
{ hintNumber: clueIndex + 1 },
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
const hint = response.data.hintText;
|
||||
this._showHint(hint);
|
||||
|
||||
// Deduct life if premium hint
|
||||
if (clueIndex > 0) {
|
||||
StorageManager.consumeLife();
|
||||
StorageManager.setLives(response.data.remainingLives);
|
||||
this._updateLivesDisplay();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to unlock hint', error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Leaderboard & Statistics
|
||||
|
||||
**What's needed**:
|
||||
- Track total points per user
|
||||
- Track completion time
|
||||
- Leaderboard endpoint
|
||||
- User statistics endpoint
|
||||
|
||||
**Implementation Plan**:
|
||||
|
||||
**Server Addition**:
|
||||
```typescript
|
||||
// Endpoint: GET /api/v1/leaderboard?limit=100
|
||||
@Get('leaderboard')
|
||||
async getLeaderboard(
|
||||
@Query('limit') limit: number = 100,
|
||||
): Promise<ApiResponseDto<LeaderboardDto>> {
|
||||
// Select top users by totalPoints
|
||||
// Include rank, nickname, points, completedLevels
|
||||
}
|
||||
|
||||
// Endpoint: GET /api/v1/users/me/statistics
|
||||
@Get('me/statistics')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async getStatistics(
|
||||
@Req() request: any,
|
||||
): Promise<ApiResponseDto<StatisticsDto>> {
|
||||
// Return user statistics
|
||||
// Total levels completed
|
||||
// Average time per level
|
||||
// Total hints used
|
||||
// Current streak, etc.
|
||||
}
|
||||
|
||||
export class StatisticsDto {
|
||||
totalLevelsCompleted: number;
|
||||
currentLevelIndex: number;
|
||||
totalPoints: number;
|
||||
currentLives: number;
|
||||
averageTimePerLevel: number; // Seconds
|
||||
totalTimeSpent: number; // Seconds
|
||||
totalHintsUsed: number;
|
||||
perfectSolves: number; // Solved in first try
|
||||
longestStreak: number;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 3: API Authentication Pattern
|
||||
|
||||
### JWT Guard Implementation
|
||||
|
||||
**Server**:
|
||||
```typescript
|
||||
// src/modules/auth/guards/jwt.guard.ts
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('No token provided');
|
||||
}
|
||||
|
||||
try {
|
||||
const secret = this.configService.get('JWT_SECRET');
|
||||
const decoded = verify(token, secret);
|
||||
request.user = decoded; // Attach to request
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
private extractTokenFromHeader(request): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage on endpoints
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('me/progress')
|
||||
async getProgress(@Req() request: any) {
|
||||
const userId = request.user.id; // From JWT payload
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Client**:
|
||||
```typescript
|
||||
// In HttpUtil.ts: Add auth header
|
||||
static async post<T>(
|
||||
url: string,
|
||||
data: any,
|
||||
options?: RequestOptions
|
||||
): Promise<ApiResponse<T>> {
|
||||
const token = StorageManager.getToken();
|
||||
|
||||
const headers = {
|
||||
...options?.headers,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Make request with headers
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 4: Data Sync Strategy
|
||||
|
||||
### Scenario 1: Online Mode
|
||||
|
||||
```
|
||||
User completes level
|
||||
│
|
||||
▼
|
||||
[Client] Submits answer
|
||||
│
|
||||
├─ HTTP POST /api/v1/levels/:id/submit
|
||||
│
|
||||
▼
|
||||
[Server] Validates and updates
|
||||
│
|
||||
├─ Check answer
|
||||
├─ Award life/points
|
||||
├─ Update progress
|
||||
├─ Store submission
|
||||
│
|
||||
▼
|
||||
[Response] Returned to client
|
||||
│
|
||||
├─ Update localStorage
|
||||
├─ Show success modal
|
||||
├─ Move to next level
|
||||
│
|
||||
▼
|
||||
Progress synced
|
||||
```
|
||||
|
||||
### Scenario 2: Offline Mode
|
||||
|
||||
```
|
||||
User completes level (no connection)
|
||||
│
|
||||
├─ Submit fails (no network)
|
||||
│
|
||||
▼
|
||||
[Client] Stores locally
|
||||
│
|
||||
├─ StorageManager.recordOfflineSubmission()
|
||||
├─ Update lives/progress locally
|
||||
├─ Show success modal (assume correct)
|
||||
│
|
||||
▼
|
||||
When connection returns
|
||||
│
|
||||
├─ StorageManager.syncPendingSubmissions()
|
||||
│
|
||||
▼
|
||||
[Server] Receives batch of submissions
|
||||
│
|
||||
├─ Validate all answers
|
||||
├─ Apply corrections if needed
|
||||
├─ Return merged state
|
||||
│
|
||||
▼
|
||||
[Client] Reconciles state
|
||||
│
|
||||
├─ If conflicts: server wins
|
||||
├─ Update localStorage
|
||||
├─ Show notification of changes
|
||||
│
|
||||
▼
|
||||
Progress synced
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 5: Implementation Roadmap
|
||||
|
||||
### Phase 1: Basic Auth (Week 1)
|
||||
- [ ] User entity & table
|
||||
- [ ] Login endpoint with wx.login() code exchange
|
||||
- [ ] JWT token generation
|
||||
- [ ] JwtAuthGuard for protected routes
|
||||
- [ ] Client login integration
|
||||
- [ ] Token storage in StorageManager
|
||||
|
||||
### Phase 2: Progress Tracking (Week 2)
|
||||
- [ ] UserProgress entity & table
|
||||
- [ ] GET /api/v1/users/me/progress
|
||||
- [ ] POST /api/v1/levels/:id/submit
|
||||
- [ ] Update client PageLevel.ts to submit answers
|
||||
- [ ] Sync endpoint for merging progress
|
||||
- [ ] Offline submission queue
|
||||
|
||||
### Phase 3: Hint System Integration (Week 3)
|
||||
- [ ] LevelHint entity & tracking
|
||||
- [ ] POST /api/v1/levels/:id/reveal-hint
|
||||
- [ ] Life deduction logic
|
||||
- [ ] Client hint unlock cost
|
||||
|
||||
### Phase 4: Leaderboard & Stats (Week 4)
|
||||
- [ ] Statistics calculation
|
||||
- [ ] Leaderboard endpoint
|
||||
- [ ] Client leaderboard page
|
||||
- [ ] Personal statistics page
|
||||
|
||||
### Phase 5: Polish & Optimization (Week 5)
|
||||
- [ ] Caching layer (@nestjs/cache-manager)
|
||||
- [ ] Rate limiting (@nestjs/throttler)
|
||||
- [ ] Request logging middleware
|
||||
- [ ] Performance monitoring
|
||||
- [ ] Database indexing optimization
|
||||
|
||||
---
|
||||
|
||||
## Part 6: Environment Configuration
|
||||
|
||||
### Server .env.production
|
||||
|
||||
```bash
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
|
||||
# Database
|
||||
DB_HOST=production-db-host
|
||||
DB_PORT=3306
|
||||
DB_USERNAME=prod_user
|
||||
DB_PASSWORD=secure_password
|
||||
DB_DATABASE=meme_mind_prod
|
||||
|
||||
# Authentication
|
||||
JWT_SECRET=very-secure-secret-key-32-chars-min
|
||||
JWT_EXPIRATION=7d
|
||||
|
||||
# WeChat
|
||||
WECHAT_APPID=your_wechat_appid
|
||||
WECHAT_SECRET=your_wechat_secret
|
||||
|
||||
# API
|
||||
API_BASE_URL=https://ilookai.cn/api
|
||||
CORS_ORIGIN=https://yourdomain.com
|
||||
```
|
||||
|
||||
### Client Storage Keys
|
||||
|
||||
```typescript
|
||||
// New keys needed:
|
||||
- 'auth_token' → JWT token
|
||||
- 'user_id' → Current user ID
|
||||
- 'offline_submissions' → Queue of submissions to send
|
||||
- 'last_sync' → Timestamp of last sync
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 7: Error Handling
|
||||
|
||||
### Common API Errors
|
||||
|
||||
```typescript
|
||||
// 401 Unauthorized
|
||||
{
|
||||
success: false,
|
||||
data: null,
|
||||
message: 'Invalid token',
|
||||
path: '/api/v1/users/me/progress'
|
||||
}
|
||||
|
||||
// 404 Not Found
|
||||
{
|
||||
success: false,
|
||||
data: null,
|
||||
message: 'Level with id "xyz" not found',
|
||||
path: '/api/v1/levels/xyz'
|
||||
}
|
||||
|
||||
// 400 Bad Request (validation)
|
||||
{
|
||||
success: false,
|
||||
data: null,
|
||||
message: 'Validation failed: answer must be a string',
|
||||
path: '/api/v1/levels/123/submit'
|
||||
}
|
||||
|
||||
// 500 Server Error
|
||||
{
|
||||
success: false,
|
||||
data: null,
|
||||
message: 'Internal server error',
|
||||
path: '/api/v1/levels'
|
||||
}
|
||||
```
|
||||
|
||||
### Client Handling
|
||||
|
||||
```typescript
|
||||
async function handleApiError(error: unknown) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
const message = error.response?.data?.message;
|
||||
|
||||
if (status === 401) {
|
||||
// Token expired - redirect to login
|
||||
StorageManager.clearToken();
|
||||
navigateTo('PageHome');
|
||||
} else if (status === 404) {
|
||||
showToast(`Not found: ${message}`);
|
||||
} else if (status === 400) {
|
||||
showToast(`Invalid input: ${message}`);
|
||||
} else {
|
||||
showToast(`Error: ${message || 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The current integration only handles reading level data. To support the full game loop with:
|
||||
- ✅ User authentication (JWT)
|
||||
- ✅ Answer submission & validation
|
||||
- ✅ Progress tracking & sync
|
||||
- ✅ Hint system with cost management
|
||||
- ✅ Leaderboard & statistics
|
||||
|
||||
You need to implement the endpoints, models, and client logic described in this guide. The roadmap suggests a 5-week implementation with phases for each feature.
|
||||
|
||||
---
|
||||
|
||||
*Generated: 2026-04-05 | For MemeMind Project*
|
||||
1191
SERVER_ANALYSIS.md
1191
SERVER_ANALYSIS.md
File diff suppressed because it is too large
Load Diff
291
docs/api-changelog-v1.1.0.md
Normal file
291
docs/api-changelog-v1.1.0.md
Normal file
@@ -0,0 +1,291 @@
|
||||
# MemeMind API 变更文档 — 双图关卡 & 体力上限调整
|
||||
|
||||
> **版本**:v1.1.0
|
||||
> **日期**:2026-04-19
|
||||
> **影响范围**:关卡列表、进入关卡、分享挑战、体力系统
|
||||
> **兼容性**:⚠️ Breaking Change — 客户端必须适配后方可上线
|
||||
|
||||
---
|
||||
|
||||
## 一、变更概览
|
||||
|
||||
| 变更项 | 旧值 | 新值 |
|
||||
|--------|------|------|
|
||||
| 关卡图片数量 | 1 张(`imageUrl`) | 2 张(`image1Url` + `image2Url`) |
|
||||
| 图片文本说明 | 无 | 每张图片各有一个 `description` 字段 |
|
||||
| 谐音梗说明 | 无 | 新增 `punchline` 字段 |
|
||||
| 体力上限 | 5 | **50** |
|
||||
| 新用户默认体力 | 5 | **50** |
|
||||
| 体力恢复速率 | 每 10 分钟 1 点 | **不变** |
|
||||
|
||||
---
|
||||
|
||||
## 二、体力系统变更
|
||||
|
||||
### StaminaInfo 结构(不变,数值范围扩大)
|
||||
|
||||
```typescript
|
||||
interface StaminaInfo {
|
||||
current: number; // 0 ~ 50(原 0 ~ 5)
|
||||
max: number; // 固定 50(原 5)
|
||||
nextRecoverAt: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
**客户端注意**:
|
||||
- 体力 UI 需要适配 0–50 的显示范围
|
||||
- 恢复速率不变,满体力恢复时间从 50 分钟变为 500 分钟(约 8.3 小时)
|
||||
- 进入关卡仍消耗 1 点体力,已通关关卡仍免费
|
||||
|
||||
---
|
||||
|
||||
## 三、接口字段变更
|
||||
|
||||
### 3.1 `GET /api/v1/levels` — 获取关卡列表
|
||||
|
||||
**删除字段**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| ~~`imageUrl`~~ | ~~string~~ | 已删除,替换为下方双图字段 |
|
||||
|
||||
**新增字段**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `image1Url` | string | 图片1 URL |
|
||||
| `image1Description` | string \| null | 图片1 文本说明 |
|
||||
| `image2Url` | string | 图片2 URL |
|
||||
| `image2Description` | string \| null | 图片2 文本说明 |
|
||||
| `punchline` | string \| null | 谐音梗说明(**仅通关后返回**,未通关为 null) |
|
||||
|
||||
**完整响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"levels": [
|
||||
{
|
||||
"id": "level_001",
|
||||
"level": 1,
|
||||
"image1Url": "https://cdn.example.com/levels/001_1.png",
|
||||
"image1Description": "一只猫在看鱼",
|
||||
"image2Url": "https://cdn.example.com/levels/001_2.png",
|
||||
"image2Description": "一条鱼在飞",
|
||||
"answer": "猫和鱼",
|
||||
"punchline": "谐音梗:鱼跃龙门 → 鱼越猫门",
|
||||
"hint1": "这是一个经典的...",
|
||||
"hint2": "和某个动物有关",
|
||||
"hint3": null,
|
||||
"completed": true,
|
||||
"timeSpent": 45
|
||||
},
|
||||
{
|
||||
"id": "level_002",
|
||||
"level": 2,
|
||||
"image1Url": "https://cdn.example.com/levels/002_1.png",
|
||||
"image1Description": "一个人在走路",
|
||||
"image2Url": "https://cdn.example.com/levels/002_2.png",
|
||||
"image2Description": "一辆车在跑",
|
||||
"answer": null,
|
||||
"punchline": null,
|
||||
"hint1": null,
|
||||
"hint2": null,
|
||||
"hint3": null,
|
||||
"completed": false,
|
||||
"timeSpent": null
|
||||
}
|
||||
],
|
||||
"total": 2
|
||||
},
|
||||
"message": null,
|
||||
"timestamp": "2026-04-19T12:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
> **可见性规则**:`answer`、`punchline`、`hint1`、`hint2`、`hint3` 仅在 `completed: true` 时返回,未通关均为 `null`。`image1Url`、`image1Description`、`image2Url`、`image2Description` 始终返回。
|
||||
|
||||
---
|
||||
|
||||
### 3.2 `POST /api/v1/levels/{id}/enter` — 进入关卡
|
||||
|
||||
**删除字段**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| ~~`imageUrl`~~ | ~~string~~ | 已删除 |
|
||||
|
||||
**新增字段**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `image1Url` | string | 图片1 URL |
|
||||
| `image1Description` | string \| null | 图片1 文本说明 |
|
||||
| `image2Url` | string | 图片2 URL |
|
||||
| `image2Description` | string \| null | 图片2 文本说明 |
|
||||
| `punchline` | string \| null | 谐音梗说明 |
|
||||
|
||||
**完整响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "level_002",
|
||||
"level": 2,
|
||||
"image1Url": "https://cdn.example.com/levels/002_1.png",
|
||||
"image1Description": "一个人在走路",
|
||||
"image2Url": "https://cdn.example.com/levels/002_2.png",
|
||||
"image2Description": "一辆车在跑",
|
||||
"answer": "人车赛跑",
|
||||
"punchline": "谐音梗:车水马龙 → 车水人龙",
|
||||
"hint1": "第一个线索",
|
||||
"hint2": "第二个线索",
|
||||
"hint3": null,
|
||||
"stamina": {
|
||||
"current": 47,
|
||||
"max": 50,
|
||||
"nextRecoverAt": "2026-04-19T12:10:00.000Z"
|
||||
}
|
||||
},
|
||||
"message": null,
|
||||
"timestamp": "2026-04-19T12:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:进入关卡时 `answer` 和 `punchline` 始终返回(无论是否通关),因为用户已消耗体力进入。
|
||||
|
||||
---
|
||||
|
||||
### 3.3 `POST /api/v1/share/{shareCode}/join` — 加入分享挑战
|
||||
|
||||
分享挑战中的关卡数据同步变更。
|
||||
|
||||
**删除字段**(`levels[]` 中每个关卡):
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| ~~`imageUrl`~~ | ~~string~~ | 已删除 |
|
||||
|
||||
**新增字段**(`levels[]` 中每个关卡):
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `image1Url` | string | 图片1 URL |
|
||||
| `image1Description` | string \| null | 图片1 文本说明 |
|
||||
| `image2Url` | string | 图片2 URL |
|
||||
| `image2Description` | string \| null | 图片2 文本说明 |
|
||||
| `punchline` | string \| null | 谐音梗说明 |
|
||||
|
||||
---
|
||||
|
||||
### 3.4 未变更的接口
|
||||
|
||||
以下接口**无任何变更**,客户端无需修改:
|
||||
|
||||
| 接口 | 说明 |
|
||||
|------|------|
|
||||
| `POST /api/v1/auth/wx-login` | 登录(新用户 stamina 初始为 50,但 login 返回结构不变) |
|
||||
| `GET /api/v1/user/profile` | 用户资料(stamina.max 变为 50,结构不变) |
|
||||
| `GET /api/v1/user/game-data` | 游戏数据(stamina.max 变为 50,结构不变) |
|
||||
| `POST /api/v1/levels/{id}/complete` | 通关上报(结构完全不变) |
|
||||
| `GET /api/v1/game-configs` | 游戏配置(不变) |
|
||||
| `GET /api/v1/game-configs/{key}` | 单个配置(不变) |
|
||||
|
||||
---
|
||||
|
||||
## 四、客户端适配清单
|
||||
|
||||
### 必须修改
|
||||
|
||||
- [ ] 所有使用 `imageUrl` 的地方改为 `image1Url` + `image2Url`
|
||||
- [ ] 关卡详情页展示两张图片,每张图片下方展示 `image1Description` / `image2Description`
|
||||
- [ ] 通关后展示 `punchline`(谐音梗说明)
|
||||
- [ ] 体力 UI 适配 0–50 范围(进度条、数字显示等)
|
||||
- [ ] 更新 TypeScript 接口定义(见下方)
|
||||
|
||||
### 建议修改
|
||||
|
||||
- [ ] 体力恢复倒计时逻辑无需修改(恢复速率不变)
|
||||
- [ ] `punchline` 为 `null` 时不展示(未配置谐音梗的关卡)
|
||||
- [ ] `image2Url` 为空字符串时做兜底处理(历史关卡可能尚未配置第二张图)
|
||||
|
||||
---
|
||||
|
||||
## 五、客户端 TypeScript 接口定义
|
||||
|
||||
直接复制替换旧接口:
|
||||
|
||||
```typescript
|
||||
/** 关卡列表项 */
|
||||
interface LevelListItem {
|
||||
id: string;
|
||||
level: number;
|
||||
image1Url: string;
|
||||
image1Description: string | null;
|
||||
image2Url: string;
|
||||
image2Description: string | null;
|
||||
answer: string | null;
|
||||
punchline: string | null;
|
||||
hint1: string | null;
|
||||
hint2: string | null;
|
||||
hint3: string | null;
|
||||
completed: boolean;
|
||||
timeSpent: number | null;
|
||||
}
|
||||
|
||||
/** 进入关卡响应 */
|
||||
interface EnterLevelResponse {
|
||||
id: string;
|
||||
level: number;
|
||||
image1Url: string;
|
||||
image1Description: string | null;
|
||||
image2Url: string;
|
||||
image2Description: string | null;
|
||||
answer: string;
|
||||
punchline: string | null;
|
||||
hint1: string | null;
|
||||
hint2: string | null;
|
||||
hint3: string | null;
|
||||
stamina: StaminaInfo;
|
||||
}
|
||||
|
||||
/** 体力信息(结构不变,数值范围 0-50) */
|
||||
interface StaminaInfo {
|
||||
current: number;
|
||||
max: number; // 50
|
||||
nextRecoverAt: string | null;
|
||||
}
|
||||
|
||||
/** 分享关卡 */
|
||||
interface ShareLevel {
|
||||
id: string;
|
||||
level: number;
|
||||
image1Url: string;
|
||||
image1Description: string | null;
|
||||
image2Url: string;
|
||||
image2Description: string | null;
|
||||
answer: string;
|
||||
punchline: string | null;
|
||||
hint1: string | null;
|
||||
hint2: string | null;
|
||||
hint3: string | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、字段映射速查表
|
||||
|
||||
方便全局搜索替换:
|
||||
|
||||
| 旧字段 | 新字段 | 备注 |
|
||||
|--------|--------|------|
|
||||
| `imageUrl` | `image1Url` | 原图片字段,直接重命名 |
|
||||
| — | `image1Description` | 新增,图片1 说明文字 |
|
||||
| — | `image2Url` | 新增,第二张图片 |
|
||||
| — | `image2Description` | 新增,图片2 说明文字 |
|
||||
| — | `punchline` | 新增,谐音梗说明 |
|
||||
| `stamina.max = 5` | `stamina.max = 50` | 数值变更 |
|
||||
52
docs/api/README.md
Normal file
52
docs/api/README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# MemeMind API 文档索引
|
||||
|
||||
> 本目录包含 MemeMind 微信小游戏所有 API 接口文档。
|
||||
|
||||
## 文档列表
|
||||
|
||||
| 模块 | 文档文件 | 说明 | 状态 |
|
||||
|------|----------|------|------|
|
||||
| 核心玩法 | [game-api.md](./game-api.md) | 认证、体力值、关卡闯关、游戏配置 | ✅ 已完成 |
|
||||
| 分享挑战 | [share-challenge-api.md](./share-challenge-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-10T12:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 基础路径
|
||||
```
|
||||
生产环境:https://api.mememind.com/api
|
||||
本地开发:http://localhost:3000/api
|
||||
```
|
||||
|
||||
## 客户端接入
|
||||
|
||||
客户端基于 Cocos Creator 开发,接入示例详见各模块文档。
|
||||
1175
docs/api/game-api.md
Normal file
1175
docs/api/game-api.md
Normal file
File diff suppressed because it is too large
Load Diff
851
docs/api/share-challenge-api.md
Normal file
851
docs/api/share-challenge-api.md
Normal file
@@ -0,0 +1,851 @@
|
||||
# MemeMind 分享挑战功能 API 文档
|
||||
|
||||
> 本文档面向微信小游戏客户端(Cocos Creator)开发人员
|
||||
|
||||
## 目录
|
||||
|
||||
- [概述](#概述)
|
||||
- [认证方式](#认证方式)
|
||||
- [通用响应格式](#通用响应格式)
|
||||
- [接口列表](#接口列表)
|
||||
- [错误码说明](#错误码说明)
|
||||
- [接入流程](#接入流程)
|
||||
- [Cocos Creator 调用示例](#cocos-creator-调用示例)
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
分享挑战功能允许用户创建包含 6 个关卡的挑战链接,分享给好友。好友通过分享码加入挑战,独立完成关卡并上报进度。
|
||||
|
||||
### 新增功能点(当前分支)
|
||||
|
||||
1. **关卡时间限制**:`levels` 表新增 `time_limit` 字段,支持关卡通关时间限制
|
||||
2. **单关进度上报**:`POST /api/v1/share/progress` 接口,用于上报用户单关通关状态和时间
|
||||
3. **进度查询**:`reportLevelProgress` 返回是否在时间限制内通过
|
||||
4. **我创建的挑战列表**:`GET /api/v1/share/created` 接口,用于查询当前用户创建过的分享挑战、参与人数和本人排名
|
||||
|
||||
---
|
||||
|
||||
## 认证方式
|
||||
|
||||
除微信登录接口外,所有接口均需通过 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; // 用户昵称(微信昵称)
|
||||
stamina: number; // 当前体力值
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**成功响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"user": {
|
||||
"id": "user_abc123",
|
||||
"nickname": "游戏玩家",
|
||||
"stamina": 5
|
||||
}
|
||||
},
|
||||
"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. 获取我创建的分享挑战
|
||||
|
||||
获取当前登录用户创建过的分享挑战列表。
|
||||
|
||||
**接口地址**:`GET /api/v1/share/created`
|
||||
|
||||
**是否需要认证**:是(JWT Bearer Token)
|
||||
|
||||
**请求头**:
|
||||
|
||||
```
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**响应数据**:
|
||||
|
||||
```typescript
|
||||
{
|
||||
items: [
|
||||
{
|
||||
id: string; // 分享挑战 ID
|
||||
shareCode: string; // 分享码
|
||||
title: string; // 分享标题
|
||||
levelCount: number; // 关卡数量
|
||||
participantCount: number; // 参与挑战人数
|
||||
userRank: number | null; // 当前用户在该挑战中的排名;未完成全部关卡时为 null
|
||||
createdAt: string; // 创建时间,ISO 8601 字符串
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**成功响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": "share_001",
|
||||
"shareCode": "abc12345",
|
||||
"title": "我的挑战",
|
||||
"levelCount": 6,
|
||||
"participantCount": 8,
|
||||
"userRank": 2,
|
||||
"createdAt": "2026-04-13T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "share_002",
|
||||
"shareCode": "xyz67890",
|
||||
"title": "速度挑战",
|
||||
"levelCount": 6,
|
||||
"participantCount": 1,
|
||||
"userRank": null,
|
||||
"createdAt": "2026-04-12T09:00:00.000Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
"message": null,
|
||||
"timestamp": "2026-04-13T12:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**排名规则**:
|
||||
|
||||
1. 只有完成该分享挑战全部关卡的用户才会进入排名。
|
||||
2. 排名按通关总耗时升序计算,总耗时越短排名越高。
|
||||
3. 当总耗时相同时,服务端会按 `participantId` 做稳定排序,保证返回顺序可重复。
|
||||
4. `userRank` 表示当前登录用户在自己创建的该挑战中的排名。如果自己尚未完成全部关卡,则返回 `null`。
|
||||
|
||||
**参与人数统计规则**:
|
||||
|
||||
- 统计 `share_participants` 中该挑战的参与者数量。
|
||||
- 创建者本人在调用创建接口时不会自动写入参与记录;只有真正以参与者身份产生挑战进度后,才可能出现在排名内。
|
||||
|
||||
**客户端调用场景**:
|
||||
|
||||
- 用户进入「我发起的挑战」页面时调用。
|
||||
- 用于展示每个分享挑战的传播效果和本人当前成绩。
|
||||
|
||||
---
|
||||
|
||||
### 5. 上报单关进度
|
||||
|
||||
用户在分享挑战中完成单关后,上报进度。
|
||||
|
||||
**接口地址**:`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; stamina: 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
|
||||
@@ -2,9 +2,12 @@ import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AppConfigModule } from './config/config.module';
|
||||
import { WechatGameModule } from './modules/wechat-game/wechat-game.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { UserModule } from './modules/user/user.module';
|
||||
import { LevelModule } from './modules/level/level.module';
|
||||
import { GameConfigModule } from './modules/game-config/game-config.module';
|
||||
import { ShareModule } from './modules/share/share.module';
|
||||
import { WechatGameModule } from './modules/wechat-game/wechat-game.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -25,8 +28,11 @@ import { ShareModule } from './modules/share/share.module';
|
||||
autoLoadEntities: true,
|
||||
}),
|
||||
}),
|
||||
WechatGameModule,
|
||||
AuthModule,
|
||||
UserModule,
|
||||
LevelModule,
|
||||
GameConfigModule,
|
||||
WechatGameModule, // 保留用于 entity/repository 导出
|
||||
ShareModule,
|
||||
],
|
||||
})
|
||||
|
||||
5
src/common/constants/game.constants.ts
Normal file
5
src/common/constants/game.constants.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/** Maximum stamina a user can have */
|
||||
export const MAX_STAMINA = 50;
|
||||
|
||||
/** Stamina recovery interval: 1 point every 10 minutes */
|
||||
export const RECOVER_INTERVAL_MS = 10 * 60 * 1000;
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
95
src/common/filters/http-exception.filter.spec.ts
Normal file
95
src/common/filters/http-exception.filter.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
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();
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import { validateEnvironment } from './env.validation';
|
||||
isGlobal: true,
|
||||
load: [databaseConfig],
|
||||
validate: validateEnvironment,
|
||||
envFilePath: ['.env.local', '.env.production', '.env'],
|
||||
envFilePath: ['.env.local', '.env', '.env.production'],
|
||||
}),
|
||||
],
|
||||
exports: [ConfigModule],
|
||||
|
||||
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;
|
||||
32
src/database/migrations/003_level_dual_image_stamina.sql
Normal file
32
src/database/migrations/003_level_dual_image_stamina.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- Migration: 003_level_dual_image_stamina
|
||||
-- Description: Level dual-image support + stamina max 50 (old max was 5)
|
||||
|
||||
-- 1. Rename image_url → image1_url and expand to VARCHAR(500)
|
||||
ALTER TABLE levels CHANGE COLUMN image_url image1_url VARCHAR(500) NOT NULL;
|
||||
|
||||
-- 2. Add image1_description after image1_url
|
||||
ALTER TABLE levels ADD COLUMN image1_description VARCHAR(500) NULL AFTER image1_url;
|
||||
|
||||
-- 3. Add image2_url with default empty string
|
||||
ALTER TABLE levels ADD COLUMN image2_url VARCHAR(500) NOT NULL DEFAULT '' AFTER image1_description;
|
||||
|
||||
-- 4. Add image2_description after image2_url
|
||||
ALTER TABLE levels ADD COLUMN image2_description VARCHAR(500) NULL AFTER image2_url;
|
||||
|
||||
-- 5. Add punchline (谐音梗说明) after answer
|
||||
ALTER TABLE levels ADD COLUMN punchline VARCHAR(500) NULL AFTER answer;
|
||||
|
||||
-- 6. Update stamina default from 5 to 50
|
||||
ALTER TABLE wx_users ALTER COLUMN stamina SET DEFAULT 50;
|
||||
|
||||
-- 7. Bump users at or below old max (5) to new max (50)
|
||||
UPDATE wx_users SET stamina = 50 WHERE stamina <= 5;
|
||||
|
||||
-- ROLLBACK (manual):
|
||||
-- ALTER TABLE levels CHANGE COLUMN image1_url image_url VARCHAR(191) NOT NULL;
|
||||
-- ALTER TABLE levels DROP COLUMN image1_description;
|
||||
-- ALTER TABLE levels DROP COLUMN image2_url;
|
||||
-- ALTER TABLE levels DROP COLUMN image2_description;
|
||||
-- ALTER TABLE levels DROP COLUMN punchline;
|
||||
-- ALTER TABLE wx_users ALTER COLUMN stamina SET DEFAULT 5;
|
||||
-- UPDATE wx_users SET stamina = 5 WHERE stamina = 50;
|
||||
47
src/modules/auth/auth.controller.spec.ts
Normal file
47
src/modules/auth/auth.controller.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
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 { MAX_STAMINA } from '../../common/constants/game.constants';
|
||||
|
||||
describe('AuthController', () => {
|
||||
let controller: AuthController;
|
||||
|
||||
const mockAuthService = {
|
||||
wxLogin: 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', stamina: MAX_STAMINA },
|
||||
};
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,15 @@
|
||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { AuthService } from './auth.service';
|
||||
import { WxLoginRequestDto, WxLoginResponseDto } from './dto/wx-login.dto';
|
||||
import {
|
||||
ConsumePointRequestDto,
|
||||
EarnPointRequestDto,
|
||||
GameDataResponseDto,
|
||||
UserAssetsResponseDto,
|
||||
} from './dto/user-assets.dto';
|
||||
import { ApiResponseDto } from '../../common/dto/api-response.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import type { JwtPayload } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@ApiTags('用户认证与资产')
|
||||
@Controller('v1')
|
||||
@ApiTags('认证')
|
||||
@Controller('v1/auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
// ==================== 公开接口 ====================
|
||||
|
||||
@Post('auth/wx-login')
|
||||
@Post('wx-login')
|
||||
@ApiOperation({
|
||||
summary: '微信登录',
|
||||
description: '使用微信 wx.login 返回的 code 换取 JWT 令牌',
|
||||
@@ -38,73 +22,4 @@ export class AuthController {
|
||||
const data = await this.authService.wxLogin(dto.code);
|
||||
return ApiResponseDto.success(data);
|
||||
}
|
||||
|
||||
// ==================== 需要鉴权的接口 ====================
|
||||
|
||||
@Get('user/assets')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '获取用户积分',
|
||||
description: '获取当前登录用户的积分信息',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '成功' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
async getUserAssets(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<ApiResponseDto<UserAssetsResponseDto>> {
|
||||
const data = await this.authService.getUserAssets(user.sub);
|
||||
return ApiResponseDto.success(data);
|
||||
}
|
||||
|
||||
@Post('user/assets/consume')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '消耗积分',
|
||||
description: '消耗 1 积分(用于解锁提示)',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '消耗成功' })
|
||||
@ApiResponse({ status: 400, description: '积分不足' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
async consumePoint(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: ConsumePointRequestDto,
|
||||
): Promise<ApiResponseDto<UserAssetsResponseDto>> {
|
||||
const data = await this.authService.consumePoint(user.sub, dto);
|
||||
return ApiResponseDto.success(data);
|
||||
}
|
||||
|
||||
@Post('user/assets/earn')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '获得积分',
|
||||
description: '通关获得 1 积分(同一关卡不重复奖励)',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '获得成功' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
async earnPoint(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: EarnPointRequestDto,
|
||||
): Promise<ApiResponseDto<UserAssetsResponseDto>> {
|
||||
const data = await this.authService.earnPoint(user.sub, dto);
|
||||
return ApiResponseDto.success(data);
|
||||
}
|
||||
|
||||
@Get('user/game-data')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '获取游戏数据',
|
||||
description: '获取用户积分和通关进度(Loading 页面使用)',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '成功' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
async getGameData(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<ApiResponseDto<GameDataResponseDto>> {
|
||||
const data = await this.authService.getGameData(user.sub);
|
||||
return ApiResponseDto.success(data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ import { UserLevelProgressRepository } from './repositories/user-level-progress.
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, UserRepository, UserLevelProgressRepository],
|
||||
exports: [JwtModule, AuthService],
|
||||
exports: [
|
||||
JwtModule,
|
||||
AuthService,
|
||||
UserRepository,
|
||||
UserLevelProgressRepository,
|
||||
],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
130
src/modules/auth/auth.service.spec.ts
Normal file
130
src/modules/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import axios from 'axios';
|
||||
import { AuthService } from './auth.service';
|
||||
import { UserRepository } from './repositories/user.repository';
|
||||
import { User } from './entities/user.entity';
|
||||
import { MAX_STAMINA } from '../../common/constants/game.constants';
|
||||
|
||||
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,
|
||||
stamina: MAX_STAMINA,
|
||||
staminaUpdatedAt: null,
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
};
|
||||
|
||||
const mockUserRepository = {
|
||||
findById: jest.fn(),
|
||||
findByOpenid: 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 },
|
||||
],
|
||||
}).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 };
|
||||
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.stamina).toBe(MAX_STAMINA);
|
||||
expect(mockUserRepository.create).toHaveBeenCalledWith({
|
||||
openid: 'wx-openid-123',
|
||||
sessionKey: 'session-key-abc',
|
||||
stamina: MAX_STAMINA,
|
||||
});
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,11 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import axios from 'axios';
|
||||
import { UserRepository } from './repositories/user.repository';
|
||||
import { UserLevelProgressRepository } from './repositories/user-level-progress.repository';
|
||||
import { WxLoginResponseDto, UserInfoDto } from './dto/wx-login.dto';
|
||||
import {
|
||||
UserAssetsResponseDto,
|
||||
ConsumePointRequestDto,
|
||||
EarnPointRequestDto,
|
||||
GameDataResponseDto,
|
||||
} from './dto/user-assets.dto';
|
||||
import { JwtPayload } from '../../common/guards/jwt-auth.guard';
|
||||
import { MAX_STAMINA } from '../../common/constants/game.constants';
|
||||
|
||||
interface WxSessionResponse {
|
||||
openid?: string;
|
||||
@@ -35,7 +24,6 @@ export class AuthService {
|
||||
private readonly configService: ConfigService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly userLevelProgressRepository: UserLevelProgressRepository,
|
||||
) {
|
||||
this.wxAppId = this.configService.get<string>('WX_APPID', '');
|
||||
this.wxSecret = this.configService.get<string>('WX_SECRET', '');
|
||||
@@ -62,7 +50,7 @@ export class AuthService {
|
||||
user = this.userRepository.create({
|
||||
openid: wxSession.openid,
|
||||
sessionKey: wxSession.session_key ?? null,
|
||||
points: 10, // 新用户默认 10 积分
|
||||
stamina: MAX_STAMINA,
|
||||
});
|
||||
user = await this.userRepository.save(user);
|
||||
this.logger.log(`新用户注册: ${user.id}`);
|
||||
@@ -85,101 +73,12 @@ export class AuthService {
|
||||
const userInfo: UserInfoDto = {
|
||||
id: user.id,
|
||||
nickname: user.nickname,
|
||||
points: user.points,
|
||||
stamina: user.stamina,
|
||||
};
|
||||
|
||||
return { token, user: userInfo };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户积分
|
||||
*/
|
||||
async getUserAssets(userId: string): Promise<UserAssetsResponseDto> {
|
||||
const user = await this.findUserOrThrow(userId);
|
||||
return { points: user.points };
|
||||
}
|
||||
|
||||
/**
|
||||
* 消耗积分(解锁提示)
|
||||
*/
|
||||
async consumePoint(
|
||||
userId: string,
|
||||
dto: ConsumePointRequestDto,
|
||||
): Promise<UserAssetsResponseDto> {
|
||||
const user = await this.findUserOrThrow(userId);
|
||||
|
||||
if (user.points <= 0) {
|
||||
throw new BadRequestException('积分不足,无法消耗');
|
||||
}
|
||||
|
||||
user.points -= 1;
|
||||
await this.userRepository.save(user);
|
||||
|
||||
this.logger.log(
|
||||
`用户 ${userId} 消耗 1 积分(${dto.reason}),剩余: ${user.points}`,
|
||||
);
|
||||
|
||||
return { points: user.points };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得积分(通关奖励)
|
||||
*/
|
||||
async earnPoint(
|
||||
userId: string,
|
||||
dto: EarnPointRequestDto,
|
||||
): Promise<UserAssetsResponseDto> {
|
||||
const user = await this.findUserOrThrow(userId);
|
||||
|
||||
// 检查是否已经领取过该关卡的通关奖励(防重复)
|
||||
const existing = await this.userLevelProgressRepository.findByUserAndLevel(
|
||||
userId,
|
||||
dto.levelId,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
this.logger.warn(`用户 ${userId} 已完成关卡 ${dto.levelId},不重复奖励`);
|
||||
return { points: user.points };
|
||||
}
|
||||
|
||||
// 记录通关进度
|
||||
const progress = this.userLevelProgressRepository.create({
|
||||
userId,
|
||||
levelId: dto.levelId,
|
||||
timeSpent: dto.timeSpent,
|
||||
});
|
||||
await this.userLevelProgressRepository.save(progress);
|
||||
|
||||
// 增加积分
|
||||
user.points += 1;
|
||||
await this.userRepository.save(user);
|
||||
|
||||
this.logger.log(
|
||||
`用户 ${userId} 通关 ${dto.levelId},获得 1 积分,当前: ${user.points}`,
|
||||
);
|
||||
|
||||
return { points: user.points };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户游戏数据(Loading 页面复合接口)
|
||||
*/
|
||||
async getGameData(userId: string): Promise<GameDataResponseDto> {
|
||||
const [user, progressList] = await Promise.all([
|
||||
this.findUserOrThrow(userId),
|
||||
this.userLevelProgressRepository.findByUserId(userId),
|
||||
]);
|
||||
const completedLevelIds = progressList.map((p) => p.levelId);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
points: user.points,
|
||||
},
|
||||
completedLevelIds,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用微信 jscode2session 接口
|
||||
*/
|
||||
@@ -200,15 +99,4 @@ export class AuthService {
|
||||
throw new UnauthorizedException('微信服务调用失败,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找用户,不存在则抛异常
|
||||
*/
|
||||
private async findUserOrThrow(userId: string) {
|
||||
const user = await this.userRepository.findById(userId);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('用户不存在');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UserAssetsResponseDto {
|
||||
@ApiProperty({ description: '积分' })
|
||||
points!: number;
|
||||
}
|
||||
|
||||
export class ConsumePointRequestDto {
|
||||
@ApiProperty({ description: '消耗原因', enum: ['hint_unlock'] })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsIn(['hint_unlock'])
|
||||
reason!: 'hint_unlock';
|
||||
|
||||
@ApiProperty({ description: '关卡 ID', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
levelId?: string;
|
||||
|
||||
@ApiProperty({ description: '提示索引(2 或 3)', required: false })
|
||||
@IsOptional()
|
||||
hintIndex?: number;
|
||||
}
|
||||
|
||||
export class EarnPointRequestDto {
|
||||
@ApiProperty({ description: '获取原因', enum: ['level_complete'] })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsIn(['level_complete'])
|
||||
reason!: 'level_complete';
|
||||
|
||||
@ApiProperty({ description: '关卡 ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
levelId!: string;
|
||||
|
||||
@ApiProperty({ description: '通关时间(秒)' })
|
||||
@IsNotEmpty()
|
||||
timeSpent!: number;
|
||||
}
|
||||
|
||||
export class GameDataResponseDto {
|
||||
@ApiProperty({ description: '用户信息' })
|
||||
user!: {
|
||||
id: string;
|
||||
points: number;
|
||||
};
|
||||
|
||||
@ApiProperty({ description: '已完成的关卡 ID 列表' })
|
||||
completedLevelIds!: string[];
|
||||
}
|
||||
@@ -15,8 +15,8 @@ export class UserInfoDto {
|
||||
@ApiProperty({ description: '用户昵称', nullable: true })
|
||||
nickname!: string | null;
|
||||
|
||||
@ApiProperty({ description: '积分' })
|
||||
points!: number;
|
||||
@ApiProperty({ description: '体力值' })
|
||||
stamina!: number;
|
||||
}
|
||||
|
||||
export class WxLoginResponseDto {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { MAX_STAMINA } from '../../../common/constants/game.constants';
|
||||
|
||||
@Entity('wx_users')
|
||||
export class User {
|
||||
@@ -25,9 +26,13 @@ export class User {
|
||||
@Column({ type: 'text', name: 'avatar_url', nullable: true })
|
||||
avatarUrl!: string | null;
|
||||
|
||||
/** 积分(默认 10) */
|
||||
@Column({ type: 'int', default: 10 })
|
||||
points!: number;
|
||||
/** 体力值(默认 MAX_STAMINA,上限 MAX_STAMINA) */
|
||||
@Column({ type: 'int', default: MAX_STAMINA })
|
||||
stamina!: number;
|
||||
|
||||
/** 体力值最后更新时间(用于计算恢复) */
|
||||
@Column({ type: 'timestamp', name: 'stamina_updated_at', nullable: true })
|
||||
staminaUpdatedAt!: Date | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { UserLevelProgress } from '../entities/user-level-progress.entity';
|
||||
|
||||
export interface IUserLevelProgressRepository {
|
||||
findByUserId(userId: string): Promise<UserLevelProgress[]>;
|
||||
countByUserId(userId: string): Promise<number>;
|
||||
findByUserAndLevel(
|
||||
userId: string,
|
||||
levelId: string,
|
||||
|
||||
@@ -15,6 +15,10 @@ export class UserLevelProgressRepository implements IUserLevelProgressRepository
|
||||
return this.repository.find({ where: { userId } });
|
||||
}
|
||||
|
||||
async countByUserId(userId: string): Promise<number> {
|
||||
return this.repository.count({ where: { userId } });
|
||||
}
|
||||
|
||||
async findByUserAndLevel(
|
||||
userId: string,
|
||||
levelId: string,
|
||||
|
||||
@@ -5,4 +5,10 @@ export interface IUserRepository {
|
||||
findByOpenid(openid: string): Promise<User | null>;
|
||||
create(data: Partial<User>): User;
|
||||
save(user: User): Promise<User>;
|
||||
updateStaminaAtomic(
|
||||
userId: string,
|
||||
expectedOldStamina: number,
|
||||
newStamina: number,
|
||||
staminaUpdatedAt: Date,
|
||||
): Promise<{ affected: number }>;
|
||||
}
|
||||
|
||||
@@ -26,4 +26,21 @@ export class UserRepository implements IUserRepository {
|
||||
async save(user: User): Promise<User> {
|
||||
return this.repository.save(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子更新体力值,使用 WHERE 条件防止并发竞态。
|
||||
* 只有当 stamina 仍等于 expectedOldStamina 时才更新。
|
||||
*/
|
||||
async updateStaminaAtomic(
|
||||
userId: string,
|
||||
expectedOldStamina: number,
|
||||
newStamina: number,
|
||||
staminaUpdatedAt: Date,
|
||||
): Promise<{ affected: number }> {
|
||||
const result = await this.repository.update(
|
||||
{ id: userId, stamina: expectedOldStamina },
|
||||
{ stamina: newStamina, staminaUpdatedAt },
|
||||
);
|
||||
return { affected: result.affected ?? 0 };
|
||||
}
|
||||
}
|
||||
|
||||
39
src/modules/game-config/game-config.controller.ts
Normal file
39
src/modules/game-config/game-config.controller.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { GameConfigService } from './game-config.service';
|
||||
import {
|
||||
GameConfigResponseDto,
|
||||
GameConfigListResponseDto,
|
||||
} from '../wechat-game/dto/game-config-response.dto';
|
||||
import { ApiResponseDto } from '../../common/dto/api-response.dto';
|
||||
|
||||
@ApiTags('游戏配置')
|
||||
@Controller('v1/game-configs')
|
||||
export class GameConfigController {
|
||||
constructor(private readonly gameConfigService: GameConfigService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '获取所有游戏配置',
|
||||
description: '获取所有激活的游戏配置列表',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '成功获取配置列表' })
|
||||
async getAllConfigs(): Promise<ApiResponseDto<GameConfigListResponseDto>> {
|
||||
const data = await this.gameConfigService.getAllConfigs();
|
||||
return ApiResponseDto.success(data);
|
||||
}
|
||||
|
||||
@Get(':key')
|
||||
@ApiOperation({
|
||||
summary: '根据 key 获取配置',
|
||||
description: '根据配置键名获取单个游戏配置',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '成功获取配置' })
|
||||
@ApiResponse({ status: 404, description: '配置不存在' })
|
||||
async getConfigByKey(
|
||||
@Param('key') key: string,
|
||||
): Promise<ApiResponseDto<GameConfigResponseDto>> {
|
||||
const data = await this.gameConfigService.getConfigByKey(key);
|
||||
return ApiResponseDto.success(data);
|
||||
}
|
||||
}
|
||||
11
src/modules/game-config/game-config.module.ts
Normal file
11
src/modules/game-config/game-config.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GameConfigController } from './game-config.controller';
|
||||
import { GameConfigService } from './game-config.service';
|
||||
import { WechatGameModule } from '../wechat-game/wechat-game.module';
|
||||
|
||||
@Module({
|
||||
imports: [WechatGameModule],
|
||||
controllers: [GameConfigController],
|
||||
providers: [GameConfigService],
|
||||
})
|
||||
export class GameConfigModule {}
|
||||
40
src/modules/game-config/game-config.service.ts
Normal file
40
src/modules/game-config/game-config.service.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { GameConfigRepository } from '../wechat-game/repositories/game-config.repository';
|
||||
import {
|
||||
GameConfigResponseDto,
|
||||
GameConfigListResponseDto,
|
||||
} from '../wechat-game/dto/game-config-response.dto';
|
||||
import { GameConfig } from '../wechat-game/entities/game-config.entity';
|
||||
|
||||
@Injectable()
|
||||
export class GameConfigService {
|
||||
constructor(private readonly gameConfigRepository: GameConfigRepository) {}
|
||||
|
||||
async getAllConfigs(): Promise<GameConfigListResponseDto> {
|
||||
const configs = await this.gameConfigRepository.findActiveConfigs();
|
||||
return {
|
||||
configs: configs.map((config) => this.toResponseDto(config)),
|
||||
total: configs.length,
|
||||
};
|
||||
}
|
||||
|
||||
async getConfigByKey(key: string): Promise<GameConfigResponseDto> {
|
||||
const config = await this.gameConfigRepository.findByKey(key);
|
||||
if (!config) {
|
||||
throw new NotFoundException(`Game config with key "${key}" not found`);
|
||||
}
|
||||
return this.toResponseDto(config);
|
||||
}
|
||||
|
||||
private toResponseDto(config: GameConfig): GameConfigResponseDto {
|
||||
return {
|
||||
id: config.id,
|
||||
configKey: config.configKey,
|
||||
configValue: config.configValue,
|
||||
description: config.description,
|
||||
isActive: config.isActive,
|
||||
createdAt: config.createdAt,
|
||||
updatedAt: config.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
29
src/modules/level/dto/complete-level.dto.ts
Normal file
29
src/modules/level/dto/complete-level.dto.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsNumber, Min } from 'class-validator';
|
||||
import { NextLevelDto } from './next-level.dto';
|
||||
|
||||
export class CompleteLevelRequestDto {
|
||||
@ApiProperty({ description: '通关时长(秒)' })
|
||||
@IsNumber()
|
||||
@IsNotEmpty()
|
||||
@Min(0)
|
||||
timeSpent!: number;
|
||||
}
|
||||
|
||||
export class CompleteLevelResponseDto {
|
||||
@ApiProperty({ description: '是否为首次通关' })
|
||||
firstClear!: boolean;
|
||||
|
||||
@ApiProperty({ description: '关卡 ID' })
|
||||
levelId!: string;
|
||||
|
||||
@ApiProperty({ description: '通关时长(秒)' })
|
||||
timeSpent!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '下一个待通关的关卡(全部通关时为 null)',
|
||||
nullable: true,
|
||||
type: NextLevelDto,
|
||||
})
|
||||
nextLevel!: NextLevelDto | null;
|
||||
}
|
||||
10
src/modules/level/dto/completed-level.dto.ts
Normal file
10
src/modules/level/dto/completed-level.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { NextLevelDto } from './next-level.dto';
|
||||
|
||||
export class CompletedLevelDto extends NextLevelDto {
|
||||
@ApiProperty({ description: '通关时长(秒)' })
|
||||
timeSpent!: number;
|
||||
|
||||
@ApiProperty({ description: '通关时间(ISO 8601)' })
|
||||
completedAt!: Date;
|
||||
}
|
||||
49
src/modules/level/dto/enter-level.dto.ts
Normal file
49
src/modules/level/dto/enter-level.dto.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { StaminaInfoDto } from '../../user/dto/user-profile.dto';
|
||||
import { NextLevelDto } from './next-level.dto';
|
||||
|
||||
export class EnterLevelResponseDto {
|
||||
@ApiProperty({ description: '关卡 ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '关卡编号' })
|
||||
level!: number;
|
||||
|
||||
@ApiProperty({ description: '图片1 URL' })
|
||||
image1Url!: string;
|
||||
|
||||
@ApiProperty({ description: '图片1 文本说明', nullable: true })
|
||||
image1Description!: string | null;
|
||||
|
||||
@ApiProperty({ description: '图片2 URL' })
|
||||
image2Url!: string;
|
||||
|
||||
@ApiProperty({ description: '图片2 文本说明', nullable: true })
|
||||
image2Description!: string | null;
|
||||
|
||||
@ApiProperty({ description: '答案' })
|
||||
answer!: string;
|
||||
|
||||
@ApiProperty({ description: '谐音梗说明', nullable: true })
|
||||
punchline!: string | null;
|
||||
|
||||
@ApiProperty({ description: '线索1', nullable: true })
|
||||
hint1!: string | null;
|
||||
|
||||
@ApiProperty({ description: '线索2', nullable: true })
|
||||
hint2!: string | null;
|
||||
|
||||
@ApiProperty({ description: '线索3', nullable: true })
|
||||
hint3!: string | null;
|
||||
|
||||
@ApiProperty({ description: '消耗体力后的体力信息' })
|
||||
stamina!: StaminaInfoDto;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'预加载的下一关数据(用于客户端预加载资源,无下一关时为 null)',
|
||||
nullable: true,
|
||||
type: NextLevelDto,
|
||||
})
|
||||
preloadNextLevel!: NextLevelDto | null;
|
||||
}
|
||||
39
src/modules/level/dto/next-level.dto.ts
Normal file
39
src/modules/level/dto/next-level.dto.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class NextLevelDto {
|
||||
@ApiProperty({ description: '关卡 ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '关卡编号(sortOrder)' })
|
||||
level!: number;
|
||||
|
||||
@ApiProperty({ description: '图片1 URL' })
|
||||
image1Url!: string;
|
||||
|
||||
@ApiProperty({ description: '图片1 文本说明', nullable: true })
|
||||
image1Description!: string | null;
|
||||
|
||||
@ApiProperty({ description: '图片2 URL' })
|
||||
image2Url!: string;
|
||||
|
||||
@ApiProperty({ description: '图片2 文本说明', nullable: true })
|
||||
image2Description!: string | null;
|
||||
|
||||
@ApiProperty({ description: '答案' })
|
||||
answer!: string;
|
||||
|
||||
@ApiProperty({ description: '谐音梗说明', nullable: true })
|
||||
punchline!: string | null;
|
||||
|
||||
@ApiProperty({ description: '线索1', nullable: true })
|
||||
hint1!: string | null;
|
||||
|
||||
@ApiProperty({ description: '线索2', nullable: true })
|
||||
hint2!: string | null;
|
||||
|
||||
@ApiProperty({ description: '线索3', nullable: true })
|
||||
hint3!: string | null;
|
||||
|
||||
@ApiProperty({ description: '限时(秒)', nullable: true })
|
||||
timeLimit!: number | null;
|
||||
}
|
||||
76
src/modules/level/level.controller.ts
Normal file
76
src/modules/level/level.controller.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { LevelService } from './level.service';
|
||||
import { EnterLevelResponseDto } from './dto/enter-level.dto';
|
||||
import {
|
||||
CompleteLevelRequestDto,
|
||||
CompleteLevelResponseDto,
|
||||
} from './dto/complete-level.dto';
|
||||
import { CompletedLevelDto } from './dto/completed-level.dto';
|
||||
import { ApiResponseDto } from '../../common/dto/api-response.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import type { JwtPayload } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@ApiTags('关卡')
|
||||
@Controller('v1/levels')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class LevelController {
|
||||
constructor(private readonly levelService: LevelService) {}
|
||||
|
||||
@Get('completed')
|
||||
@ApiOperation({
|
||||
summary: '获取已通关关卡列表',
|
||||
description:
|
||||
'返回当前用户所有已通关的关卡,按关卡顺序(sortOrder)升序排列。每项包含完整关卡信息 + 通关时长 + 通关时间。',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '成功' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
async getCompletedLevels(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<ApiResponseDto<CompletedLevelDto[]>> {
|
||||
const data = await this.levelService.getCompletedLevels(user.sub);
|
||||
return ApiResponseDto.success(data);
|
||||
}
|
||||
|
||||
@Post(':id/enter')
|
||||
@ApiOperation({
|
||||
summary: '进入关卡',
|
||||
description:
|
||||
'消耗 1 体力进入关卡,返回完整关卡详情(线索+答案)。已通关关卡不消耗体力。',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '成功' })
|
||||
@ApiResponse({ status: 400, description: '体力不足' })
|
||||
@ApiResponse({ status: 404, description: '关卡不存在' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
async enterLevel(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('id') id: string,
|
||||
): Promise<ApiResponseDto<EnterLevelResponseDto>> {
|
||||
const data = await this.levelService.enterLevel(user.sub, id);
|
||||
return ApiResponseDto.success(data);
|
||||
}
|
||||
|
||||
@Post(':id/complete')
|
||||
@ApiOperation({
|
||||
summary: '通关上报',
|
||||
description: '上报用户通关时长,同一关卡不重复记录',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '成功' })
|
||||
@ApiResponse({ status: 404, description: '关卡不存在' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
async completeLevel(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: CompleteLevelRequestDto,
|
||||
): Promise<ApiResponseDto<CompleteLevelResponseDto>> {
|
||||
const data = await this.levelService.completeLevel(user.sub, id, dto);
|
||||
return ApiResponseDto.success(data);
|
||||
}
|
||||
}
|
||||
14
src/modules/level/level.module.ts
Normal file
14
src/modules/level/level.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LevelController } from './level.controller';
|
||||
import { LevelService } from './level.service';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { WechatGameModule } from '../wechat-game/wechat-game.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, UserModule, WechatGameModule],
|
||||
controllers: [LevelController],
|
||||
providers: [LevelService],
|
||||
exports: [LevelService],
|
||||
})
|
||||
export class LevelModule {}
|
||||
165
src/modules/level/level.service.ts
Normal file
165
src/modules/level/level.service.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
|
||||
import { LevelRepository } from '../wechat-game/repositories/level.repository';
|
||||
import { UserLevelProgressRepository } from '../auth/repositories/user-level-progress.repository';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { EnterLevelResponseDto } from './dto/enter-level.dto';
|
||||
import {
|
||||
CompleteLevelRequestDto,
|
||||
CompleteLevelResponseDto,
|
||||
} from './dto/complete-level.dto';
|
||||
import { CompletedLevelDto } from './dto/completed-level.dto';
|
||||
import { pickLevelImageFields } from '../wechat-game/level-fields.helper';
|
||||
import { findNextUncompletedLevels, toNextLevelDto } from './next-level.helper';
|
||||
|
||||
@Injectable()
|
||||
export class LevelService {
|
||||
private readonly logger = new Logger(LevelService.name);
|
||||
|
||||
constructor(
|
||||
private readonly levelRepository: LevelRepository,
|
||||
private readonly userLevelProgressRepository: UserLevelProgressRepository,
|
||||
private readonly userService: UserService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 进入关卡:消耗 1 体力,返回完整关卡详情 + 预加载下一关
|
||||
*/
|
||||
async enterLevel(
|
||||
userId: string,
|
||||
levelId: string,
|
||||
): Promise<EnterLevelResponseDto> {
|
||||
const [level, existing] = await Promise.all([
|
||||
this.levelRepository.findById(levelId),
|
||||
this.userLevelProgressRepository.findByUserAndLevel(userId, levelId),
|
||||
]);
|
||||
|
||||
if (!level) {
|
||||
throw new NotFoundException(`关卡 ${levelId} 不存在`);
|
||||
}
|
||||
|
||||
let staminaInfo;
|
||||
|
||||
if (existing) {
|
||||
// Already completed — no stamina cost
|
||||
const user = await this.userService.findUserOrThrow(userId);
|
||||
staminaInfo = this.userService.computeStamina(user);
|
||||
} else {
|
||||
const result = await this.userService.consumeStamina(userId);
|
||||
staminaInfo = result.stamina;
|
||||
this.logger.log(`用户 ${userId} 进入关卡 ${levelId},消耗 1 体力`);
|
||||
}
|
||||
|
||||
// 计算预加载的下一关(当前关卡之后的第一个未完成关卡)
|
||||
const [allLevels, progressList] = await Promise.all([
|
||||
this.levelRepository.findAllOrdered(),
|
||||
this.userLevelProgressRepository.findByUserId(userId),
|
||||
]);
|
||||
const completedIds = new Set(progressList.map((p) => p.levelId));
|
||||
// 当前关卡不算已完成(用户正在玩),找当前关卡之后的第一个未完成关卡
|
||||
const levelsAfterCurrent = allLevels.filter(
|
||||
(l) => l.sortOrder > level.sortOrder,
|
||||
);
|
||||
const nextLevels = findNextUncompletedLevels(
|
||||
levelsAfterCurrent,
|
||||
completedIds,
|
||||
1,
|
||||
);
|
||||
|
||||
return {
|
||||
id: level.id,
|
||||
level: level.sortOrder,
|
||||
...pickLevelImageFields(level),
|
||||
answer: level.answer,
|
||||
stamina: staminaInfo,
|
||||
preloadNextLevel: nextLevels[0] ? toNextLevelDto(nextLevels[0]) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 通关上报:记录通关时长,返回下一关数据
|
||||
*/
|
||||
async completeLevel(
|
||||
userId: string,
|
||||
levelId: string,
|
||||
dto: CompleteLevelRequestDto,
|
||||
): Promise<CompleteLevelResponseDto> {
|
||||
const [level, existing] = await Promise.all([
|
||||
this.levelRepository.findById(levelId),
|
||||
this.userLevelProgressRepository.findByUserAndLevel(userId, levelId),
|
||||
]);
|
||||
|
||||
if (!level) {
|
||||
throw new NotFoundException(`关卡 ${levelId} 不存在`);
|
||||
}
|
||||
|
||||
let firstClear: boolean;
|
||||
let timeSpent: number;
|
||||
|
||||
if (existing) {
|
||||
this.logger.warn(`用户 ${userId} 已通关关卡 ${levelId},不重复记录`);
|
||||
firstClear = false;
|
||||
timeSpent = existing.timeSpent;
|
||||
} else {
|
||||
const progress = this.userLevelProgressRepository.create({
|
||||
userId,
|
||||
levelId,
|
||||
timeSpent: dto.timeSpent,
|
||||
});
|
||||
await this.userLevelProgressRepository.save(progress);
|
||||
this.logger.log(
|
||||
`用户 ${userId} 通关 ${levelId},用时 ${dto.timeSpent} 秒`,
|
||||
);
|
||||
firstClear = true;
|
||||
timeSpent = dto.timeSpent;
|
||||
}
|
||||
|
||||
// 计算下一关
|
||||
const [allLevels, allProgress] = await Promise.all([
|
||||
this.levelRepository.findAllOrdered(),
|
||||
this.userLevelProgressRepository.findByUserId(userId),
|
||||
]);
|
||||
const completedIds = new Set(allProgress.map((p) => p.levelId));
|
||||
const nextLevels = findNextUncompletedLevels(allLevels, completedIds, 1);
|
||||
|
||||
return {
|
||||
firstClear,
|
||||
levelId,
|
||||
timeSpent,
|
||||
nextLevel: nextLevels[0] ? toNextLevelDto(nextLevels[0]) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户已通关的关卡列表,按关卡顺序(sortOrder)升序返回
|
||||
*/
|
||||
async getCompletedLevels(userId: string): Promise<CompletedLevelDto[]> {
|
||||
const progressList =
|
||||
await this.userLevelProgressRepository.findByUserId(userId);
|
||||
|
||||
if (progressList.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const levelIds = progressList.map((p) => p.levelId);
|
||||
const levels = await this.levelRepository.findByIds(levelIds);
|
||||
|
||||
// 构建 levelId -> progress 映射,便于合并 timeSpent / completedAt
|
||||
const progressMap = new Map(progressList.map((p) => [p.levelId, p]));
|
||||
|
||||
return levels
|
||||
.slice()
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((level) => {
|
||||
const progress = progressMap.get(level.id)!;
|
||||
return {
|
||||
id: level.id,
|
||||
level: level.sortOrder,
|
||||
...pickLevelImageFields(level),
|
||||
answer: level.answer,
|
||||
timeLimit: level.timeLimit,
|
||||
timeSpent: progress.timeSpent,
|
||||
completedAt: progress.completedAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
35
src/modules/level/next-level.helper.ts
Normal file
35
src/modules/level/next-level.helper.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Level } from '../wechat-game/entities/level.entity';
|
||||
import { NextLevelDto } from './dto/next-level.dto';
|
||||
import { pickLevelImageFields } from '../wechat-game/level-fields.helper';
|
||||
|
||||
/**
|
||||
* Convert a Level entity to a NextLevelDto for client consumption.
|
||||
*/
|
||||
export function toNextLevelDto(level: Level): NextLevelDto {
|
||||
return {
|
||||
id: level.id,
|
||||
level: level.sortOrder,
|
||||
...pickLevelImageFields(level),
|
||||
answer: level.answer,
|
||||
timeLimit: level.timeLimit,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Given all levels (sorted by sortOrder ASC) and the set of completed level IDs,
|
||||
* return the next `count` uncompleted levels.
|
||||
*/
|
||||
export function findNextUncompletedLevels(
|
||||
allLevelsOrdered: Level[],
|
||||
completedLevelIds: Set<string>,
|
||||
count: number,
|
||||
): Level[] {
|
||||
const result: Level[] = [];
|
||||
for (const level of allLevelsOrdered) {
|
||||
if (!completedLevelIds.has(level.id)) {
|
||||
result.push(level);
|
||||
if (result.length >= count) break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
29
src/modules/share/dto/report-level-progress.dto.ts
Normal file
29
src/modules/share/dto/report-level-progress.dto.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
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;
|
||||
}
|
||||
@@ -18,12 +18,24 @@ export class ShareLevelDto {
|
||||
@ApiProperty()
|
||||
level!: number;
|
||||
|
||||
@ApiProperty()
|
||||
imageUrl!: string;
|
||||
@ApiProperty({ description: '图片1 URL' })
|
||||
image1Url!: string;
|
||||
|
||||
@ApiProperty({ description: '图片1 文本说明', nullable: true })
|
||||
image1Description!: string | null;
|
||||
|
||||
@ApiProperty({ description: '图片2 URL' })
|
||||
image2Url!: string;
|
||||
|
||||
@ApiProperty({ description: '图片2 文本说明', nullable: true })
|
||||
image2Description!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
answer!: string;
|
||||
|
||||
@ApiProperty({ description: '谐音梗说明', nullable: true })
|
||||
punchline!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
hint1!: string | null;
|
||||
|
||||
@@ -47,3 +59,37 @@ export class JoinShareResponseDto {
|
||||
@ApiProperty({ description: '关卡列表', type: [ShareLevelDto] })
|
||||
levels!: ShareLevelDto[];
|
||||
}
|
||||
|
||||
export class CreatedShareItemDto {
|
||||
@ApiProperty({ description: '分享 ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '分享码' })
|
||||
shareCode!: string;
|
||||
|
||||
@ApiProperty({ description: '分享标题' })
|
||||
title!: string;
|
||||
|
||||
@ApiProperty({ description: '关卡数量' })
|
||||
levelCount!: number;
|
||||
|
||||
@ApiProperty({ description: '参与挑战人数' })
|
||||
participantCount!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '当前用户在该挑战中的排名,未完成全部关卡时为 null',
|
||||
nullable: true,
|
||||
})
|
||||
userRank!: number | null;
|
||||
|
||||
@ApiProperty({ description: '创建时间' })
|
||||
createdAt!: string;
|
||||
}
|
||||
|
||||
export class CreatedShareListResponseDto {
|
||||
@ApiProperty({
|
||||
description: '当前用户创建的分享挑战列表',
|
||||
type: [CreatedShareItemDto],
|
||||
})
|
||||
items!: CreatedShareItemDto[];
|
||||
}
|
||||
|
||||
48
src/modules/share/entities/share-level-progress.entity.ts
Normal file
48
src/modules/share/entities/share-level-progress.entity.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
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;
|
||||
}
|
||||
@@ -18,4 +18,11 @@ export class ShareConfigRepository {
|
||||
async findByShareCode(code: string): Promise<ShareConfig | null> {
|
||||
return this.repository.findOne({ where: { shareCode: code } });
|
||||
}
|
||||
|
||||
async findBySharerId(sharerId: string): Promise<ShareConfig[]> {
|
||||
return this.repository.find({
|
||||
where: { sharerId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ShareLevelProgress } from '../entities/share-level-progress.entity';
|
||||
|
||||
export type ShareChallengeRankingRow = {
|
||||
shareConfigId: string;
|
||||
participantId: string;
|
||||
totalTimeSpent: string;
|
||||
passedLevelCount: string;
|
||||
};
|
||||
|
||||
@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 } });
|
||||
}
|
||||
|
||||
async summarizeByShareConfigIds(
|
||||
shareConfigIds: string[],
|
||||
): Promise<ShareChallengeRankingRow[]> {
|
||||
if (shareConfigIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.repository
|
||||
.createQueryBuilder('progress')
|
||||
.innerJoin('progress.participant', 'participant')
|
||||
.select('participant.shareConfigId', 'shareConfigId')
|
||||
.addSelect('participant.participantId', 'participantId')
|
||||
.addSelect('SUM(progress.timeSpent)', 'totalTimeSpent')
|
||||
.addSelect('COUNT(DISTINCT progress.levelId)', 'passedLevelCount')
|
||||
.where('participant.shareConfigId IN (:...shareConfigIds)', {
|
||||
shareConfigIds,
|
||||
})
|
||||
.andWhere('progress.passed = :passed', { passed: true })
|
||||
.groupBy('participant.shareConfigId')
|
||||
.addGroupBy('participant.participantId')
|
||||
.getRawMany<ShareChallengeRankingRow>();
|
||||
}
|
||||
|
||||
create(data: Partial<ShareLevelProgress>): ShareLevelProgress {
|
||||
return this.repository.create(data);
|
||||
}
|
||||
|
||||
async save(progress: ShareLevelProgress): Promise<ShareLevelProgress> {
|
||||
return this.repository.save(progress);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,11 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ShareParticipant } from '../entities/share-participant.entity';
|
||||
|
||||
type ShareParticipantCountRow = {
|
||||
shareConfigId: string;
|
||||
participantCount: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ShareParticipantRepository {
|
||||
constructor(
|
||||
@@ -27,4 +32,41 @@ export class ShareParticipantRepository {
|
||||
async countByShareConfigId(shareConfigId: string): Promise<number> {
|
||||
return this.repository.count({ where: { shareConfigId } });
|
||||
}
|
||||
|
||||
async countByShareConfigIds(
|
||||
shareConfigIds: string[],
|
||||
): Promise<Map<string, number>> {
|
||||
if (shareConfigIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const rows = await this.repository
|
||||
.createQueryBuilder('participant')
|
||||
.select('participant.shareConfigId', 'shareConfigId')
|
||||
.addSelect('COUNT(participant.id)', 'participantCount')
|
||||
.where('participant.shareConfigId IN (:...shareConfigIds)', {
|
||||
shareConfigIds,
|
||||
})
|
||||
.groupBy('participant.shareConfigId')
|
||||
.getRawMany<ShareParticipantCountRow>();
|
||||
|
||||
return new Map(
|
||||
rows.map((row) => [row.shareConfigId, Number(row.participantCount)]),
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
149
src/modules/share/share.controller.spec.ts
Normal file
149
src/modules/share/share.controller.spec.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
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(),
|
||||
getCreatedShares: 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('getCreatedShares', () => {
|
||||
it('should return success response with created share list', async () => {
|
||||
const createdSharesResponse = {
|
||||
items: [
|
||||
{
|
||||
id: 'share-uuid-1',
|
||||
shareCode: 'ABCD1234',
|
||||
title: '我的挑战',
|
||||
levelCount: 6,
|
||||
participantCount: 8,
|
||||
userRank: 2,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
mockShareService.getCreatedShares.mockResolvedValue(
|
||||
createdSharesResponse,
|
||||
);
|
||||
|
||||
const result = await controller.getCreatedShares(mockUser);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual(createdSharesResponse);
|
||||
expect(mockShareService.getCreatedShares).toHaveBeenCalledWith(
|
||||
'user-uuid-1',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
@@ -9,18 +9,36 @@ import { ShareService } from './share.service';
|
||||
import { CreateShareDto } from './dto/create-share.dto';
|
||||
import {
|
||||
CreateShareResponseDto,
|
||||
CreatedShareListResponseDto,
|
||||
JoinShareResponseDto,
|
||||
} from './dto/share-response.dto';
|
||||
import { ApiResponseDto } from '../../common/dto/api-response.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import type { JwtPayload } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { ReportLevelProgressDto } from './dto/report-level-progress.dto';
|
||||
import { ReportLevelProgressResponseDto } from './dto/share-level-progress-response.dto';
|
||||
|
||||
@ApiTags('分享挑战')
|
||||
@Controller('v1/share')
|
||||
export class ShareController {
|
||||
constructor(private readonly shareService: ShareService) {}
|
||||
|
||||
@Get('created')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '获取我创建的分享挑战',
|
||||
description: '返回当前用户创建过的分享挑战,并统计参与人数和用户排名',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '成功' })
|
||||
async getCreatedShares(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<ApiResponseDto<CreatedShareListResponseDto>> {
|
||||
const data = await this.shareService.getCreatedShares(user.sub);
|
||||
return ApiResponseDto.success(data);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@@ -54,4 +72,21 @@ export class ShareController {
|
||||
const data = await this.shareService.joinShare(user.sub, code);
|
||||
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 { ShareConfig } from './entities/share-config.entity';
|
||||
import { ShareParticipant } from './entities/share-participant.entity';
|
||||
import { ShareLevelProgress } from './entities/share-level-progress.entity';
|
||||
import { ShareConfigRepository } from './repositories/share-config.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 { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ShareConfig, ShareParticipant]),
|
||||
TypeOrmModule.forFeature([
|
||||
ShareConfig,
|
||||
ShareParticipant,
|
||||
ShareLevelProgress,
|
||||
]),
|
||||
WechatGameModule,
|
||||
AuthModule,
|
||||
],
|
||||
controllers: [ShareController],
|
||||
providers: [ShareService, ShareConfigRepository, ShareParticipantRepository],
|
||||
providers: [
|
||||
ShareService,
|
||||
ShareConfigRepository,
|
||||
ShareParticipantRepository,
|
||||
ShareLevelProgressRepository,
|
||||
],
|
||||
})
|
||||
export class ShareModule {}
|
||||
|
||||
542
src/modules/share/share.service.spec.ts
Normal file
542
src/modules/share/share.service.spec.ts
Normal file
@@ -0,0 +1,542 @@
|
||||
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';
|
||||
import { User } from '../auth/entities/user.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}`,
|
||||
image1Url: `https://example.com/meme${i + 1}_1.jpg`,
|
||||
image1Description: null,
|
||||
image2Url: `https://example.com/meme${i + 1}_2.jpg`,
|
||||
image2Description: null,
|
||||
answer: `答案${i + 1}`,
|
||||
punchline: null,
|
||||
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 User,
|
||||
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 ShareConfig,
|
||||
participant: {} as User,
|
||||
createdAt: new Date('2026-01-01'),
|
||||
};
|
||||
|
||||
const mockShareConfigRepository = {
|
||||
create: jest.fn(),
|
||||
findByShareCode: jest.fn(),
|
||||
findBySharerId: jest.fn(),
|
||||
};
|
||||
|
||||
const mockShareParticipantRepository = {
|
||||
addParticipant: jest.fn(),
|
||||
countByShareConfigId: jest.fn(),
|
||||
countByShareConfigIds: jest.fn(),
|
||||
findByShareConfigAndParticipant: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
};
|
||||
|
||||
const mockShareLevelProgressRepository = {
|
||||
findByParticipantAndLevel: jest.fn(),
|
||||
summarizeByShareConfigIds: 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('getCreatedShares', () => {
|
||||
it('should return empty list when user has not created any share', async () => {
|
||||
mockShareConfigRepository.findBySharerId.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getCreatedShares('user-uuid-1');
|
||||
|
||||
expect(result).toEqual({ items: [] });
|
||||
expect(
|
||||
mockShareParticipantRepository.countByShareConfigIds,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mockShareLevelProgressRepository.summarizeByShareConfigIds,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return created shares with participant count and user rank', async () => {
|
||||
const otherShareConfig: ShareConfig = {
|
||||
...mockShareConfig,
|
||||
id: 'share-uuid-2',
|
||||
shareCode: 'WXYZ5678',
|
||||
title: '第二个挑战',
|
||||
createdAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
};
|
||||
|
||||
mockShareConfigRepository.findBySharerId.mockResolvedValue([
|
||||
otherShareConfig,
|
||||
mockShareConfig,
|
||||
]);
|
||||
mockShareParticipantRepository.countByShareConfigIds.mockResolvedValue(
|
||||
new Map([
|
||||
['share-uuid-1', 3],
|
||||
['share-uuid-2', 1],
|
||||
]),
|
||||
);
|
||||
mockShareLevelProgressRepository.summarizeByShareConfigIds.mockResolvedValue(
|
||||
[
|
||||
{
|
||||
shareConfigId: 'share-uuid-1',
|
||||
participantId: 'user-uuid-1',
|
||||
totalTimeSpent: '120',
|
||||
passedLevelCount: '6',
|
||||
},
|
||||
{
|
||||
shareConfigId: 'share-uuid-1',
|
||||
participantId: 'user-uuid-2',
|
||||
totalTimeSpent: '100',
|
||||
passedLevelCount: '6',
|
||||
},
|
||||
{
|
||||
shareConfigId: 'share-uuid-1',
|
||||
participantId: 'user-uuid-3',
|
||||
totalTimeSpent: '200',
|
||||
passedLevelCount: '5',
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const result = await service.getCreatedShares('user-uuid-1');
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: 'share-uuid-2',
|
||||
shareCode: 'WXYZ5678',
|
||||
title: '第二个挑战',
|
||||
levelCount: 6,
|
||||
participantCount: 1,
|
||||
userRank: null,
|
||||
createdAt: '2026-01-02T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'share-uuid-1',
|
||||
shareCode: 'ABCD1234',
|
||||
title: '我的挑战',
|
||||
levelCount: 6,
|
||||
participantCount: 3,
|
||||
userRank: 2,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should use participantId as deterministic tie breaker for rank', async () => {
|
||||
mockShareConfigRepository.findBySharerId.mockResolvedValue([
|
||||
mockShareConfig,
|
||||
]);
|
||||
mockShareParticipantRepository.countByShareConfigIds.mockResolvedValue(
|
||||
new Map([['share-uuid-1', 2]]),
|
||||
);
|
||||
mockShareLevelProgressRepository.summarizeByShareConfigIds.mockResolvedValue(
|
||||
[
|
||||
{
|
||||
shareConfigId: 'share-uuid-1',
|
||||
participantId: 'user-uuid-2',
|
||||
totalTimeSpent: '120',
|
||||
passedLevelCount: '6',
|
||||
},
|
||||
{
|
||||
shareConfigId: 'share-uuid-1',
|
||||
participantId: 'user-uuid-1',
|
||||
totalTimeSpent: '120',
|
||||
passedLevelCount: '6',
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const result = await service.getCreatedShares('user-uuid-1');
|
||||
|
||||
expect(result.items[0].userRank).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
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,18 @@ import {
|
||||
import { nanoid } from 'nanoid';
|
||||
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 { pickLevelImageFields } from '../wechat-game/level-fields.helper';
|
||||
import { CreateShareDto } from './dto/create-share.dto';
|
||||
import { ReportLevelProgressDto } from './dto/report-level-progress.dto';
|
||||
import {
|
||||
CreateShareResponseDto,
|
||||
CreatedShareListResponseDto,
|
||||
JoinShareResponseDto,
|
||||
ShareLevelDto,
|
||||
} from './dto/share-response.dto';
|
||||
import { ReportLevelProgressResponseDto } from './dto/share-level-progress-response.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ShareService {
|
||||
@@ -20,6 +25,7 @@ export class ShareService {
|
||||
private readonly shareConfigRepository: ShareConfigRepository,
|
||||
private readonly shareParticipantRepository: ShareParticipantRepository,
|
||||
private readonly levelRepository: LevelRepository,
|
||||
private readonly shareLevelProgressRepository: ShareLevelProgressRepository,
|
||||
) {}
|
||||
|
||||
async createShare(
|
||||
@@ -78,7 +84,7 @@ export class ShareService {
|
||||
await this.shareParticipantRepository.addParticipant(config.id, userId);
|
||||
}
|
||||
|
||||
// 单次查询获取所有关卡,再按 levelIds 顺序排列
|
||||
// Single query, then reorder to match levelIds sequence
|
||||
const allLevels = await this.levelRepository.findByIds(config.levelIds);
|
||||
const levelMap = new Map(allLevels.map((l) => [l.id, l]));
|
||||
|
||||
@@ -90,11 +96,8 @@ export class ShareService {
|
||||
return {
|
||||
id: level.id,
|
||||
level: index + 1,
|
||||
imageUrl: level.imageUrl,
|
||||
...pickLevelImageFields(level),
|
||||
answer: level.answer,
|
||||
hint1: level.hint1,
|
||||
hint2: level.hint2,
|
||||
hint3: level.hint3,
|
||||
sortOrder: level.sortOrder,
|
||||
};
|
||||
});
|
||||
@@ -105,4 +108,144 @@ export class ShareService {
|
||||
levels,
|
||||
};
|
||||
}
|
||||
|
||||
async getCreatedShares(userId: string): Promise<CreatedShareListResponseDto> {
|
||||
const configs = await this.shareConfigRepository.findBySharerId(userId);
|
||||
if (configs.length === 0) {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
const shareConfigIds = configs.map((config) => config.id);
|
||||
const [participantCountMap, rankingRows] = await Promise.all([
|
||||
this.shareParticipantRepository.countByShareConfigIds(shareConfigIds),
|
||||
this.shareLevelProgressRepository.summarizeByShareConfigIds(
|
||||
shareConfigIds,
|
||||
),
|
||||
]);
|
||||
|
||||
const rankingsByShareConfigId = new Map<string, string[]>();
|
||||
for (const config of configs) {
|
||||
const completedRankings = rankingRows
|
||||
.filter(
|
||||
(row) =>
|
||||
row.shareConfigId === config.id &&
|
||||
Number(row.passedLevelCount) === config.levelIds.length,
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const totalTimeDiff =
|
||||
Number(a.totalTimeSpent) - Number(b.totalTimeSpent);
|
||||
if (totalTimeDiff !== 0) {
|
||||
return totalTimeDiff;
|
||||
}
|
||||
|
||||
return a.participantId.localeCompare(b.participantId);
|
||||
})
|
||||
.map((row) => row.participantId);
|
||||
|
||||
rankingsByShareConfigId.set(config.id, completedRankings);
|
||||
}
|
||||
|
||||
return {
|
||||
items: configs.map((config) => {
|
||||
const rankings = rankingsByShareConfigId.get(config.id) ?? [];
|
||||
const rankingIndex = rankings.findIndex(
|
||||
(participantId) => participantId === userId,
|
||||
);
|
||||
|
||||
return {
|
||||
id: config.id,
|
||||
shareCode: config.shareCode,
|
||||
title: config.title,
|
||||
levelCount: config.levelIds.length,
|
||||
participantCount: participantCountMap.get(config.id) ?? 0,
|
||||
userRank: rankingIndex >= 0 ? rankingIndex + 1 : null,
|
||||
createdAt: config.createdAt.toISOString(),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
45
src/modules/user/dto/user-profile.dto.ts
Normal file
45
src/modules/user/dto/user-profile.dto.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { NextLevelDto } from '../../level/dto/next-level.dto';
|
||||
|
||||
export class StaminaInfoDto {
|
||||
@ApiProperty({ description: '当前体力值' })
|
||||
current!: number;
|
||||
|
||||
@ApiProperty({ description: '体力值上限' })
|
||||
max!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '下次恢复时间(ISO 字符串),满体力时为 null',
|
||||
nullable: true,
|
||||
})
|
||||
nextRecoverAt!: string | null;
|
||||
}
|
||||
|
||||
export class UserProfileResponseDto {
|
||||
@ApiProperty({ description: '用户 ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '用户昵称', nullable: true })
|
||||
nickname!: string | null;
|
||||
|
||||
@ApiProperty({ description: '体力信息' })
|
||||
stamina!: StaminaInfoDto;
|
||||
}
|
||||
|
||||
export class GameDataResponseDto {
|
||||
@ApiProperty({ description: '用户信息' })
|
||||
user!: {
|
||||
id: string;
|
||||
stamina: StaminaInfoDto;
|
||||
};
|
||||
|
||||
@ApiProperty({ description: '已通关的关卡数量' })
|
||||
completedLevelCount!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '下一个待通关的关卡(全部通关时为 null)',
|
||||
nullable: true,
|
||||
type: NextLevelDto,
|
||||
})
|
||||
nextLevel!: NextLevelDto | null;
|
||||
}
|
||||
52
src/modules/user/user.controller.ts
Normal file
52
src/modules/user/user.controller.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { UserService } from './user.service';
|
||||
import {
|
||||
UserProfileResponseDto,
|
||||
GameDataResponseDto,
|
||||
} from './dto/user-profile.dto';
|
||||
import { ApiResponseDto } from '../../common/dto/api-response.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import type { JwtPayload } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@ApiTags('用户')
|
||||
@Controller('v1/user')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class UserController {
|
||||
constructor(private readonly userService: UserService) {}
|
||||
|
||||
@Get('profile')
|
||||
@ApiOperation({
|
||||
summary: '获取用户资料',
|
||||
description: '获取当前用户的资料信息,包括计算后的体力值与下次恢复时间',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '成功' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
async getProfile(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<ApiResponseDto<UserProfileResponseDto>> {
|
||||
const data = await this.userService.getUserProfile(user.sub);
|
||||
return ApiResponseDto.success(data);
|
||||
}
|
||||
|
||||
@Get('game-data')
|
||||
@ApiOperation({
|
||||
summary: '获取游戏数据',
|
||||
description: '获取用户体力值和通关进度(Loading 页面使用)',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '成功' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
async getGameData(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<ApiResponseDto<GameDataResponseDto>> {
|
||||
const data = await this.userService.getGameData(user.sub);
|
||||
return ApiResponseDto.success(data);
|
||||
}
|
||||
}
|
||||
13
src/modules/user/user.module.ts
Normal file
13
src/modules/user/user.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UserController } from './user.controller';
|
||||
import { UserService } from './user.service';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { WechatGameModule } from '../wechat-game/wechat-game.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, WechatGameModule],
|
||||
controllers: [UserController],
|
||||
providers: [UserService],
|
||||
exports: [UserService],
|
||||
})
|
||||
export class UserModule {}
|
||||
155
src/modules/user/user.service.ts
Normal file
155
src/modules/user/user.service.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
Injectable,
|
||||
BadRequestException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { UserRepository } from '../auth/repositories/user.repository';
|
||||
import { UserLevelProgressRepository } from '../auth/repositories/user-level-progress.repository';
|
||||
import { LevelRepository } from '../wechat-game/repositories/level.repository';
|
||||
import { User } from '../auth/entities/user.entity';
|
||||
import {
|
||||
StaminaInfoDto,
|
||||
UserProfileResponseDto,
|
||||
GameDataResponseDto,
|
||||
} from './dto/user-profile.dto';
|
||||
import {
|
||||
MAX_STAMINA,
|
||||
RECOVER_INTERVAL_MS,
|
||||
} from '../../common/constants/game.constants';
|
||||
import {
|
||||
findNextUncompletedLevels,
|
||||
toNextLevelDto,
|
||||
} from '../level/next-level.helper';
|
||||
|
||||
export { MAX_STAMINA, RECOVER_INTERVAL_MS };
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly userLevelProgressRepository: UserLevelProgressRepository,
|
||||
private readonly levelRepository: LevelRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 根据数据库中的 stamina + staminaUpdatedAt,计算当前实际体力值
|
||||
*/
|
||||
computeStamina(user: User): StaminaInfoDto {
|
||||
if (user.stamina >= MAX_STAMINA) {
|
||||
return { current: MAX_STAMINA, max: MAX_STAMINA, nextRecoverAt: null };
|
||||
}
|
||||
|
||||
if (!user.staminaUpdatedAt) {
|
||||
return {
|
||||
current: user.stamina,
|
||||
max: MAX_STAMINA,
|
||||
nextRecoverAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - user.staminaUpdatedAt.getTime();
|
||||
const recovered = Math.floor(elapsed / RECOVER_INTERVAL_MS);
|
||||
const currentStamina = Math.min(MAX_STAMINA, user.stamina + recovered);
|
||||
|
||||
let nextRecoverAt: string | null = null;
|
||||
if (currentStamina < MAX_STAMINA) {
|
||||
const remainder = elapsed % RECOVER_INTERVAL_MS;
|
||||
nextRecoverAt = new Date(
|
||||
Date.now() + RECOVER_INTERVAL_MS - remainder,
|
||||
).toISOString();
|
||||
}
|
||||
|
||||
return { current: currentStamina, max: MAX_STAMINA, nextRecoverAt };
|
||||
}
|
||||
|
||||
private static readonly MAX_STAMINA_RETRIES = 3;
|
||||
|
||||
/**
|
||||
* 消耗 1 点体力,返回消耗后的体力信息。
|
||||
* 使用原子更新防止并发竞态条件(双击进入关卡场景)。
|
||||
*/
|
||||
async consumeStamina(
|
||||
userId: string,
|
||||
retries = 0,
|
||||
): Promise<{ user: User; stamina: StaminaInfoDto }> {
|
||||
const user = await this.findUserOrThrow(userId);
|
||||
const staminaInfo = this.computeStamina(user);
|
||||
|
||||
if (staminaInfo.current <= 0) {
|
||||
throw new BadRequestException('体力不足');
|
||||
}
|
||||
|
||||
const newStamina = staminaInfo.current - 1;
|
||||
const now = new Date();
|
||||
|
||||
const result = await this.userRepository.updateStaminaAtomic(
|
||||
userId,
|
||||
user.stamina,
|
||||
newStamina,
|
||||
now,
|
||||
);
|
||||
|
||||
if (result.affected === 0) {
|
||||
if (retries >= UserService.MAX_STAMINA_RETRIES) {
|
||||
throw new BadRequestException('操作冲突,请重试');
|
||||
}
|
||||
return this.consumeStamina(userId, retries + 1);
|
||||
}
|
||||
|
||||
const updatedStamina: StaminaInfoDto =
|
||||
newStamina >= MAX_STAMINA
|
||||
? { current: MAX_STAMINA, max: MAX_STAMINA, nextRecoverAt: null }
|
||||
: {
|
||||
current: newStamina,
|
||||
max: MAX_STAMINA,
|
||||
nextRecoverAt: new Date(
|
||||
now.getTime() + RECOVER_INTERVAL_MS,
|
||||
).toISOString(),
|
||||
};
|
||||
|
||||
return {
|
||||
user: Object.assign(Object.create(Object.getPrototypeOf(user)), user, {
|
||||
stamina: newStamina,
|
||||
staminaUpdatedAt: now,
|
||||
}),
|
||||
stamina: updatedStamina,
|
||||
};
|
||||
}
|
||||
|
||||
async getUserProfile(userId: string): Promise<UserProfileResponseDto> {
|
||||
const user = await this.findUserOrThrow(userId);
|
||||
const stamina = this.computeStamina(user);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
nickname: user.nickname,
|
||||
stamina,
|
||||
};
|
||||
}
|
||||
|
||||
async getGameData(userId: string): Promise<GameDataResponseDto> {
|
||||
const [user, progressList, allLevels] = await Promise.all([
|
||||
this.findUserOrThrow(userId),
|
||||
this.userLevelProgressRepository.findByUserId(userId),
|
||||
this.levelRepository.findAllOrdered(),
|
||||
]);
|
||||
|
||||
const stamina = this.computeStamina(user);
|
||||
const completedIds = new Set(progressList.map((p) => p.levelId));
|
||||
const nextLevels = findNextUncompletedLevels(allLevels, completedIds, 1);
|
||||
|
||||
return {
|
||||
user: { id: user.id, stamina },
|
||||
completedLevelCount: completedIds.size,
|
||||
nextLevel: nextLevels[0] ? toNextLevelDto(nextLevels[0]) : null,
|
||||
};
|
||||
}
|
||||
|
||||
async findUserOrThrow(userId: string): Promise<User> {
|
||||
const user = await this.userRepository.findById(userId);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('用户不存在');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,24 @@ export class LevelResponseDto {
|
||||
@ApiProperty({ description: '关卡ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '图片URL' })
|
||||
imageUrl!: string;
|
||||
@ApiProperty({ description: '图片1 URL' })
|
||||
image1Url!: string;
|
||||
|
||||
@ApiProperty({ description: '图片1 文本说明', nullable: true })
|
||||
image1Description!: string | null;
|
||||
|
||||
@ApiProperty({ description: '图片2 URL' })
|
||||
image2Url!: string;
|
||||
|
||||
@ApiProperty({ description: '图片2 文本说明', nullable: true })
|
||||
image2Description!: string | null;
|
||||
|
||||
@ApiProperty({ description: '答案' })
|
||||
answer!: string;
|
||||
|
||||
@ApiProperty({ description: '谐音梗说明', nullable: true })
|
||||
punchline!: string | null;
|
||||
|
||||
@ApiProperty({ description: '提示1', nullable: true })
|
||||
hint1!: string | null;
|
||||
|
||||
|
||||
@@ -11,12 +11,34 @@ export class Level {
|
||||
@PrimaryColumn({ type: 'varchar', length: 191 })
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 191, name: 'image_url' })
|
||||
imageUrl!: string;
|
||||
@Column({ type: 'varchar', length: 500, name: 'image1_url' })
|
||||
image1Url!: string;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 500,
|
||||
name: 'image1_description',
|
||||
nullable: true,
|
||||
})
|
||||
image1Description!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 500, name: 'image2_url', default: '' })
|
||||
image2Url!: string;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 500,
|
||||
name: 'image2_description',
|
||||
nullable: true,
|
||||
})
|
||||
image2Description!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 191 })
|
||||
answer!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 500, nullable: true })
|
||||
punchline!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 191, nullable: true })
|
||||
hint1!: string | null;
|
||||
|
||||
@@ -29,6 +51,9 @@ export class Level {
|
||||
@Column({ type: 'int', name: 'sort_order', default: 0 })
|
||||
sortOrder!: number;
|
||||
|
||||
@Column({ type: 'int', name: 'time_limit', nullable: true, default: null })
|
||||
timeLimit!: number | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
|
||||
50
src/modules/wechat-game/level-fields.helper.ts
Normal file
50
src/modules/wechat-game/level-fields.helper.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Level } from './entities/level.entity';
|
||||
|
||||
/** Common image + content fields shared across all level-related DTOs */
|
||||
export interface LevelImageFields {
|
||||
image1Url: string;
|
||||
image1Description: string | null;
|
||||
image2Url: string;
|
||||
image2Description: string | null;
|
||||
punchline: string | null;
|
||||
hint1: string | null;
|
||||
hint2: string | null;
|
||||
hint3: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the common image/content fields from a Level entity.
|
||||
* Use spread to merge into any level DTO.
|
||||
*/
|
||||
export function pickLevelImageFields(level: Level): LevelImageFields {
|
||||
return {
|
||||
image1Url: level.image1Url,
|
||||
image1Description: level.image1Description,
|
||||
image2Url: level.image2Url,
|
||||
image2Description: level.image2Description,
|
||||
punchline: level.punchline,
|
||||
hint1: level.hint1,
|
||||
hint2: level.hint2,
|
||||
hint3: level.hint3,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick image fields with answer/hints masked for non-completed levels.
|
||||
* hint1 is always shown (first clue is the default hint shown to players).
|
||||
*/
|
||||
export function pickLevelImageFieldsMasked(
|
||||
level: Level,
|
||||
completed: boolean,
|
||||
): LevelImageFields {
|
||||
return {
|
||||
image1Url: level.image1Url,
|
||||
image1Description: level.image1Description,
|
||||
image2Url: level.image2Url,
|
||||
image2Description: level.image2Description,
|
||||
punchline: level.punchline,
|
||||
hint1: level.hint1,
|
||||
hint2: completed ? level.hint2 : null,
|
||||
hint3: completed ? level.hint3 : null,
|
||||
};
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { WechatGameService } from './wechat-game.service';
|
||||
import {
|
||||
GameConfigResponseDto,
|
||||
GameConfigListResponseDto,
|
||||
} from './dto/game-config-response.dto';
|
||||
import {
|
||||
LevelResponseDto,
|
||||
LevelListResponseDto,
|
||||
} from './dto/level-response.dto';
|
||||
import { ApiResponseDto } from '../../common/dto/api-response.dto';
|
||||
|
||||
@ApiTags('微信小游戏')
|
||||
@Controller('v1/wechat-game')
|
||||
export class WechatGameController {
|
||||
constructor(private readonly wechatGameService: WechatGameService) {}
|
||||
|
||||
@Get('configs')
|
||||
@ApiOperation({
|
||||
summary: '获取所有游戏配置',
|
||||
description: '获取所有激活的游戏配置列表',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '成功获取配置列表' })
|
||||
async getAllConfigs(): Promise<ApiResponseDto<GameConfigListResponseDto>> {
|
||||
const data = await this.wechatGameService.getAllConfigs();
|
||||
return ApiResponseDto.success(data);
|
||||
}
|
||||
|
||||
@Get('configs/:key')
|
||||
@ApiOperation({
|
||||
summary: '根据key获取配置',
|
||||
description: '根据配置键名获取单个游戏配置',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '成功获取配置' })
|
||||
@ApiResponse({ status: 404, description: '配置不存在' })
|
||||
async getConfigByKey(
|
||||
@Param('key') key: string,
|
||||
): Promise<ApiResponseDto<GameConfigResponseDto>> {
|
||||
const data = await this.wechatGameService.getConfigByKey(key);
|
||||
return ApiResponseDto.success(data);
|
||||
}
|
||||
|
||||
@Get('levels')
|
||||
@ApiOperation({
|
||||
summary: '获取所有关卡',
|
||||
description: '获取所有关卡列表,按sort_order排序',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '成功获取关卡列表' })
|
||||
async getAllLevels(): Promise<ApiResponseDto<LevelListResponseDto>> {
|
||||
const data = await this.wechatGameService.getAllLevels();
|
||||
return ApiResponseDto.success(data);
|
||||
}
|
||||
|
||||
@Get('levels/:id')
|
||||
@ApiOperation({
|
||||
summary: '根据ID获取关卡',
|
||||
description: '根据关卡ID获取单个关卡信息',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '成功获取关卡' })
|
||||
@ApiResponse({ status: 404, description: '关卡不存在' })
|
||||
async getLevelById(
|
||||
@Param('id') id: string,
|
||||
): Promise<ApiResponseDto<LevelResponseDto>> {
|
||||
const data = await this.wechatGameService.getLevelById(id);
|
||||
return ApiResponseDto.success(data);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { WechatGameController } from './wechat-game.controller';
|
||||
import { WechatGameService } from './wechat-game.service';
|
||||
import { GameConfig } from './entities/game-config.entity';
|
||||
import { Level } from './entities/level.entity';
|
||||
import { GameConfigRepository } from './repositories/game-config.repository';
|
||||
import { LevelRepository } from './repositories/level.repository';
|
||||
|
||||
/**
|
||||
* 保留此模块仅用于导出 entity/repository,供其他模块使用。
|
||||
* 业务逻辑已迁移至 GameConfigModule、LevelModule。
|
||||
*/
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([GameConfig, Level])],
|
||||
controllers: [WechatGameController],
|
||||
providers: [WechatGameService, GameConfigRepository, LevelRepository],
|
||||
exports: [WechatGameService, LevelRepository],
|
||||
providers: [GameConfigRepository, LevelRepository],
|
||||
exports: [LevelRepository, GameConfigRepository],
|
||||
})
|
||||
export class WechatGameModule {}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { WechatGameService } from './wechat-game.service';
|
||||
import { GameConfigRepository } from './repositories/game-config.repository';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { GameConfig } from './entities/game-config.entity';
|
||||
|
||||
describe('WechatGameService', () => {
|
||||
let service: WechatGameService;
|
||||
let repository: GameConfigRepository;
|
||||
|
||||
const mockGameConfig: GameConfig = {
|
||||
id: 'test-uuid',
|
||||
configKey: 'game_speed',
|
||||
configValue: '1.5',
|
||||
description: 'Game speed multiplier',
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockRepository = {
|
||||
findActiveConfigs: jest.fn(),
|
||||
findByKey: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
WechatGameService,
|
||||
{
|
||||
provide: GameConfigRepository,
|
||||
useValue: mockRepository,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<WechatGameService>(WechatGameService);
|
||||
repository = module.get<GameConfigRepository>(GameConfigRepository);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getAllConfigs', () => {
|
||||
it('should return all active configs', async () => {
|
||||
mockRepository.findActiveConfigs.mockResolvedValue([mockGameConfig]);
|
||||
|
||||
const result = await service.getAllConfigs();
|
||||
|
||||
expect(result.configs).toHaveLength(1);
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.configs[0].configKey).toBe('game_speed');
|
||||
expect(mockRepository.findActiveConfigs).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return empty array when no configs found', async () => {
|
||||
mockRepository.findActiveConfigs.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getAllConfigs();
|
||||
|
||||
expect(result.configs).toHaveLength(0);
|
||||
expect(result.total).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConfigByKey', () => {
|
||||
it('should return config by key', async () => {
|
||||
mockRepository.findByKey.mockResolvedValue(mockGameConfig);
|
||||
|
||||
const result = await service.getConfigByKey('game_speed');
|
||||
|
||||
expect(result.configKey).toBe('game_speed');
|
||||
expect(result.configValue).toBe('1.5');
|
||||
expect(mockRepository.findByKey).toHaveBeenCalledWith('game_speed');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when config not found', async () => {
|
||||
mockRepository.findByKey.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getConfigByKey('nonexistent')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { GameConfigRepository } from './repositories/game-config.repository';
|
||||
import { LevelRepository } from './repositories/level.repository';
|
||||
import {
|
||||
GameConfigResponseDto,
|
||||
GameConfigListResponseDto,
|
||||
} from './dto/game-config-response.dto';
|
||||
import {
|
||||
LevelResponseDto,
|
||||
LevelListResponseDto,
|
||||
} from './dto/level-response.dto';
|
||||
|
||||
@Injectable()
|
||||
export class WechatGameService {
|
||||
constructor(
|
||||
private readonly gameConfigRepository: GameConfigRepository,
|
||||
private readonly levelRepository: LevelRepository,
|
||||
) {}
|
||||
|
||||
async getAllConfigs(): Promise<GameConfigListResponseDto> {
|
||||
const configs = await this.gameConfigRepository.findActiveConfigs();
|
||||
|
||||
return {
|
||||
configs: configs.map((config) => this.toResponseDto(config)),
|
||||
total: configs.length,
|
||||
};
|
||||
}
|
||||
|
||||
async getConfigByKey(key: string): Promise<GameConfigResponseDto> {
|
||||
const config = await this.gameConfigRepository.findByKey(key);
|
||||
|
||||
if (!config) {
|
||||
throw new NotFoundException(`Game config with key "${key}" not found`);
|
||||
}
|
||||
|
||||
return this.toResponseDto(config);
|
||||
}
|
||||
|
||||
async getAllLevels(): Promise<LevelListResponseDto> {
|
||||
const levels = await this.levelRepository.findAllOrdered();
|
||||
|
||||
return {
|
||||
levels: levels.map((level, index) =>
|
||||
this.toLevelResponseDto(level, index + 1),
|
||||
),
|
||||
total: levels.length,
|
||||
};
|
||||
}
|
||||
|
||||
async getLevelById(id: string): Promise<LevelResponseDto> {
|
||||
const levels = await this.levelRepository.findAllOrdered();
|
||||
const levelIndex = levels.findIndex((l) => l.id === id);
|
||||
|
||||
if (levelIndex === -1) {
|
||||
throw new NotFoundException(`Level with id "${id}" not found`);
|
||||
}
|
||||
|
||||
return this.toLevelResponseDto(levels[levelIndex], levelIndex + 1);
|
||||
}
|
||||
|
||||
private toResponseDto(
|
||||
config: import('./entities/game-config.entity').GameConfig,
|
||||
): GameConfigResponseDto {
|
||||
return {
|
||||
id: config.id,
|
||||
configKey: config.configKey,
|
||||
configValue: config.configValue,
|
||||
description: config.description,
|
||||
isActive: config.isActive,
|
||||
createdAt: config.createdAt,
|
||||
updatedAt: config.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private toLevelResponseDto(
|
||||
level: import('./entities/level.entity').Level,
|
||||
levelNumber: number,
|
||||
): LevelResponseDto {
|
||||
return {
|
||||
level: levelNumber,
|
||||
id: level.id,
|
||||
imageUrl: level.imageUrl,
|
||||
answer: level.answer,
|
||||
hint1: level.hint1,
|
||||
hint2: level.hint2,
|
||||
hint3: level.hint3,
|
||||
sortOrder: level.sortOrder,
|
||||
createdAt: level.createdAt,
|
||||
updatedAt: level.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user