6 Commits

Author SHA1 Message Date
richarjiang
1f8db6c473 feat: 支持通关接口 2026-04-30 16:20:22 +08:00
richarjiang
25d196263b feat: 重构关卡接口 2026-04-26 17:08:27 +08:00
richarjiang
e5d6c3a674 fix(level): 默认返回 punchline 和 hint1 线索提示
未通关用户现在可以查看谐音梗说明和第一个线索提示,
hint2/hint3 仍需通关后才显示。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 21:37:13 +08:00
richarjiang
e6079e4345 feat: 支持新的关卡数据结构 2026-04-19 13:27:10 +08:00
richarjiang
1d6cd0cdc0 feat: 支持获取我创建的分享挑战列表以及详情数据 2026-04-13 09:08:11 +08:00
richarjiang
fe2c13258e refactor: 拆分核心玩法模块并优化代码质量
将 WechatGame 单体模块拆分为独立的 User、Level、GameConfig 模块,
新增体力值系统、关卡闯关流程,并修复多项代码质量问题:
- 体力不足错误码从 401 修正为 400
- enterLevel 改用 findById 替代全表扫描
- consumeStamina 增加原子更新防止并发竞态
- 并行化独立数据库查询 (Promise.all)
- 移除 WechatGameService/Controller 死代码
2026-04-10 09:07:50 +08:00
61 changed files with 3324 additions and 3820 deletions

6
.claude/settings.json Normal file
View File

@@ -0,0 +1,6 @@
{
"enabledPlugins": {
"code-review@claude-plugins-official": true,
"claude-md-management@claude-plugins-official": true
}
}

42
AGENTS.md Normal file
View 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-caseDTO 文件名要具备明确语义,例如 `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因此执行前需要先检查其中的服务器相关配置。

View File

@@ -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
View 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

View File

@@ -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*

File diff suppressed because it is too large Load Diff

View 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 需要适配 050 的显示范围
- 恢复速率不变,满体力恢复时间从 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 适配 050 范围(进度条、数字显示等)
- [ ] 更新 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` | 数值变更 |

View File

@@ -6,10 +6,8 @@
| 模块 | 文档文件 | 说明 | 状态 |
|------|----------|------|------|
| 用户认证 | [auth-api.md](./auth-api.md) | 微信登录、JWT Token | 待编写 |
| 分享挑战 | [share-challenge-api.md](./share-challenge-api.md) | 创建分享、加入挑战、进度上报 | 已完成 |
| 游戏关卡 | [game-api.md](./game-api.md) | 关卡数据、答案验证 | 待编写 |
| 用户资产 | [user-assets-api.md](./user-assets-api.md) | 积分获取与消耗 | 待编写 |
| 核心玩法 | [game-api.md](./game-api.md) | 认证、体力值、关卡闯关、游戏配置 | ✅ 已完成 |
| 分享挑战 | [share-challenge-api.md](./share-challenge-api.md) | 创建分享、加入挑战、进度上报 | 已完成 |
| 排行榜 | [leaderboard-api.md](./leaderboard-api.md) | 排名、分数上报 | 预留 |
## 文档维护规则
@@ -37,9 +35,9 @@ Authorization: Bearer <token>
```json
{
"success": true,
"data": { ... },
"data": { "..." : "..." },
"message": null,
"timestamp": "2026-04-08T12:00:00.000Z"
"timestamp": "2026-04-10T12:00:00.000Z"
}
```

1175
docs/api/game-api.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,7 @@
1. **关卡时间限制**`levels` 表新增 `time_limit` 字段,支持关卡通关时间限制
2. **单关进度上报**`POST /api/v1/share/progress` 接口,用于上报用户单关通关状态和时间
3. **进度查询**`reportLevelProgress` 返回是否在时间限制内通过
4. **我创建的挑战列表**`GET /api/v1/share/created` 接口,用于查询当前用户创建过的分享挑战、参与人数和本人排名
---
@@ -107,7 +108,7 @@ Authorization: Bearer <token>
user: {
id: string; // 用户 ID
nickname: string | null; // 用户昵称(微信昵称)
points: number; // 当前积分
stamina: number; // 当前体力值
}
}
```
@@ -122,7 +123,7 @@ Authorization: Bearer <token>
"user": {
"id": "user_abc123",
"nickname": "游戏玩家",
"points": 10
"stamina": 5
}
},
"message": null,
@@ -131,6 +132,7 @@ Authorization: Bearer <token>
```
**客户端调用时机**
- 用户首次进入游戏时调用
- 小游戏冷启动时调用(建议缓存 token
@@ -145,6 +147,7 @@ Authorization: Bearer <token>
**是否需要认证**JWT Bearer Token
**请求头**
```
Authorization: Bearer <token>
Content-Type: application/json
@@ -155,7 +158,8 @@ Content-Type: application/json
```json
{
"title": "我的挑战", // 分享标题,不超过 100 字符
"levelIds": [ // 恰好 6 个关卡 ID
"levelIds": [
// 恰好 6 个关卡 ID
"level_id_1",
"level_id_2",
"level_id_3",
@@ -192,11 +196,13 @@ Content-Type: application/json
```
**分享码生成规则**
- 使用 nanoid 生成 8 位字符
- 字符集为 a-z, A-Z, 0-9
- 发生碰撞时最多重试 3 次
**客户端调用场景**
- 用户点击「分享挑战」按钮时调用
- 用户选择 6 个关卡后,生成分享码
- 将分享码拼接为分享链接或二维码
@@ -214,7 +220,7 @@ Content-Type: application/json
**路径参数**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| ---- | ------ | ---- | -------------- |
| code | string | 是 | 分享码8 位) |
**响应数据**
@@ -265,16 +271,101 @@ Content-Type: application/json
```
**特殊逻辑**
- 如果 `userId` 与分享创建者相同,不会创建 `ShareParticipant` 记录
- 返回的关卡列表按 `levelIds` 创建时的顺序排列
**客户端调用场景**
- 用户通过分享码/链接进入游戏时调用
- 解析 URL 参数中的分享码,调用此接口获取关卡数据
---
### 4. 上报单关进度
### 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. 上报单关进度
用户在分享挑战中完成单关后,上报进度。
@@ -296,7 +387,7 @@ Content-Type: application/json
**字段说明**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| --------- | ------- | ---- | -------------------------- |
| shareCode | string | 是 | 分享码 |
| levelId | string | 是 | 关卡 ID |
| passed | boolean | 是 | 是否通过 |
@@ -337,6 +428,7 @@ Content-Type: application/json
- 如果 `timeLimit` 不为 `null`,只有 `timeSpent <= timeLimit``withinTimeLimit` 才为 `true`
**客户端调用场景**
- 用户完成一个关卡后调用
- 无论通关还是失败都需要调用
- 失败时 `passed=false``timeSpent` 可以传入实际用时或关卡时间上限
@@ -346,7 +438,7 @@ Content-Type: application/json
## 错误码说明
| HTTP Status | message | 说明 |
|-------------|---------|------|
| ----------- | ------------------------------------- | ---------------------------- |
| 400 | 关卡ID不能重复需要恰好6个不同的关卡 | 创建分享时 levelIds 格式错误 |
| 400 | 以下关卡不存在: xxx | 创建分享时关卡 ID 不存在 |
| 400 | 生成分享码失败,请重试 | 服务器生成分享码失败 |
@@ -400,7 +492,7 @@ interface ShareChallengeState {
passed: boolean;
timeSpent: number;
withinTimeLimit: boolean;
}
};
};
}
```
@@ -445,7 +537,7 @@ export class HttpManager {
async request<T>(
method: 'GET' | 'POST',
url: string,
body?: object
body?: object,
): Promise<ApiResponse<T>> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
@@ -504,7 +596,7 @@ async function wxLogin() {
const wxLoginRes = await new Promise<{ code: string }>((resolve, reject) => {
wx.login({
success: (res) => resolve({ code: res.code }),
fail: reject
fail: reject,
});
});
@@ -512,9 +604,9 @@ async function wxLogin() {
try {
const response = await httpManager.post<{
token: string;
user: { id: string; nickname: string | null; points: number };
user: { id: string; nickname: string | null; stamina: number };
}>('/v1/auth/wx-login', {
code: wxLoginRes.code
code: wxLoginRes.code,
});
if (response.success && response.data) {
@@ -543,7 +635,10 @@ interface CreateShareResponse {
levelCount: number;
}
async function createShare(title: string, levelIds: string[]): Promise<CreateShareResponse> {
async function createShare(
title: string,
levelIds: string[],
): Promise<CreateShareResponse> {
// 确保已登录
if (!httpManager.getToken()) {
await wxLogin();
@@ -551,7 +646,7 @@ async function createShare(title: string, levelIds: string[]): Promise<CreateSha
const response = await httpManager.post<CreateShareResponse>('/v1/share', {
title,
levelIds
levelIds,
});
if (response.success && response.data) {
@@ -563,7 +658,14 @@ async function createShare(title: string, levelIds: string[]): Promise<CreateSha
}
// 使用示例
const levelIds = ['level_1', 'level_2', 'level_3', 'level_4', 'level_5', 'level_6'];
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}`
@@ -596,7 +698,7 @@ async function joinShare(shareCode: string): Promise<JoinShareResponse> {
}
const response = await httpManager.post<JoinShareResponse>(
`/v1/share/${shareCode}/join`
`/v1/share/${shareCode}/join`,
);
if (response.success && response.data) {
@@ -627,7 +729,7 @@ async function reportLevelProgress(
shareCode: string,
levelId: string,
passed: boolean,
timeSpent: number
timeSpent: number,
): Promise<ReportProgressResponse> {
// 确保已登录
if (!httpManager.getToken()) {
@@ -640,8 +742,8 @@ async function reportLevelProgress(
shareCode,
levelId,
passed,
timeSpent
}
timeSpent,
},
);
if (response.success && response.data) {
@@ -655,10 +757,17 @@ async function reportLevelProgress(
// 使用示例
async function onLevelComplete(levelId: string, timeSpent: number) {
const passed = true; // 根据游戏逻辑判断是否通过
const result = await reportLevelProgress(this.shareCode, levelId, passed, timeSpent);
const result = await reportLevelProgress(
this.shareCode,
levelId,
passed,
timeSpent,
);
if (result.passed) {
console.log(`通关成功!${result.withinTimeLimit ? '在' : '超出'}时间限制内完成`);
console.log(
`通关成功!${result.withinTimeLimit ? '在' : '超出'}时间限制内完成`,
);
if (result.timeLimit) {
console.log(`本关时间限制: ${result.timeLimit}`);
}
@@ -709,7 +818,7 @@ export class GameEntry extends Component {
### LevelData 完整字段
| 字段 | 类型 | 说明 |
|------|------|------|
| --------- | -------------- | --------------- |
| id | string | 关卡唯一标识 |
| level | number | 关卡序号1-6 |
| imageUrl | string | 关卡图片 URL |
@@ -724,6 +833,7 @@ export class GameEntry extends Component {
`timeLimit` 字段在关卡数据结构中**不直接返回**,而是通过上报进度接口返回。
如果需要在前端判断时间限制:
1. 用户完成关卡后调用 `reportLevelProgress`
2. 从返回的 `timeLimit` 字段获取当前关卡的时间限制
3. 从返回的 `withinTimeLimit` 字段判断是否在时间内完成

View File

@@ -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,
],
})

View 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;

View File

@@ -13,9 +13,7 @@ describe('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 mockGetRequest = jest.fn().mockReturnValue({ url: '/api/v1/test' });
const mockHost: ArgumentsHost = {
switchToHttp: () => ({

View File

@@ -45,7 +45,7 @@ describe('JwtAuthGuard', () => {
mockJwtService.verifyAsync.mockResolvedValue(payload);
const context = createMockContext('Bearer valid-token');
const request = context.switchToHttp().getRequest() as Record<string, unknown>;
const request = context.switchToHttp().getRequest();
await guard.canActivate(context);
expect(request.user).toEqual(payload);

View File

@@ -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],

View 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;

View File

@@ -3,22 +3,13 @@ import { JwtService } from '@nestjs/jwt';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { ApiResponseDto } from '../../common/dto/api-response.dto';
import type { JwtPayload } from '../../common/guards/jwt-auth.guard';
import { MAX_STAMINA } from '../../common/constants/game.constants';
describe('AuthController', () => {
let controller: AuthController;
const mockUser: JwtPayload = {
sub: 'user-uuid-1',
openid: 'wx-openid-123',
};
const mockAuthService = {
wxLogin: jest.fn(),
getUserAssets: jest.fn(),
consumePoint: jest.fn(),
earnPoint: jest.fn(),
getGameData: jest.fn(),
};
beforeEach(async () => {
@@ -41,7 +32,7 @@ describe('AuthController', () => {
it('should return success response with token and user info', async () => {
const loginResponse = {
token: 'jwt-token',
user: { id: 'user-uuid-1', nickname: 'Test', points: 10 },
user: { id: 'user-uuid-1', nickname: 'Test', stamina: MAX_STAMINA },
};
mockAuthService.wxLogin.mockResolvedValue(loginResponse);
@@ -53,68 +44,4 @@ describe('AuthController', () => {
expect(mockAuthService.wxLogin).toHaveBeenCalledWith('wx-code-123');
});
});
describe('getUserAssets', () => {
it('should return success response with user points', async () => {
mockAuthService.getUserAssets.mockResolvedValue({ points: 10 });
const result = await controller.getUserAssets(mockUser);
expect(result.success).toBe(true);
expect(result.data).toEqual({ points: 10 });
expect(mockAuthService.getUserAssets).toHaveBeenCalledWith('user-uuid-1');
});
});
describe('consumePoint', () => {
it('should return success response with updated points', async () => {
mockAuthService.consumePoint.mockResolvedValue({ points: 9 });
const dto = { reason: 'hint_unlock' as const, levelId: 'level-1', hintIndex: 2 };
const result = await controller.consumePoint(mockUser, dto);
expect(result.success).toBe(true);
expect(result.data).toEqual({ points: 9 });
expect(mockAuthService.consumePoint).toHaveBeenCalledWith(
'user-uuid-1',
dto,
);
});
});
describe('earnPoint', () => {
it('should return success response with updated points', async () => {
mockAuthService.earnPoint.mockResolvedValue({ points: 11 });
const dto = {
reason: 'level_complete' as const,
levelId: 'level-1',
timeSpent: 30,
};
const result = await controller.earnPoint(mockUser, dto);
expect(result.success).toBe(true);
expect(result.data).toEqual({ points: 11 });
expect(mockAuthService.earnPoint).toHaveBeenCalledWith(
'user-uuid-1',
dto,
);
});
});
describe('getGameData', () => {
it('should return success response with game data', async () => {
const gameData = {
user: { id: 'user-uuid-1', points: 10 },
completedLevelIds: ['level-1', 'level-2'],
};
mockAuthService.getGameData.mockResolvedValue(gameData);
const result = await controller.getGameData(mockUser);
expect(result.success).toBe(true);
expect(result.data).toEqual(gameData);
expect(mockAuthService.getGameData).toHaveBeenCalledWith('user-uuid-1');
});
});
});

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -1,16 +1,12 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import {
UnauthorizedException,
BadRequestException,
} from '@nestjs/common';
import { UnauthorizedException } from '@nestjs/common';
import axios from 'axios';
import { AuthService } from './auth.service';
import { UserRepository } from './repositories/user.repository';
import { UserLevelProgressRepository } from './repositories/user-level-progress.repository';
import { User } from './entities/user.entity';
import { UserLevelProgress } from './entities/user-level-progress.entity';
import { MAX_STAMINA } from '../../common/constants/game.constants';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
@@ -24,20 +20,12 @@ describe('AuthService', () => {
sessionKey: 'session-key-abc',
nickname: 'TestUser',
avatarUrl: null,
points: 10,
stamina: MAX_STAMINA,
staminaUpdatedAt: null,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
};
const mockLevelProgress: UserLevelProgress = {
id: 'progress-uuid-1',
userId: 'user-uuid-1',
levelId: 'level-1',
user: mockUser,
timeSpent: 30,
completedAt: new Date('2026-01-02'),
};
const mockUserRepository = {
findById: jest.fn(),
findByOpenid: jest.fn(),
@@ -45,13 +33,6 @@ describe('AuthService', () => {
save: jest.fn(),
};
const mockUserLevelProgressRepository = {
findByUserId: jest.fn(),
findByUserAndLevel: jest.fn(),
create: jest.fn(),
save: jest.fn(),
};
const mockJwtService = {
signAsync: jest.fn(),
};
@@ -73,10 +54,6 @@ describe('AuthService', () => {
{ provide: ConfigService, useValue: mockConfigService },
{ provide: JwtService, useValue: mockJwtService },
{ provide: UserRepository, useValue: mockUserRepository },
{
provide: UserLevelProgressRepository,
useValue: mockUserLevelProgressRepository,
},
],
}).compile();
@@ -89,7 +66,7 @@ describe('AuthService', () => {
describe('wxLogin', () => {
it('should create a new user and return JWT token on first login', async () => {
const newUser = { ...mockUser, points: 10 };
const newUser = { ...mockUser };
mockedAxios.get.mockResolvedValue({
data: { openid: 'wx-openid-123', session_key: 'session-key-abc' },
});
@@ -102,11 +79,11 @@ describe('AuthService', () => {
expect(result.token).toBe('jwt-token-xyz');
expect(result.user.id).toBe('user-uuid-1');
expect(result.user.points).toBe(10);
expect(result.user.stamina).toBe(MAX_STAMINA);
expect(mockUserRepository.create).toHaveBeenCalledWith({
openid: 'wx-openid-123',
sessionKey: 'session-key-abc',
points: 10,
stamina: MAX_STAMINA,
});
expect(mockJwtService.signAsync).toHaveBeenCalledWith({
sub: 'user-uuid-1',
@@ -150,144 +127,4 @@ describe('AuthService', () => {
);
});
});
describe('getUserAssets', () => {
it('should return user points', async () => {
mockUserRepository.findById.mockResolvedValue(mockUser);
const result = await service.getUserAssets('user-uuid-1');
expect(result.points).toBe(10);
expect(mockUserRepository.findById).toHaveBeenCalledWith('user-uuid-1');
});
it('should throw UnauthorizedException when user not found', async () => {
mockUserRepository.findById.mockResolvedValue(null);
await expect(service.getUserAssets('nonexistent')).rejects.toThrow(
UnauthorizedException,
);
});
});
describe('consumePoint', () => {
it('should deduct 1 point and return updated points', async () => {
const user = { ...mockUser, points: 5 };
const savedUser = { ...user, points: 4 };
mockUserRepository.findById.mockResolvedValue(user);
mockUserRepository.save.mockResolvedValue(savedUser);
const result = await service.consumePoint('user-uuid-1', {
reason: 'hint_unlock',
levelId: 'level-1',
hintIndex: 2,
});
expect(result.points).toBe(4);
expect(mockUserRepository.save).toHaveBeenCalled();
});
it('should throw BadRequestException when points are 0', async () => {
mockUserRepository.findById.mockResolvedValue({
...mockUser,
points: 0,
});
await expect(
service.consumePoint('user-uuid-1', {
reason: 'hint_unlock',
}),
).rejects.toThrow(BadRequestException);
});
it('should throw UnauthorizedException when user not found', async () => {
mockUserRepository.findById.mockResolvedValue(null);
await expect(
service.consumePoint('nonexistent', { reason: 'hint_unlock' }),
).rejects.toThrow(UnauthorizedException);
});
});
describe('earnPoint', () => {
const earnDto = {
reason: 'level_complete' as const,
levelId: 'level-1',
timeSpent: 30,
};
it('should award 1 point for first-time level completion', async () => {
const user = { ...mockUser, points: 10 };
mockUserRepository.findById.mockResolvedValue(user);
mockUserLevelProgressRepository.findByUserAndLevel.mockResolvedValue(null);
mockUserLevelProgressRepository.create.mockReturnValue(mockLevelProgress);
mockUserLevelProgressRepository.save.mockResolvedValue(mockLevelProgress);
mockUserRepository.save.mockResolvedValue({ ...user, points: 11 });
const result = await service.earnPoint('user-uuid-1', earnDto);
expect(result.points).toBe(11);
expect(mockUserLevelProgressRepository.create).toHaveBeenCalledWith({
userId: 'user-uuid-1',
levelId: 'level-1',
timeSpent: 30,
});
});
it('should not award duplicate points for already completed level', async () => {
const user = { ...mockUser, points: 10 };
mockUserRepository.findById.mockResolvedValue(user);
mockUserLevelProgressRepository.findByUserAndLevel.mockResolvedValue(
mockLevelProgress,
);
const result = await service.earnPoint('user-uuid-1', earnDto);
expect(result.points).toBe(10);
expect(mockUserLevelProgressRepository.create).not.toHaveBeenCalled();
expect(mockUserRepository.save).not.toHaveBeenCalled();
});
it('should throw UnauthorizedException when user not found', async () => {
mockUserRepository.findById.mockResolvedValue(null);
await expect(
service.earnPoint('nonexistent', earnDto),
).rejects.toThrow(UnauthorizedException);
});
});
describe('getGameData', () => {
it('should return user info and completed level IDs', async () => {
mockUserRepository.findById.mockResolvedValue(mockUser);
mockUserLevelProgressRepository.findByUserId.mockResolvedValue([
{ ...mockLevelProgress, levelId: 'level-1' },
{ ...mockLevelProgress, levelId: 'level-2' },
]);
const result = await service.getGameData('user-uuid-1');
expect(result.user.id).toBe('user-uuid-1');
expect(result.user.points).toBe(10);
expect(result.completedLevelIds).toEqual(['level-1', 'level-2']);
});
it('should return empty completedLevelIds when no progress', async () => {
mockUserRepository.findById.mockResolvedValue(mockUser);
mockUserLevelProgressRepository.findByUserId.mockResolvedValue([]);
const result = await service.getGameData('user-uuid-1');
expect(result.completedLevelIds).toEqual([]);
});
it('should throw UnauthorizedException when user not found', async () => {
mockUserRepository.findById.mockResolvedValue(null);
mockUserLevelProgressRepository.findByUserId.mockResolvedValue([]);
await expect(service.getGameData('nonexistent')).rejects.toThrow(
UnauthorizedException,
);
});
});
});

View File

@@ -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;
}
}

View File

@@ -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[];
}

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 }>;
}

View File

@@ -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 };
}
}

View 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);
}
}

View 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 {}

View 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,
};
}
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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);
}
}

View 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 {}

View 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,
};
});
}
}

View 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;
}

View File

@@ -1,5 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
import {
IsBoolean,
IsNotEmpty,
IsNumber,
IsString,
Min,
} from 'class-validator';
export class ReportLevelProgressDto {
@ApiProperty({ description: '分享码' })

View File

@@ -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[];
}

View File

@@ -38,6 +38,11 @@ export class ShareLevelProgress {
@Column({ type: 'int', default: 0, name: 'time_spent' })
timeSpent!: number;
@Column({ type: 'timestamp', name: 'completed_at', nullable: true, default: null })
@Column({
type: 'timestamp',
name: 'completed_at',
nullable: true,
default: null,
})
completedAt!: Date | null;
}

View File

@@ -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' },
});
}
}

View File

@@ -3,6 +3,13 @@ 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(
@@ -10,7 +17,9 @@ export class ShareLevelProgressRepository {
private readonly repository: Repository<ShareLevelProgress>,
) {}
async findByParticipantId(participantId: string): Promise<ShareLevelProgress[]> {
async findByParticipantId(
participantId: string,
): Promise<ShareLevelProgress[]> {
return this.repository.find({ where: { participantId } });
}
@@ -21,6 +30,29 @@ export class ShareLevelProgressRepository {
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);
}

View File

@@ -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(
@@ -28,6 +33,28 @@ export class ShareParticipantRepository {
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,

View File

@@ -15,6 +15,7 @@ describe('ShareController', () => {
const mockShareService = {
createShare: jest.fn(),
getCreatedShares: jest.fn(),
joinShare: jest.fn(),
reportLevelProgress: jest.fn(),
};
@@ -60,6 +61,35 @@ describe('ShareController', () => {
});
});
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 = {

View File

@@ -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,6 +9,7 @@ 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';
@@ -23,6 +24,21 @@ import { ReportLevelProgressResponseDto } from './dto/share-level-progress-respo
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()

View File

@@ -1,8 +1,5 @@
import { Test, TestingModule } from '@nestjs/testing';
import {
NotFoundException,
BadRequestException,
} from '@nestjs/common';
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';
@@ -12,6 +9,7 @@ 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', () => ({
@@ -23,8 +21,12 @@ describe('ShareService', () => {
const mockLevels: Level[] = Array.from({ length: 6 }, (_, i) => ({
id: `level-${i + 1}`,
imageUrl: `https://example.com/meme${i + 1}.jpg`,
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,
@@ -40,7 +42,7 @@ describe('ShareService', () => {
title: '我的挑战',
sharerId: 'user-uuid-1',
levelIds: mockLevels.map((l) => l.id),
sharer: {} as any,
sharer: {} as User,
participants: [],
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
@@ -50,19 +52,21 @@ describe('ShareService', () => {
id: 'participant-uuid-1',
shareConfigId: 'share-uuid-1',
participantId: 'user-uuid-2',
shareConfig: {} as any,
participant: {} as any,
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(),
@@ -70,6 +74,7 @@ describe('ShareService', () => {
const mockShareLevelProgressRepository = {
findByParticipantAndLevel: jest.fn(),
summarizeByShareConfigIds: jest.fn(),
create: jest.fn(),
save: jest.fn(),
};
@@ -106,7 +111,14 @@ describe('ShareService', () => {
describe('createShare', () => {
const createDto = {
title: '我的挑战',
levelIds: ['level-1', 'level-2', 'level-3', 'level-4', 'level-5', 'level-6'],
levelIds: [
'level-1',
'level-2',
'level-3',
'level-4',
'level-5',
'level-6',
],
};
it('should create a share with valid 6 unique levels', async () => {
@@ -124,7 +136,14 @@ describe('ShareService', () => {
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'],
levelIds: [
'level-1',
'level-1',
'level-2',
'level-3',
'level-4',
'level-5',
],
};
await expect(
@@ -158,7 +177,9 @@ describe('ShareService', () => {
mockShareConfigRepository.findByShareCode.mockResolvedValue(
mockShareConfig,
);
mockShareParticipantRepository.addParticipant.mockResolvedValue(undefined);
mockShareParticipantRepository.addParticipant.mockResolvedValue(
undefined,
);
mockLevelRepository.findByIds.mockResolvedValue(mockLevels);
const result = await service.joinShare('user-uuid-2', 'ABCD1234');
@@ -168,10 +189,9 @@ describe('ShareService', () => {
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',
);
expect(
mockShareParticipantRepository.addParticipant,
).toHaveBeenCalledWith('share-uuid-1', 'user-uuid-2');
});
it('should not add participant when user is the sharer', async () => {
@@ -191,9 +211,122 @@ describe('ShareService', () => {
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);
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);
});
});

View File

@@ -8,10 +8,12 @@ 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';
@@ -82,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]));
@@ -94,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,
};
});
@@ -110,6 +109,62 @@ export class ShareService {
};
}
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,
@@ -153,7 +208,10 @@ export class ShareService {
return {
passed: true,
timeLimit: level.timeLimit,
withinTimeLimit: this.isWithinTimeLimit(level.timeLimit, progress.timeSpent),
withinTimeLimit: this.isWithinTimeLimit(
level.timeLimit,
progress.timeSpent,
),
};
}

View 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;
}

View 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);
}
}

View 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 {}

View 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;
}
}

View File

@@ -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;

View File

@@ -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;

View 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,
};
}

View File

@@ -1,133 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { WechatGameController } from './wechat-game.controller';
import { WechatGameService } from './wechat-game.service';
import { ApiResponseDto } from '../../common/dto/api-response.dto';
describe('WechatGameController', () => {
let controller: WechatGameController;
const mockWechatGameService = {
getAllConfigs: jest.fn(),
getConfigByKey: jest.fn(),
getAllLevels: jest.fn(),
getLevelById: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [WechatGameController],
providers: [
{ provide: WechatGameService, useValue: mockWechatGameService },
],
}).compile();
controller = module.get<WechatGameController>(WechatGameController);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('getAllConfigs', () => {
it('should return success response with config list', async () => {
const configList = {
configs: [
{
id: 'config-1',
configKey: 'game_speed',
configValue: '1.5',
description: null,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
},
],
total: 1,
};
mockWechatGameService.getAllConfigs.mockResolvedValue(configList);
const result = await controller.getAllConfigs();
expect(result).toBeInstanceOf(ApiResponseDto);
expect(result.success).toBe(true);
expect(result.data).toEqual(configList);
});
});
describe('getConfigByKey', () => {
it('should return success response with config', async () => {
const config = {
id: 'config-1',
configKey: 'game_speed',
configValue: '1.5',
description: null,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
};
mockWechatGameService.getConfigByKey.mockResolvedValue(config);
const result = await controller.getConfigByKey('game_speed');
expect(result.success).toBe(true);
expect(result.data).toEqual(config);
expect(mockWechatGameService.getConfigByKey).toHaveBeenCalledWith(
'game_speed',
);
});
});
describe('getAllLevels', () => {
it('should return success response with level list', async () => {
const levelList = {
levels: [
{
level: 1,
id: 'level-1',
imageUrl: 'https://example.com/1.jpg',
answer: '答案',
hint1: null,
hint2: null,
hint3: null,
sortOrder: 0,
createdAt: new Date(),
updatedAt: new Date(),
},
],
total: 1,
};
mockWechatGameService.getAllLevels.mockResolvedValue(levelList);
const result = await controller.getAllLevels();
expect(result.success).toBe(true);
expect(result.data).toEqual(levelList);
});
});
describe('getLevelById', () => {
it('should return success response with level', async () => {
const level = {
level: 1,
id: 'level-1',
imageUrl: 'https://example.com/1.jpg',
answer: '答案',
hint1: null,
hint2: null,
hint3: null,
sortOrder: 0,
createdAt: new Date(),
updatedAt: new Date(),
};
mockWechatGameService.getLevelById.mockResolvedValue(level);
const result = await controller.getLevelById('level-1');
expect(result.success).toBe(true);
expect(result.data).toEqual(level);
expect(mockWechatGameService.getLevelById).toHaveBeenCalledWith(
'level-1',
);
});
});
});

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -1,169 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { WechatGameService } from './wechat-game.service';
import { GameConfigRepository } from './repositories/game-config.repository';
import { LevelRepository } from './repositories/level.repository';
import { GameConfig } from './entities/game-config.entity';
import { Level } from './entities/level.entity';
describe('WechatGameService', () => {
let service: WechatGameService;
const mockGameConfig: GameConfig = {
id: 'config-uuid-1',
configKey: 'game_speed',
configValue: '1.5',
description: 'Game speed multiplier',
isActive: true,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
};
const mockLevel: Level = {
id: 'level-1',
imageUrl: 'https://example.com/meme1.jpg',
answer: '答案一',
hint1: '提示1',
hint2: '提示2',
hint3: null,
sortOrder: 0,
timeLimit: 60,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
};
const mockLevel2: Level = {
id: 'level-2',
imageUrl: 'https://example.com/meme2.jpg',
answer: '答案二',
hint1: '提示A',
hint2: null,
hint3: null,
sortOrder: 1,
timeLimit: null,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
};
const mockGameConfigRepository = {
findActiveConfigs: jest.fn(),
findByKey: jest.fn(),
};
const mockLevelRepository = {
findAllOrdered: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
WechatGameService,
{ provide: GameConfigRepository, useValue: mockGameConfigRepository },
{ provide: LevelRepository, useValue: mockLevelRepository },
],
}).compile();
service = module.get<WechatGameService>(WechatGameService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('getAllConfigs', () => {
it('should return all active configs', async () => {
mockGameConfigRepository.findActiveConfigs.mockResolvedValue([
mockGameConfig,
]);
const result = await service.getAllConfigs();
expect(result.configs).toHaveLength(1);
expect(result.total).toBe(1);
expect(result.configs[0].configKey).toBe('game_speed');
expect(result.configs[0].configValue).toBe('1.5');
});
it('should return empty array when no configs found', async () => {
mockGameConfigRepository.findActiveConfigs.mockResolvedValue([]);
const result = await service.getAllConfigs();
expect(result.configs).toHaveLength(0);
expect(result.total).toBe(0);
});
});
describe('getConfigByKey', () => {
it('should return config by key', async () => {
mockGameConfigRepository.findByKey.mockResolvedValue(mockGameConfig);
const result = await service.getConfigByKey('game_speed');
expect(result.configKey).toBe('game_speed');
expect(result.configValue).toBe('1.5');
expect(mockGameConfigRepository.findByKey).toHaveBeenCalledWith(
'game_speed',
);
});
it('should throw NotFoundException when config not found', async () => {
mockGameConfigRepository.findByKey.mockResolvedValue(null);
await expect(service.getConfigByKey('nonexistent')).rejects.toThrow(
NotFoundException,
);
});
});
describe('getAllLevels', () => {
it('should return all levels with 1-indexed level numbers', async () => {
mockLevelRepository.findAllOrdered.mockResolvedValue([
mockLevel,
mockLevel2,
]);
const result = await service.getAllLevels();
expect(result.levels).toHaveLength(2);
expect(result.total).toBe(2);
expect(result.levels[0].level).toBe(1);
expect(result.levels[0].id).toBe('level-1');
expect(result.levels[0].answer).toBe('答案一');
expect(result.levels[1].level).toBe(2);
expect(result.levels[1].id).toBe('level-2');
});
it('should return empty array when no levels exist', async () => {
mockLevelRepository.findAllOrdered.mockResolvedValue([]);
const result = await service.getAllLevels();
expect(result.levels).toHaveLength(0);
expect(result.total).toBe(0);
});
});
describe('getLevelById', () => {
it('should return level with correct level number', async () => {
mockLevelRepository.findAllOrdered.mockResolvedValue([
mockLevel,
mockLevel2,
]);
const result = await service.getLevelById('level-2');
expect(result.id).toBe('level-2');
expect(result.level).toBe(2);
expect(result.answer).toBe('答案二');
});
it('should throw NotFoundException when level not found', async () => {
mockLevelRepository.findAllOrdered.mockResolvedValue([mockLevel]);
await expect(service.getLevelById('nonexistent')).rejects.toThrow(
NotFoundException,
);
});
});
});

View File

@@ -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,
};
}
}