feat: 支持新的关卡数据结构
This commit is contained in:
6
.claude/settings.json
Normal file
6
.claude/settings.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"code-review@claude-plugins-official": true,
|
||||||
|
"claude-md-management@claude-plugins-official": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,706 +0,0 @@
|
|||||||
# MemeMind-Server Architecture Diagrams & Flows
|
|
||||||
|
|
||||||
## 1. System Architecture Overview
|
|
||||||
|
|
||||||
```
|
|
||||||
┌───────────────────────────────────────────────────────────────┐
|
|
||||||
│ WeChat Mini-Game Client │
|
|
||||||
│ (Cocos Creator 3.8.8) │
|
|
||||||
│ │
|
|
||||||
│ • PageLoading.ts (startup) │
|
|
||||||
│ • LevelDataManager.ts (API calls) │
|
|
||||||
│ • PageLevel.ts (gameplay) │
|
|
||||||
│ • StorageManager.ts (localStorage) │
|
|
||||||
└────────────────────┬────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
│ HTTP Requests
|
|
||||||
│ GET /api/v1/wechat-game/levels
|
|
||||||
│ GET /api/v1/wechat-game/configs
|
|
||||||
│
|
|
||||||
┌────────────────────▼────────────────────────────────────────┐
|
|
||||||
│ MemeMind-Server (NestJS) │
|
|
||||||
│ http://ilookai.cn:3000/api │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ HTTP Layer │ │
|
|
||||||
│ │ • GlobalPrefix: /api │ │
|
|
||||||
│ │ • CORS: Enabled │ │
|
|
||||||
│ │ • ValidationPipe: Global validation │ │
|
|
||||||
│ │ • Swagger: /api/docs │ │
|
|
||||||
│ └──────────────────────────────────────────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌──────────────────────▼──────────────────────────────┐ │
|
|
||||||
│ │ WechatGameController │ │
|
|
||||||
│ │ POST /v1/wechat-game/configs │ │
|
|
||||||
│ │ GET /v1/wechat-game/configs/:key │ │
|
|
||||||
│ │ GET /v1/wechat-game/levels │ │
|
|
||||||
│ │ GET /v1/wechat-game/levels/:id │ │
|
|
||||||
│ └──────────────────────┬──────────────────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌──────────────────────▼──────────────────────────────┐ │
|
|
||||||
│ │ WechatGameService │ │
|
|
||||||
│ │ • getAllConfigs() │ │
|
|
||||||
│ │ • getConfigByKey(key) │ │
|
|
||||||
│ │ • getAllLevels() │ │
|
|
||||||
│ │ • getLevelById(id) │ │
|
|
||||||
│ │ • toResponseDto() │ │
|
|
||||||
│ │ • toLevelResponseDto() │ │
|
|
||||||
│ └──────────────────────┬──────────────────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌──────────────────────▼──────────────────────────────┐ │
|
|
||||||
│ │ Repository Layer │ │
|
|
||||||
│ │ ├─ LevelRepository │ │
|
|
||||||
│ │ └─ GameConfigRepository │ │
|
|
||||||
│ └──────────────────────┬──────────────────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌──────────────────────▼──────────────────────────────┐ │
|
|
||||||
│ │ TypeORM / MySQL │ │
|
|
||||||
│ │ ├─ levels table │ │
|
|
||||||
│ │ └─ game_configs table │ │
|
|
||||||
│ └──────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
└───────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
│ Response (JSON)
|
|
||||||
│ {success, data, message, timestamp}
|
|
||||||
│
|
|
||||||
┌────────────────────▼────────────────────────────────────────┐
|
|
||||||
│ WeChat Mini-Game Client │
|
|
||||||
│ • LevelDataManager stores in _apiData │
|
|
||||||
│ • PageLevel reads _apiData │
|
|
||||||
│ • Images preloaded via assetManager.loadRemote() │
|
|
||||||
└───────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Request-Response Flow
|
|
||||||
|
|
||||||
### Scenario 1: Get All Levels
|
|
||||||
|
|
||||||
```
|
|
||||||
Client Server
|
|
||||||
│ │
|
|
||||||
├─ GET /api/v1/wechat-game/levels
|
|
||||||
│─────────────────────────────>│
|
|
||||||
│ │
|
|
||||||
│ [Controller]
|
|
||||||
│ getAllLevels()
|
|
||||||
│ │
|
|
||||||
│ [Service]
|
|
||||||
│ levelRepository.findAllOrdered()
|
|
||||||
│ │
|
|
||||||
│ [Repository]
|
|
||||||
│ SELECT * FROM levels ORDER BY sort_order
|
|
||||||
│ │
|
|
||||||
│ [MySQL]
|
|
||||||
│ Returns Level[]
|
|
||||||
│ │
|
|
||||||
│ [Service]
|
|
||||||
│ Map to LevelResponseDto
|
|
||||||
│ Add level numbers
|
|
||||||
│ │
|
|
||||||
│ [Filter]
|
|
||||||
│ Wrap in ApiResponseDto.success()
|
|
||||||
│ │
|
|
||||||
│ <─────────────────────────────│
|
|
||||||
│ { │
|
|
||||||
│ "success": true, │
|
|
||||||
│ "data": { │
|
|
||||||
│ "levels": [...], │
|
|
||||||
│ "total": 50 │
|
|
||||||
│ }, │
|
|
||||||
│ "message": null, │
|
|
||||||
│ "timestamp": "2026-04-05..." │
|
|
||||||
│ } │
|
|
||||||
│ │
|
|
||||||
├─ Store in _apiData
|
|
||||||
├─ Preload images
|
|
||||||
└─ Ready for gameplay
|
|
||||||
```
|
|
||||||
|
|
||||||
### Scenario 2: Get Config by Key
|
|
||||||
|
|
||||||
```
|
|
||||||
Client Server
|
|
||||||
│ │
|
|
||||||
├─ GET /api/v1/wechat-game/configs/HINT_COST
|
|
||||||
│─────────────────────────────>│
|
|
||||||
│ │
|
|
||||||
│ [Controller]
|
|
||||||
│ getConfigByKey("HINT_COST")
|
|
||||||
│ │
|
|
||||||
│ [Service]
|
|
||||||
│ gameConfigRepository.findByKey()
|
|
||||||
│ │
|
|
||||||
│ [Repository]
|
|
||||||
│ SELECT * FROM game_configs
|
|
||||||
│ WHERE config_key = 'HINT_COST'
|
|
||||||
│ │
|
|
||||||
│ [MySQL]
|
|
||||||
│ Returns GameConfig or null
|
|
||||||
│ │
|
|
||||||
│ ┌─────────┴─────────┐
|
|
||||||
│ │ │
|
|
||||||
│ FOUND NOT FOUND
|
|
||||||
│ │ │
|
|
||||||
│ [Service] [Service]
|
|
||||||
│ Map to DTO throw NotFoundException
|
|
||||||
│ │ │
|
|
||||||
│ [Filter] [Filter]
|
|
||||||
│ success() catch exception
|
|
||||||
│ │ │
|
|
||||||
│ <─────────────────┤ │
|
|
||||||
│ { │ │
|
|
||||||
│ "success": true,│ │
|
|
||||||
│ "data": {...} │ │
|
|
||||||
│ } │ ┌────────────────┘
|
|
||||||
│ │ │
|
|
||||||
│ │ └─> {
|
|
||||||
│ │ "success": false,
|
|
||||||
│ │ "data": null,
|
|
||||||
│ │ "message": "Game config... not found",
|
|
||||||
│ │ "path": "/api/v1/..."
|
|
||||||
│ │ }
|
|
||||||
│ │
|
|
||||||
└──────────────────────────────────
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Module Dependency Graph
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────┐
|
|
||||||
│ AppModule (root) │
|
|
||||||
├──────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ imports: [ │
|
|
||||||
│ AppConfigModule, │
|
|
||||||
│ TypeOrmModule, │
|
|
||||||
│ WechatGameModule │
|
|
||||||
│ ] │
|
|
||||||
│ │
|
|
||||||
└──┬───────────────────────────┘
|
|
||||||
│
|
|
||||||
├─────────────────────────────────────┐
|
|
||||||
│ │
|
|
||||||
│ │
|
|
||||||
┌──▼─────────────────┐ ┌──────────────▼──────────────┐
|
|
||||||
│ AppConfigModule │ │ WechatGameModule │
|
|
||||||
├────────────────────┤ ├─────────────────────────────┤
|
|
||||||
│ @Global() │ │ imports: [ │
|
|
||||||
│ │ │ TypeOrmModule.forFeature( │
|
|
||||||
│ imports: [ │ │ [GameConfig, Level] │
|
|
||||||
│ ConfigModule │ │ ) │
|
|
||||||
│ ] │ │ ] │
|
|
||||||
│ │ │ │
|
|
||||||
│ exports: [ │ │ controllers: [ │
|
|
||||||
│ ConfigModule │ │ WechatGameController │
|
|
||||||
│ ] │ │ ] │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ providers: [ │
|
|
||||||
│ │ │ WechatGameService, │
|
|
||||||
│ │ │ LevelRepository, │
|
|
||||||
│ │ │ GameConfigRepository │
|
|
||||||
│ │ │ ] │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ exports: [ │
|
|
||||||
│ │ │ WechatGameService │
|
|
||||||
│ │ │ ] │
|
|
||||||
│ │ │ │
|
|
||||||
└────────────────────┘ └─────────────────────────────┘
|
|
||||||
│
|
|
||||||
│
|
|
||||||
┌──────────▼───────────────┐
|
|
||||||
│ TypeOrmModule.forFeature│
|
|
||||||
├───────────────────────────┤
|
|
||||||
│ Registers: │
|
|
||||||
│ • Level entity │
|
|
||||||
│ • GameConfig entity │
|
|
||||||
│ • Auto-creates repos │
|
|
||||||
└──────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Data Model Relationships
|
|
||||||
|
|
||||||
```
|
|
||||||
┌────────────────────────────────┐
|
|
||||||
│ levels │
|
|
||||||
├────────────────────────────────┤
|
|
||||||
│ PK: id (VARCHAR 191) │
|
|
||||||
├────────────────────────────────┤
|
|
||||||
│ id (PK) │
|
|
||||||
│ image_url (VARCHAR) │
|
|
||||||
│ answer (VARCHAR) │
|
|
||||||
│ hint1 (VARCHAR) │
|
|
||||||
│ hint2 (VARCHAR) │
|
|
||||||
│ hint3 (VARCHAR) │
|
|
||||||
│ sort_order (INT) │
|
|
||||||
│ created_at (DATETIME)│
|
|
||||||
│ updated_at (DATETIME)│
|
|
||||||
├────────────────────────────────┤
|
|
||||||
│ Indexes: │
|
|
||||||
│ • PK: id │
|
|
||||||
│ • idx_sort_order: sort_order │
|
|
||||||
├────────────────────────────────┤
|
|
||||||
│ Used by: │
|
|
||||||
│ • LevelRepository │
|
|
||||||
│ • WechatGameService │
|
|
||||||
└────────────────────────────────┘
|
|
||||||
|
|
||||||
┌────────────────────────────────┐
|
|
||||||
│ game_configs │
|
|
||||||
├────────────────────────────────┤
|
|
||||||
│ PK: id (UUID) │
|
|
||||||
├────────────────────────────────┤
|
|
||||||
│ id (PK) │
|
|
||||||
│ config_key (VARCHAR) │
|
|
||||||
│ config_value (TEXT) │
|
|
||||||
│ description (VARCHAR) │
|
|
||||||
│ is_active (BOOLEAN) │
|
|
||||||
│ created_at (DATETIME)│
|
|
||||||
│ updated_at (DATETIME)│
|
|
||||||
├────────────────────────────────┤
|
|
||||||
│ Indexes: │
|
|
||||||
│ • PK: id │
|
|
||||||
│ • UNIQUE: config_key │
|
|
||||||
│ • idx_active: is_active │
|
|
||||||
├────────────────────────────────┤
|
|
||||||
│ Used by: │
|
|
||||||
│ • GameConfigRepository │
|
|
||||||
│ • WechatGameService │
|
|
||||||
└────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Service Method Call Chain
|
|
||||||
|
|
||||||
### GET /api/v1/wechat-game/levels
|
|
||||||
|
|
||||||
```
|
|
||||||
Controller.getAllLevels()
|
|
||||||
│
|
|
||||||
├─> service.getAllLevels()
|
|
||||||
│ │
|
|
||||||
│ ├─> levelRepository.findAllOrdered()
|
|
||||||
│ │ │
|
|
||||||
│ │ └─> repository.find({ order: { sortOrder: 'ASC' } })
|
|
||||||
│ │ │
|
|
||||||
│ │ └─> [SELECT * FROM levels ORDER BY sort_order ASC]
|
|
||||||
│ │
|
|
||||||
│ ├─> LOOP levels array:
|
|
||||||
│ │ ├─> toLevelResponseDto(level, index + 1)
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ └─> {
|
|
||||||
│ │ │ level: 1 (or 2, 3, ...)
|
|
||||||
│ │ │ id: level.id
|
|
||||||
│ │ │ imageUrl: level.imageUrl
|
|
||||||
│ │ │ answer: level.answer
|
|
||||||
│ │ │ hint1: level.hint1
|
|
||||||
│ │ │ hint2: level.hint2
|
|
||||||
│ │ │ hint3: level.hint3
|
|
||||||
│ │ │ sortOrder: level.sortOrder
|
|
||||||
│ │ │ createdAt: level.createdAt
|
|
||||||
│ │ │ updatedAt: level.updatedAt
|
|
||||||
│ │ │ }
|
|
||||||
│ │
|
|
||||||
│ └─> return {
|
|
||||||
│ levels: [LevelResponseDto[], ...]
|
|
||||||
│ total: count
|
|
||||||
│ }
|
|
||||||
│
|
|
||||||
└─> ApiResponseDto.success(data)
|
|
||||||
│
|
|
||||||
└─> {
|
|
||||||
success: true
|
|
||||||
data: { levels, total }
|
|
||||||
message: null
|
|
||||||
timestamp: new Date()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Error Handling Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────┐
|
|
||||||
│ HTTP Request │
|
|
||||||
└────────┬────────┘
|
|
||||||
│
|
|
||||||
┌────▼─────────────┐
|
|
||||||
│ ValidationPipe │
|
|
||||||
│ (Global) │
|
|
||||||
└────┬─────────────┘
|
|
||||||
│
|
|
||||||
┌───┴────────────────────────┐
|
|
||||||
│ │
|
|
||||||
▼ Valid ▼ Invalid
|
|
||||||
Continue ValidationException
|
|
||||||
│
|
|
||||||
[ExceptionFilter]
|
|
||||||
catches @Catch()
|
|
||||||
│
|
|
||||||
ApiResponseDto.error()
|
|
||||||
│
|
|
||||||
{success: false,
|
|
||||||
message: "...",
|
|
||||||
path: "..."}
|
|
||||||
│
|
|
||||||
▼ Valid data
|
|
||||||
[Controller]
|
|
||||||
[Service]
|
|
||||||
[Repository]
|
|
||||||
[Database]
|
|
||||||
│
|
|
||||||
├─ Success ──┐
|
|
||||||
│ │
|
|
||||||
│ ▼
|
|
||||||
│ [Service] returns data
|
|
||||||
│ │
|
|
||||||
│ [Controller]
|
|
||||||
│ │
|
|
||||||
│ ApiResponseDto.success(data)
|
|
||||||
│ │
|
|
||||||
│ Return to client
|
|
||||||
│
|
|
||||||
└─ Exception ──┐
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
NotFoundException
|
|
||||||
BadRequestException
|
|
||||||
(or any HttpException)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
[HttpExceptionFilter]
|
|
||||||
@Catch() catches exception
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Extract status & message
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
ApiResponseDto.error(message)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
response.status(code).json(errorDto)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Return to client with error
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Request Validation Pipeline
|
|
||||||
|
|
||||||
```
|
|
||||||
HTTP Request
|
|
||||||
│
|
|
||||||
├─ Path params extracted
|
|
||||||
│ @Param('id') id: string
|
|
||||||
│ @Param('key') key: string
|
|
||||||
│
|
|
||||||
├─ Query params extracted
|
|
||||||
│ @Query() dto: QueryDto
|
|
||||||
│
|
|
||||||
├─ Body params extracted (for POST/PUT)
|
|
||||||
│ @Body() dto: CreateDto
|
|
||||||
│
|
|
||||||
├─ Global ValidationPipe processes:
|
|
||||||
│ │
|
|
||||||
│ ├─ whitelist: true
|
|
||||||
│ │ └─ Remove unknown properties
|
|
||||||
│ │
|
|
||||||
│ ├─ forbidNonWhitelisted: true
|
|
||||||
│ │ └─ Throw if unknown properties found
|
|
||||||
│ │
|
|
||||||
│ └─ transform: true
|
|
||||||
│ └─ Transform strings to appropriate types
|
|
||||||
│ (e.g., "123" → 123)
|
|
||||||
│
|
|
||||||
├─ class-validator decorators checked
|
|
||||||
│ @IsString()
|
|
||||||
│ @IsNumber()
|
|
||||||
│ @IsEmail()
|
|
||||||
│ etc.
|
|
||||||
│
|
|
||||||
├─ If validation fails
|
|
||||||
│ └─> BadRequestException
|
|
||||||
│ └─> ExceptionFilter catches
|
|
||||||
│ └─> 400 status + error message
|
|
||||||
│
|
|
||||||
└─ If validation passes
|
|
||||||
└─> Continue to controller
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Data Transformation Chain
|
|
||||||
|
|
||||||
```
|
|
||||||
HTTP Request JSON
|
|
||||||
│
|
|
||||||
├─ Parse JSON
|
|
||||||
├─ Extract into DTO object
|
|
||||||
│ {
|
|
||||||
│ "@Type(() => Number)" hint: "5" ──> 5 (number)
|
|
||||||
│ "@Transform()" name: "JOHN" ──> "john" (lowercased)
|
|
||||||
│ }
|
|
||||||
│
|
|
||||||
├─ Validate against DTO decorators
|
|
||||||
│ @IsNotEmpty()
|
|
||||||
│ @IsNumber()
|
|
||||||
│ @Min(0)
|
|
||||||
│ @Max(100)
|
|
||||||
│
|
|
||||||
├─ Pass to Service
|
|
||||||
│
|
|
||||||
├─ Service transforms to Entity
|
|
||||||
│ DTO ──> Entity
|
|
||||||
│ {id, name} {id, name, timestamp}
|
|
||||||
│
|
|
||||||
├─ Database operations
|
|
||||||
│ Entity ──> SQL
|
|
||||||
│ TypeORM handles serialization
|
|
||||||
│
|
|
||||||
├─ Result from Database
|
|
||||||
│ Entity[] ──> Entity[]
|
|
||||||
│
|
|
||||||
├─ Service transforms Entity to ResponseDto
|
|
||||||
│ Entity ──> ResponseDto
|
|
||||||
│ Remove sensitive fields
|
|
||||||
│ Add computed fields
|
|
||||||
│
|
|
||||||
├─ Wrap in ApiResponseDto
|
|
||||||
│
|
|
||||||
└─ Send as JSON Response
|
|
||||||
{
|
|
||||||
success: true,
|
|
||||||
data: [...ResponseDtos...],
|
|
||||||
message: null,
|
|
||||||
timestamp: "..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Database Connection Lifecycle
|
|
||||||
|
|
||||||
```
|
|
||||||
[Application Start]
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
[config.module.ts loads]
|
|
||||||
• validateEnvironment()
|
|
||||||
• Reads .env, .env.local, .env.production
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
[app.module.ts initializes]
|
|
||||||
• TypeOrmModule.forRootAsync()
|
|
||||||
• Uses ConfigService to get DB params
|
|
||||||
│
|
|
||||||
├─ DB_HOST: localhost
|
|
||||||
├─ DB_PORT: 3306
|
|
||||||
├─ DB_USERNAME: meme_user
|
|
||||||
├─ DB_PASSWORD: (from env)
|
|
||||||
├─ DB_DATABASE: meme_mind
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
[TypeORM connects to MySQL]
|
|
||||||
mysql2 driver establishes connection
|
|
||||||
│
|
|
||||||
├─ If NODE_ENV === 'development'
|
|
||||||
│ └─ synchronize: true
|
|
||||||
│ └─ Auto-create/update tables
|
|
||||||
│
|
|
||||||
├─ If NODE_ENV === 'production'
|
|
||||||
│ └─ synchronize: false
|
|
||||||
│ └─ Use migrations instead
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
[Repositories instantiated]
|
|
||||||
@InjectRepository(Level)
|
|
||||||
@InjectRepository(GameConfig)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
[Ready to accept requests]
|
|
||||||
• findAllOrdered() ──> SELECT ...
|
|
||||||
• findByKey() ──> SELECT ...
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
[Application shutdown]
|
|
||||||
TypeORM closes connection
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Environment to Runtime Configuration
|
|
||||||
|
|
||||||
```
|
|
||||||
.env.local / .env file
|
|
||||||
│
|
|
||||||
├─ NODE_ENV=development
|
|
||||||
├─ PORT=3000
|
|
||||||
├─ DB_HOST=localhost
|
|
||||||
├─ DB_PORT=3306
|
|
||||||
├─ DB_USERNAME=meme_user
|
|
||||||
├─ DB_PASSWORD=secret
|
|
||||||
├─ DB_DATABASE=meme_mind
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
env.validation.ts
|
|
||||||
• plainToInstance(EnvironmentVariables, config)
|
|
||||||
• validateSync()
|
|
||||||
• Throws if validation fails
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
config.module.ts
|
|
||||||
• ConfigModule.forRoot()
|
|
||||||
• isGlobal: true ──> Available everywhere
|
|
||||||
• validate: validateEnvironment
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
database.config.ts
|
|
||||||
registerAs('database', () => ({
|
|
||||||
type: 'mysql',
|
|
||||||
host: configService.get('DB_HOST'),
|
|
||||||
port: configService.get('DB_PORT'),
|
|
||||||
username: configService.get('DB_USERNAME'),
|
|
||||||
password: configService.get('DB_PASSWORD'),
|
|
||||||
database: configService.get('DB_DATABASE'),
|
|
||||||
...
|
|
||||||
}))
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
app.module.ts
|
|
||||||
TypeOrmModule.forRootAsync({
|
|
||||||
useFactory: (configService) => ({
|
|
||||||
...configService.get('database')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
main.ts
|
|
||||||
port = process.env.PORT ?? 3000
|
|
||||||
app.listen(port)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Application Running
|
|
||||||
• Connected to MySQL
|
|
||||||
• Listening on port
|
|
||||||
• Ready for requests
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. API Response Mapping Example
|
|
||||||
|
|
||||||
### Request:
|
|
||||||
```
|
|
||||||
GET /api/v1/wechat-game/levels
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database Results:
|
|
||||||
```sql
|
|
||||||
SELECT * FROM levels ORDER BY sort_order ASC LIMIT 2;
|
|
||||||
|
|
||||||
Results:
|
|
||||||
┌─────────────┬──────────────────────┬────────┬───────┬───────┬───────┬────────────┬─────────────┬─────────────┐
|
|
||||||
│ id │ image_url │ answer │ hint1 │ hint2 │ hint3 │ sort_order │ created_at │ updated_at │
|
|
||||||
├─────────────┼──────────────────────┼────────┼───────┼───────┼───────┼────────────┼─────────────┼─────────────┤
|
|
||||||
│ level-001 │ http://...img1.jpg │ meme │ image │ funny │ null │ 0 │ 2026-04-01 │ 2026-04-05 │
|
|
||||||
│ level-002 │ http://...img2.jpg │ code │ tech │ null │ null │ 1 │ 2026-04-02 │ 2026-04-05 │
|
|
||||||
└─────────────┴──────────────────────┴────────┴───────┴───────┴───────┴────────────┴─────────────┴─────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Service Transformation:
|
|
||||||
```javascript
|
|
||||||
levels.map((level, index) => toLevelResponseDto(level, index + 1))
|
|
||||||
|
|
||||||
Result:
|
|
||||||
[
|
|
||||||
{
|
|
||||||
level: 1, // Computed: index + 1
|
|
||||||
id: "level-001",
|
|
||||||
imageUrl: "http://...img1.jpg",
|
|
||||||
answer: "meme",
|
|
||||||
hint1: "image",
|
|
||||||
hint2: "funny",
|
|
||||||
hint3: null,
|
|
||||||
sortOrder: 0,
|
|
||||||
createdAt: "2026-04-01T...",
|
|
||||||
updatedAt: "2026-04-05T..."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
level: 2,
|
|
||||||
id: "level-002",
|
|
||||||
imageUrl: "http://...img2.jpg",
|
|
||||||
answer: "code",
|
|
||||||
hint1: "tech",
|
|
||||||
hint2: null,
|
|
||||||
hint3: null,
|
|
||||||
sortOrder: 1,
|
|
||||||
createdAt: "2026-04-02T...",
|
|
||||||
updatedAt: "2026-04-05T..."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Final HTTP Response:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"levels": [
|
|
||||||
{
|
|
||||||
"level": 1,
|
|
||||||
"id": "level-001",
|
|
||||||
"imageUrl": "http://...img1.jpg",
|
|
||||||
"answer": "meme",
|
|
||||||
"hint1": "image",
|
|
||||||
"hint2": "funny",
|
|
||||||
"hint3": null,
|
|
||||||
"sortOrder": 0,
|
|
||||||
"createdAt": "2026-04-01T00:00:00.000Z",
|
|
||||||
"updatedAt": "2026-04-05T12:00:00.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": 2,
|
|
||||||
"id": "level-002",
|
|
||||||
"imageUrl": "http://...img2.jpg",
|
|
||||||
"answer": "code",
|
|
||||||
"hint1": "tech",
|
|
||||||
"hint2": null,
|
|
||||||
"hint3": null,
|
|
||||||
"sortOrder": 1,
|
|
||||||
"createdAt": "2026-04-02T00:00:00.000Z",
|
|
||||||
"updatedAt": "2026-04-05T12:00:00.000Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total": 2
|
|
||||||
},
|
|
||||||
"message": null,
|
|
||||||
"timestamp": "2026-04-05T12:34:56.789Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary of Diagrams
|
|
||||||
|
|
||||||
1. **System Architecture**: High-level components (Client, Server, Database)
|
|
||||||
2. **Request-Response Flow**: Detailed flow for GET and error scenarios
|
|
||||||
3. **Module Dependency Graph**: How modules depend on each other
|
|
||||||
4. **Data Model Relationships**: Database table structures
|
|
||||||
5. **Service Method Call Chain**: Stack of calls from Controller to DB
|
|
||||||
6. **Error Handling Flow**: Exception catching and wrapping
|
|
||||||
7. **Request Validation Pipeline**: Validation process
|
|
||||||
8. **Data Transformation Chain**: DTO → Entity → DB → Entity → ResponseDto
|
|
||||||
9. **Database Connection Lifecycle**: Connection initialization
|
|
||||||
10. **Environment to Runtime**: How .env becomes runtime config
|
|
||||||
11. **API Response Mapping**: Real example of transformation
|
|
||||||
|
|
||||||
*Generated: 2026-04-05*
|
|
||||||
266
CLAUDE.md
Normal file
266
CLAUDE.md
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
**MemeMind-Server** is a NestJS backend for a WeChat mini-game called MemeMind. The server handles user authentication via WeChat login, manages game levels and progress, supports social sharing/challenges, and maintains user profiles. All API responses use a standardized format and are exposed under `/api` with Swagger docs at `/api/docs`.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Framework**: NestJS 11 with TypeScript 5.7
|
||||||
|
- **Database**: MySQL (via TypeORM)
|
||||||
|
- **Authentication**: JWT (7-day expiration)
|
||||||
|
- **Package Manager**: pnpm (with pnpm-lock.yaml)
|
||||||
|
- **Testing**: Jest (unit tests alongside source, e2e tests in `test/`)
|
||||||
|
- **Code Quality**: ESLint + Prettier (single quotes, trailing commas)
|
||||||
|
- **Deployment**: PM2 with rsync to remote server
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── main.ts # App bootstrap (CORS, validation, Swagger)
|
||||||
|
├── app.module.ts # Root module importing all features
|
||||||
|
├── config/
|
||||||
|
│ ├── config.module.ts # Global config provider (env validation)
|
||||||
|
│ ├── env.validation.ts # Environment variable schema validation
|
||||||
|
│ └── database.config.ts # TypeORM configuration
|
||||||
|
├── database/
|
||||||
|
│ └── migrations/ # TypeORM migrations (not heavily used yet)
|
||||||
|
├── common/
|
||||||
|
│ ├── dto/api-response.dto.ts # Unified response wrapper (success/error)
|
||||||
|
│ ├── filters/http-exception.filter.ts # Global error handling
|
||||||
|
│ ├── guards/jwt-auth.guard.ts # JWT verification & payload extraction
|
||||||
|
│ └── decorators/current-user.decorator.ts # Param decorator for @CurrentUser()
|
||||||
|
└── modules/
|
||||||
|
├── auth/ # WeChat login, user creation, JWT issuance
|
||||||
|
├── user/ # User profile and stamina management
|
||||||
|
├── level/ # Game level progression tracking
|
||||||
|
├── share/ # Social challenge/share features
|
||||||
|
├── game-config/ # Game configuration endpoints
|
||||||
|
└── wechat-game/ # Shared game entities and repositories
|
||||||
|
|
||||||
|
docs/
|
||||||
|
├── api/ # API documentation (Markdown, auto-synced with code)
|
||||||
|
├── api/README.md # Index of all API modules
|
||||||
|
└── superpowers/ # Legacy or undocumented features
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Commands
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install # Install dependencies
|
||||||
|
pnpm run start:dev # Run with file watching (http://localhost:3000/api)
|
||||||
|
pnpm run start:debug # Debug mode with Node inspector
|
||||||
|
pnpm run build # Compile TypeScript to dist/
|
||||||
|
pnpm run start:prod # Run compiled production build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run lint # ESLint with auto-fix
|
||||||
|
pnpm run format # Prettier format (src/ and test/)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run test # All unit tests (*.spec.ts in src/)
|
||||||
|
pnpm run test:watch # Watch mode
|
||||||
|
pnpm run test:cov # Coverage report (outputs to coverage/)
|
||||||
|
pnpm run test:debug # Debug unit tests
|
||||||
|
pnpm run test:e2e # E2E tests (test/*.e2e-spec.ts)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run deploy # Build, rsync to server, restart PM2 cluster
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### Module Structure
|
||||||
|
|
||||||
|
Each feature module follows NestJS conventions:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// module.ts: Declares imports (other modules, TypeORM entities, services)
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Entity]), AuthModule],
|
||||||
|
controllers: [FeatureController],
|
||||||
|
providers: [FeatureService, CustomRepository],
|
||||||
|
exports: [FeatureService], // For cross-module injection
|
||||||
|
})
|
||||||
|
export class FeatureModule {}
|
||||||
|
|
||||||
|
// controller.ts: HTTP routing, DTO validation, Swagger decorators
|
||||||
|
@Controller('v1/feature')
|
||||||
|
export class FeatureController {
|
||||||
|
@Post() async create(@Body() dto: CreateDto): Promise<ApiResponseDto<ResponseDto>>
|
||||||
|
@UseGuards(JwtAuthGuard) // Applied per-endpoint
|
||||||
|
@Get(':id') async getOne(@Param('id') id: string, @CurrentUser() user: JwtPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// service.ts: Business logic, repository calls, external API calls
|
||||||
|
@Injectable()
|
||||||
|
export class FeatureService {
|
||||||
|
constructor(private readonly repo: CustomRepository) {}
|
||||||
|
async create(data: CreateDto): Promise<ResponseDto> { ... }
|
||||||
|
}
|
||||||
|
|
||||||
|
// repositories/: Custom data access logic (extend TypeORM repositories)
|
||||||
|
@Injectable()
|
||||||
|
export class CustomRepository extends Repository<Entity> {
|
||||||
|
async customQuery(): Promise<Entity[]> { ... }
|
||||||
|
}
|
||||||
|
|
||||||
|
// dto/: Request/response schemas with class-validator decorators
|
||||||
|
export class CreateDto {
|
||||||
|
@IsString() @MinLength(1) name: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication & Authorization
|
||||||
|
|
||||||
|
- **No global guard**: JWT validation is per-endpoint via `@UseGuards(JwtAuthGuard)`
|
||||||
|
- **User extraction**: Use `@CurrentUser()` to inject `JwtPayload { sub: userId, openid }`
|
||||||
|
- **WeChat integration**: `AuthService.wxLogin(code)` calls WeChat API, creates user on first login, issues JWT
|
||||||
|
|
||||||
|
### Request/Response Contract
|
||||||
|
|
||||||
|
All endpoints return `ApiResponseDto<T>`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
success: boolean, // true on success, false on error
|
||||||
|
data: T | null, // Response payload or null
|
||||||
|
message: string | null, // Error message (null on success)
|
||||||
|
timestamp: Date // ISO 8601 timestamp
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors are caught by `HttpExceptionFilter`, which formats exceptions as failed responses.
|
||||||
|
|
||||||
|
### Database
|
||||||
|
|
||||||
|
- **ORM**: TypeORM with MySQL
|
||||||
|
- **Entities**: Auto-loaded from `**/*.entity.ts`, sync mode in dev
|
||||||
|
- **Migrations**: Located in `src/database/migrations/` (minimal usage)
|
||||||
|
- **Connection**: Configured via `ConfigService` (env vars: DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_DATABASE)
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Environment variables are validated via `env.validation.ts` using class-validator:
|
||||||
|
|
||||||
|
```
|
||||||
|
NODE_ENV # development|production|test (default: development)
|
||||||
|
PORT # Server port (default: 3000)
|
||||||
|
DB_HOST # MySQL host (default: localhost)
|
||||||
|
DB_PORT # MySQL port (default: 3306)
|
||||||
|
DB_USERNAME # MySQL user (default: meme_user)
|
||||||
|
DB_PASSWORD # MySQL password (default: '')
|
||||||
|
DB_DATABASE # MySQL database (default: meme_mind)
|
||||||
|
WX_APPID # WeChat mini-game app ID
|
||||||
|
WX_SECRET # WeChat mini-game secret
|
||||||
|
JWT_SECRET # JWT signing secret
|
||||||
|
```
|
||||||
|
|
||||||
|
Config files loaded in order: `.env.local` → `.env` → `.env.production`. Use `.env.local` for local overrides (never committed).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Unit tests**: Colocated with source as `*.spec.ts` (e.g., `auth.service.spec.ts`)
|
||||||
|
- **E2E tests**: In `test/` directory (e.g., `test/app.e2e-spec.ts`)
|
||||||
|
- **Framework**: Jest with ts-jest compiler
|
||||||
|
- **Coverage**: Run `pnpm run test:cov` to generate coverage report in `coverage/`
|
||||||
|
- **Individual tests**: `pnpm run test -- --testNamePattern="test name"` or `pnpm run test:debug`
|
||||||
|
|
||||||
|
## API Documentation
|
||||||
|
|
||||||
|
API docs are maintained in `docs/api/` and **must be updated whenever controller/DTO changes occur**. The `api-doc-maintainer` skill automates this when editing `src/modules/*/`.
|
||||||
|
|
||||||
|
**File mapping**:
|
||||||
|
- `docs/api/auth-api.md` → `src/modules/auth/`
|
||||||
|
- `docs/api/game-api.md` → `src/modules/wechat-game/` & `src/modules/level/`
|
||||||
|
- `docs/api/share-challenge-api.md` → `src/modules/share/`
|
||||||
|
|
||||||
|
**When to update docs**:
|
||||||
|
- New endpoint added
|
||||||
|
- Request/response DTO fields changed
|
||||||
|
- Error codes or business logic modified
|
||||||
|
- Authentication requirements changed
|
||||||
|
|
||||||
|
Use the template in `AGENTS.md` (api-doc-maintainer skill) for consistent formatting.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
**Local to production**:
|
||||||
|
|
||||||
|
1. Ensure `.env.production` has correct credentials (WX_APPID, WX_SECRET, JWT_SECRET, DB credentials)
|
||||||
|
2. Run `pnpm run deploy` (triggers `deploy.sh`)
|
||||||
|
3. Script: builds locally → rsyncs `dist/`, `package.json`, `pnpm-lock.yaml` to `/var/www/MemeMind-Server/` on server → installs deps → restarts PM2 cluster (2 instances)
|
||||||
|
|
||||||
|
**PM2 config** (`ecosystem.config.js`): Cluster mode (2 instances), auto-restart, 1GB memory limit, logs to `logs/*.log`
|
||||||
|
|
||||||
|
**Important**: Before deploying, verify deploy.sh credentials (SERVER_IP, SERVER_USER, REMOTE_DIR, APP_NAME) match your target environment.
|
||||||
|
|
||||||
|
## Code Style & Conventions
|
||||||
|
|
||||||
|
- **TypeScript**: Strict mode enabled, no implicit `any`, explicit types on public APIs
|
||||||
|
- **Naming**: Classes PascalCase, methods/variables camelCase, directories kebab-case
|
||||||
|
- **DTO files**: Semantic names (e.g., `wx-login.dto.ts`, `share-response.dto.ts`)
|
||||||
|
- **Quotes**: Single quotes (enforced by Prettier)
|
||||||
|
- **Indentation**: 2 spaces
|
||||||
|
- **Commits**: Use Conventional Commits (e.g., `feat(auth):`, `fix(level):`, `docs(api):`)
|
||||||
|
|
||||||
|
## Key Integration Points
|
||||||
|
|
||||||
|
### Auth Module
|
||||||
|
|
||||||
|
- **Controller**: `POST /api/v1/auth/wx-login`
|
||||||
|
- **Flow**: WeChat code → `AuthService.wxLogin()` → calls WeChat API (`jscode2session`) → creates/updates user → signs JWT
|
||||||
|
- **Exports**: `AuthService`, `UserRepository`, `UserLevelProgressRepository` (used by other modules)
|
||||||
|
|
||||||
|
### User Module
|
||||||
|
|
||||||
|
- **Depends on**: AuthModule (for JWT guard, UserRepository)
|
||||||
|
- **Usage**: Profile endpoints, stamina management
|
||||||
|
|
||||||
|
### Level Module
|
||||||
|
|
||||||
|
- **Depends on**: AuthModule, UserModule, WechatGameModule
|
||||||
|
- **Entities**: Reused from WechatGameModule (Level, UserLevelProgress)
|
||||||
|
|
||||||
|
### Share Module
|
||||||
|
|
||||||
|
- **Entities**: ShareConfig, ShareParticipant, ShareLevelProgress (independent tables)
|
||||||
|
- **Depends on**: WechatGameModule, AuthModule
|
||||||
|
- **Pattern**: Multi-table repository pattern for complex queries
|
||||||
|
|
||||||
|
## Common Development Tasks
|
||||||
|
|
||||||
|
- **Add a new API endpoint**: Create DTO in `modules/{feature}/dto/`, add method to controller, implement in service, call `pnpm run lint && pnpm run test` to verify
|
||||||
|
- **Add a new entity**: Create `*.entity.ts` in module folder, add to TypeOrmModule.forFeature in module, update relevant repository
|
||||||
|
- **Modify API response format**: Update DTO/entity, regenerate Swagger docs, update `docs/api/*.md` manually if not auto-synced
|
||||||
|
- **Debug a failing test**: Run `pnpm run test:debug -- --testNamePattern="specific test"` and use Node inspector
|
||||||
|
- **Run migrations**: TypeORM in-dev synchronize mode auto-creates tables; for production, use TypeORM CLI or manual SQL
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **Port 3000 in use**: `lsof -i :3000` and `kill -9 <PID>`, or change PORT env var
|
||||||
|
- **Database connection fails**: Verify DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD in `.env.local` match your MySQL setup
|
||||||
|
- **JWT verification fails**: Check JWT_SECRET is consistent across app instances and `.env` files
|
||||||
|
- **Swagger not loading**: Ensure app.listen() completes; check browser console for CORS errors
|
||||||
|
- **Tests hanging**: Check for open database connections; run `pnpm run test:debug` to inspect
|
||||||
|
- **Prettier/ESLint conflicts**: Run `pnpm run format && pnpm run lint` in sequence (format first, then lint)
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- **NestJS docs**: https://docs.nestjs.com
|
||||||
|
- **TypeORM docs**: https://typeorm.io
|
||||||
|
- **Project-specific guidance**: See `AGENTS.md` for multi-agent collaboration patterns
|
||||||
@@ -1,836 +0,0 @@
|
|||||||
# MemeMind Client-Server Integration Guide
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This document explains how the Cocos Creator client communicates with the MemeMind-Server backend and what extensions would be needed to support the full game flow including user authentication, progress tracking, and point/life management.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 1: Current Integration (Read-Only)
|
|
||||||
|
|
||||||
### Current API Call: Get All Levels
|
|
||||||
|
|
||||||
**Client File**: `/Users/richard/Documents/code/cocosProject/mp-xieyingeng/assets/scripts/managers/LevelDataManager.ts`
|
|
||||||
|
|
||||||
**Current Implementation**:
|
|
||||||
```typescript
|
|
||||||
async initialize(): Promise<void> {
|
|
||||||
try {
|
|
||||||
// Initialize() is called by PageLoading during startup
|
|
||||||
const response = await HttpUtil.get<ApiResponse>(
|
|
||||||
'https://ilookai.cn/api/v1/wechat-game/levels'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.success && response.data?.levels) {
|
|
||||||
this._apiData = response.data.levels;
|
|
||||||
this._levelDataCache.clear();
|
|
||||||
|
|
||||||
// Preload next level images asynchronously
|
|
||||||
this.preloadNextLevel(0);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load level data', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Server Endpoint**:
|
|
||||||
```
|
|
||||||
GET /api/v1/wechat-game/levels
|
|
||||||
Status: 200
|
|
||||||
Response: ApiResponseDto<LevelListResponseDto>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response Format**:
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
levels: [
|
|
||||||
{
|
|
||||||
level: 1, // Level number (1-indexed)
|
|
||||||
id: "level-001", // Unique ID
|
|
||||||
imageUrl: "https://...", // Level image URL
|
|
||||||
answer: "meme", // Correct answer
|
|
||||||
hint1: "image", // First hint (free)
|
|
||||||
hint2: "funny", // Second hint (costs 1 life)
|
|
||||||
hint3: null, // Third hint (costs 1 life)
|
|
||||||
sortOrder: 0, // Display order
|
|
||||||
createdAt: "2026-04-01T...",
|
|
||||||
updatedAt: "2026-04-05T..."
|
|
||||||
},
|
|
||||||
...
|
|
||||||
],
|
|
||||||
total: 50
|
|
||||||
},
|
|
||||||
message: null,
|
|
||||||
timestamp: "2026-04-05T10:30:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Data Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
Client Startup
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
PageLoading.ts: _startPreload()
|
|
||||||
│
|
|
||||||
├─ LevelDataManager.initialize()
|
|
||||||
│ │
|
|
||||||
│ └─> HttpUtil.get('/api/v1/wechat-game/levels')
|
|
||||||
│ │
|
|
||||||
│ ▼
|
|
||||||
│ MemeMind-Server
|
|
||||||
│ │
|
|
||||||
│ ├─> WechatGameController.getAllLevels()
|
|
||||||
│ ├─> WechatGameService.getAllLevels()
|
|
||||||
│ ├─> LevelRepository.findAllOrdered()
|
|
||||||
│ └─> MySQL: SELECT * FROM levels ORDER BY sort_order
|
|
||||||
│
|
|
||||||
│ Response returned
|
|
||||||
│ │
|
|
||||||
│ ▼
|
|
||||||
│ LevelDataManager._apiData = levels
|
|
||||||
│ Preload images
|
|
||||||
│
|
|
||||||
├─ Progress: 80% -> 100%
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
PageHome displayed
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
User clicks "Start Game"
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
PageLevel loaded
|
|
||||||
│
|
|
||||||
├─> Reads from _apiData
|
|
||||||
├─> Displays level image, hints, input
|
|
||||||
└─> Ready for gameplay
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 2: Missing Features for Full Integration
|
|
||||||
|
|
||||||
### 1. User Authentication
|
|
||||||
|
|
||||||
**What's needed**:
|
|
||||||
- WeChat OpenID extraction via `wx.login()`
|
|
||||||
- Backend user registration/login endpoint
|
|
||||||
- JWT token generation and validation
|
|
||||||
- User context in requests
|
|
||||||
|
|
||||||
**Implementation Plan**:
|
|
||||||
|
|
||||||
**Server Addition**:
|
|
||||||
```typescript
|
|
||||||
// src/modules/users/users.module.ts
|
|
||||||
@Module({
|
|
||||||
controllers: [UsersController],
|
|
||||||
providers: [UsersService, UsersRepository],
|
|
||||||
imports: [TypeOrmModule.forFeature([User])],
|
|
||||||
})
|
|
||||||
export class UsersModule {}
|
|
||||||
|
|
||||||
// src/modules/users/entities/user.entity.ts
|
|
||||||
@Entity('users')
|
|
||||||
export class User {
|
|
||||||
@PrimaryGeneratedColumn('uuid')
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
@Column({ unique: true })
|
|
||||||
wxOpenId: string; // WeChat OpenID
|
|
||||||
|
|
||||||
@Column()
|
|
||||||
nickname: string;
|
|
||||||
|
|
||||||
@Column({ default: 10 })
|
|
||||||
currentLives: number;
|
|
||||||
|
|
||||||
@Column({ default: 0 })
|
|
||||||
currentLevelIndex: number;
|
|
||||||
|
|
||||||
@Column({ default: 0 })
|
|
||||||
totalPoints: number;
|
|
||||||
|
|
||||||
@CreateDateColumn()
|
|
||||||
createdAt: Date;
|
|
||||||
|
|
||||||
@UpdateDateColumn()
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Endpoint: POST /api/v1/users/login
|
|
||||||
@Post('login')
|
|
||||||
async login(@Body() dto: LoginDto): Promise<ApiResponseDto<LoginResponseDto>> {
|
|
||||||
// dto.code from wx.login()
|
|
||||||
// Exchange code for wxOpenId via WeChat API
|
|
||||||
// Create or fetch user
|
|
||||||
// Generate JWT token
|
|
||||||
// Return user + token
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Client Addition**:
|
|
||||||
```typescript
|
|
||||||
// In PageLoading.ts or main.ts
|
|
||||||
async function initializeUser() {
|
|
||||||
try {
|
|
||||||
const code = await wx.login();
|
|
||||||
const response = await HttpUtil.post('/api/v1/users/login', {
|
|
||||||
code: code.code
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
const { user, token } = response.data;
|
|
||||||
StorageManager.setToken(token); // New: Store JWT
|
|
||||||
StorageManager.setUserId(user.id); // New: Store user ID
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Login failed', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Level Submission & Answer Validation
|
|
||||||
|
|
||||||
**What's needed**:
|
|
||||||
- POST endpoint for level submissions
|
|
||||||
- Answer validation (case-insensitive, trim whitespace)
|
|
||||||
- Update user progress
|
|
||||||
- Award points/lives
|
|
||||||
- Handle wrong answers
|
|
||||||
|
|
||||||
**Implementation Plan**:
|
|
||||||
|
|
||||||
**Server Addition**:
|
|
||||||
```typescript
|
|
||||||
// src/modules/levels/level-submission.module.ts
|
|
||||||
@Module({
|
|
||||||
controllers: [LevelSubmissionController],
|
|
||||||
providers: [LevelSubmissionService],
|
|
||||||
imports: [
|
|
||||||
TypeOrmModule.forFeature([UserProgress, Level]),
|
|
||||||
UsersModule,
|
|
||||||
WechatGameModule,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
export class LevelSubmissionModule {}
|
|
||||||
|
|
||||||
// Endpoint: POST /api/v1/levels/:levelId/submit
|
|
||||||
@Post(':levelId/submit')
|
|
||||||
@UseGuards(JwtAuthGuard) // Require authentication
|
|
||||||
async submitAnswer(
|
|
||||||
@Param('levelId') levelId: string,
|
|
||||||
@Body() dto: SubmitAnswerDto,
|
|
||||||
@Req() request: any,
|
|
||||||
): Promise<ApiResponseDto<SubmitAnswerResponseDto>> {
|
|
||||||
// request.user.id from JWT
|
|
||||||
// Validate answer (case-insensitive, trim)
|
|
||||||
// If correct:
|
|
||||||
// - Award 1 life
|
|
||||||
// - Update currentLevelIndex
|
|
||||||
// - Record submission
|
|
||||||
// If wrong:
|
|
||||||
// - Record wrong attempt
|
|
||||||
// - Maybe deduct lives?
|
|
||||||
// Return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// src/modules/levels/dto/submit-answer.dto.ts
|
|
||||||
export class SubmitAnswerDto {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
answer: string;
|
|
||||||
|
|
||||||
@IsNumber()
|
|
||||||
timeTaken: number; // Seconds to solve
|
|
||||||
|
|
||||||
@IsNumber()
|
|
||||||
hintsUsed: number; // How many hints revealed
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SubmitAnswerResponseDto {
|
|
||||||
success: boolean; // Correct answer?
|
|
||||||
message: string; // "Correct!" or "Wrong!"
|
|
||||||
newLives: number; // Updated lives
|
|
||||||
newLevel: number; // Next level index
|
|
||||||
pointsEarned: number; // Points for this solve
|
|
||||||
totalPoints: number; // Total accumulated points
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Client Change**:
|
|
||||||
```typescript
|
|
||||||
// In PageLevel.ts: showSuccess() method
|
|
||||||
private async showSuccess(): void {
|
|
||||||
this.stopCountdown();
|
|
||||||
this.playSuccessSound();
|
|
||||||
|
|
||||||
// NEW: Submit to server
|
|
||||||
try {
|
|
||||||
const token = StorageManager.getToken();
|
|
||||||
const response = await HttpUtil.post(
|
|
||||||
`/api/v1/levels/${this._currentLevel.id}/submit`,
|
|
||||||
{
|
|
||||||
answer: this._userAnswer,
|
|
||||||
timeTaken: this._elapsedTime,
|
|
||||||
hintsUsed: this._hintsUsed
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
// Update local storage with new progress
|
|
||||||
StorageManager.setLives(response.data.newLives);
|
|
||||||
StorageManager.onLevelCompleted(response.data.newLevel - 1);
|
|
||||||
|
|
||||||
// Show points earned
|
|
||||||
this.showPointsNotification(response.data.pointsEarned);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Submission failed', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._showPassModal();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. User Progress Tracking
|
|
||||||
|
|
||||||
**What's needed**:
|
|
||||||
- Endpoint to get user progress
|
|
||||||
- Endpoint to update progress
|
|
||||||
- Sync between client localStorage and server
|
|
||||||
- Handle offline mode
|
|
||||||
|
|
||||||
**Implementation Plan**:
|
|
||||||
|
|
||||||
**Server Addition**:
|
|
||||||
```typescript
|
|
||||||
// Endpoint: GET /api/v1/users/me/progress
|
|
||||||
@Get('me/progress')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
async getProgress(
|
|
||||||
@Req() request: any,
|
|
||||||
): Promise<ApiResponseDto<UserProgressDto>> {
|
|
||||||
// request.user.id from JWT
|
|
||||||
// Return user progress
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UserProgressDto {
|
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
currentLevelIndex: number;
|
|
||||||
maxLevelUnlocked: number;
|
|
||||||
totalPoints: number;
|
|
||||||
currentLives: number;
|
|
||||||
completedLevels: {
|
|
||||||
levelId: string;
|
|
||||||
completedAt: Date;
|
|
||||||
timeTaken: number;
|
|
||||||
hintsUsed: number;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Endpoint: POST /api/v1/users/me/progress/sync
|
|
||||||
@Post('me/progress/sync')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
async syncProgress(
|
|
||||||
@Req() request: any,
|
|
||||||
@Body() dto: SyncProgressDto,
|
|
||||||
): Promise<ApiResponseDto<SyncProgressResponseDto>> {
|
|
||||||
// Merge client progress with server
|
|
||||||
// Handle conflicts (prefer latest)
|
|
||||||
// Return merged progress
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Client Update**:
|
|
||||||
```typescript
|
|
||||||
// In StorageManager.ts: Add sync methods
|
|
||||||
static async syncWithServer(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const token = this.getToken();
|
|
||||||
const localProgress = this.getCurrentLevelIndex();
|
|
||||||
const localLives = this.getLives();
|
|
||||||
|
|
||||||
const response = await HttpUtil.post(
|
|
||||||
'/api/v1/users/me/progress/sync',
|
|
||||||
{
|
|
||||||
currentLevelIndex: localProgress,
|
|
||||||
currentLives: localLives,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
// Update local with server's merged data
|
|
||||||
this.setLives(response.data.currentLives);
|
|
||||||
// Update progress for each completed level
|
|
||||||
response.data.completedLevels.forEach((level) => {
|
|
||||||
this.onLevelCompleted(level.levelIndex);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Sync failed', error);
|
|
||||||
// Continue with local data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call on app startup and periodically
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. Hint Usage & Cost Management
|
|
||||||
|
|
||||||
**What's needed**:
|
|
||||||
- Track which hints have been used
|
|
||||||
- Deduct lives when using premium hints
|
|
||||||
- Prevent excessive hint usage
|
|
||||||
|
|
||||||
**Implementation Plan**:
|
|
||||||
|
|
||||||
**Server Addition**:
|
|
||||||
```typescript
|
|
||||||
// Track hint usage per level per user
|
|
||||||
@Entity('level_hints')
|
|
||||||
export class LevelHint {
|
|
||||||
@PrimaryGeneratedColumn('uuid')
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
@ManyToOne(() => User)
|
|
||||||
user: User;
|
|
||||||
|
|
||||||
@Column()
|
|
||||||
levelId: string;
|
|
||||||
|
|
||||||
@Column()
|
|
||||||
hint1Revealed: boolean; // Always free
|
|
||||||
|
|
||||||
@Column()
|
|
||||||
hint2Revealed: boolean; // Costs 1 life
|
|
||||||
|
|
||||||
@Column()
|
|
||||||
hint3Revealed: boolean; // Costs 1 life
|
|
||||||
|
|
||||||
@CreateDateColumn()
|
|
||||||
createdAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Endpoint: POST /api/v1/levels/:levelId/reveal-hint
|
|
||||||
@Post(':levelId/reveal-hint')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
async revealHint(
|
|
||||||
@Param('levelId') levelId: string,
|
|
||||||
@Body() dto: RevealHintDto, // { hintNumber: 2 }
|
|
||||||
@Req() request: any,
|
|
||||||
): Promise<ApiResponseDto<RevealHintResponseDto>> {
|
|
||||||
// Validate hint number
|
|
||||||
// Check if already revealed
|
|
||||||
// If hint 2 or 3: deduct 1 life
|
|
||||||
// Update hint tracking
|
|
||||||
// Return hint text
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Client Change**:
|
|
||||||
```typescript
|
|
||||||
// In PageLevel.ts: onUnlockClue() method
|
|
||||||
private async onUnlockClue(clueIndex: number): void {
|
|
||||||
const token = StorageManager.getToken();
|
|
||||||
|
|
||||||
// Hint index 1 is always free (hint1)
|
|
||||||
// Hints 2 and 3 cost 1 life each
|
|
||||||
if (clueIndex > 0 && !this._freeCluePassed) {
|
|
||||||
const currentLives = StorageManager.getLives();
|
|
||||||
if (currentLives <= 0) {
|
|
||||||
this._showToast('没有生命了!(No lives left!)');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await HttpUtil.post(
|
|
||||||
`/api/v1/levels/${this._currentLevel.id}/reveal-hint`,
|
|
||||||
{ hintNumber: clueIndex + 1 },
|
|
||||||
{ headers: { Authorization: `Bearer ${token}` } }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
const hint = response.data.hintText;
|
|
||||||
this._showHint(hint);
|
|
||||||
|
|
||||||
// Deduct life if premium hint
|
|
||||||
if (clueIndex > 0) {
|
|
||||||
StorageManager.consumeLife();
|
|
||||||
StorageManager.setLives(response.data.remainingLives);
|
|
||||||
this._updateLivesDisplay();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to unlock hint', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. Leaderboard & Statistics
|
|
||||||
|
|
||||||
**What's needed**:
|
|
||||||
- Track total points per user
|
|
||||||
- Track completion time
|
|
||||||
- Leaderboard endpoint
|
|
||||||
- User statistics endpoint
|
|
||||||
|
|
||||||
**Implementation Plan**:
|
|
||||||
|
|
||||||
**Server Addition**:
|
|
||||||
```typescript
|
|
||||||
// Endpoint: GET /api/v1/leaderboard?limit=100
|
|
||||||
@Get('leaderboard')
|
|
||||||
async getLeaderboard(
|
|
||||||
@Query('limit') limit: number = 100,
|
|
||||||
): Promise<ApiResponseDto<LeaderboardDto>> {
|
|
||||||
// Select top users by totalPoints
|
|
||||||
// Include rank, nickname, points, completedLevels
|
|
||||||
}
|
|
||||||
|
|
||||||
// Endpoint: GET /api/v1/users/me/statistics
|
|
||||||
@Get('me/statistics')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
async getStatistics(
|
|
||||||
@Req() request: any,
|
|
||||||
): Promise<ApiResponseDto<StatisticsDto>> {
|
|
||||||
// Return user statistics
|
|
||||||
// Total levels completed
|
|
||||||
// Average time per level
|
|
||||||
// Total hints used
|
|
||||||
// Current streak, etc.
|
|
||||||
}
|
|
||||||
|
|
||||||
export class StatisticsDto {
|
|
||||||
totalLevelsCompleted: number;
|
|
||||||
currentLevelIndex: number;
|
|
||||||
totalPoints: number;
|
|
||||||
currentLives: number;
|
|
||||||
averageTimePerLevel: number; // Seconds
|
|
||||||
totalTimeSpent: number; // Seconds
|
|
||||||
totalHintsUsed: number;
|
|
||||||
perfectSolves: number; // Solved in first try
|
|
||||||
longestStreak: number;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 3: API Authentication Pattern
|
|
||||||
|
|
||||||
### JWT Guard Implementation
|
|
||||||
|
|
||||||
**Server**:
|
|
||||||
```typescript
|
|
||||||
// src/modules/auth/guards/jwt.guard.ts
|
|
||||||
@Injectable()
|
|
||||||
export class JwtAuthGuard implements CanActivate {
|
|
||||||
constructor(private readonly configService: ConfigService) {}
|
|
||||||
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
|
||||||
const request = context.switchToHttp().getRequest();
|
|
||||||
const token = this.extractTokenFromHeader(request);
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
throw new UnauthorizedException('No token provided');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const secret = this.configService.get('JWT_SECRET');
|
|
||||||
const decoded = verify(token, secret);
|
|
||||||
request.user = decoded; // Attach to request
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
throw new UnauthorizedException('Invalid token');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractTokenFromHeader(request): string | undefined {
|
|
||||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
|
||||||
return type === 'Bearer' ? token : undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage on endpoints
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Get('me/progress')
|
|
||||||
async getProgress(@Req() request: any) {
|
|
||||||
const userId = request.user.id; // From JWT payload
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Client**:
|
|
||||||
```typescript
|
|
||||||
// In HttpUtil.ts: Add auth header
|
|
||||||
static async post<T>(
|
|
||||||
url: string,
|
|
||||||
data: any,
|
|
||||||
options?: RequestOptions
|
|
||||||
): Promise<ApiResponse<T>> {
|
|
||||||
const token = StorageManager.getToken();
|
|
||||||
|
|
||||||
const headers = {
|
|
||||||
...options?.headers,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make request with headers
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 4: Data Sync Strategy
|
|
||||||
|
|
||||||
### Scenario 1: Online Mode
|
|
||||||
|
|
||||||
```
|
|
||||||
User completes level
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
[Client] Submits answer
|
|
||||||
│
|
|
||||||
├─ HTTP POST /api/v1/levels/:id/submit
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
[Server] Validates and updates
|
|
||||||
│
|
|
||||||
├─ Check answer
|
|
||||||
├─ Award life/points
|
|
||||||
├─ Update progress
|
|
||||||
├─ Store submission
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
[Response] Returned to client
|
|
||||||
│
|
|
||||||
├─ Update localStorage
|
|
||||||
├─ Show success modal
|
|
||||||
├─ Move to next level
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Progress synced
|
|
||||||
```
|
|
||||||
|
|
||||||
### Scenario 2: Offline Mode
|
|
||||||
|
|
||||||
```
|
|
||||||
User completes level (no connection)
|
|
||||||
│
|
|
||||||
├─ Submit fails (no network)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
[Client] Stores locally
|
|
||||||
│
|
|
||||||
├─ StorageManager.recordOfflineSubmission()
|
|
||||||
├─ Update lives/progress locally
|
|
||||||
├─ Show success modal (assume correct)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
When connection returns
|
|
||||||
│
|
|
||||||
├─ StorageManager.syncPendingSubmissions()
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
[Server] Receives batch of submissions
|
|
||||||
│
|
|
||||||
├─ Validate all answers
|
|
||||||
├─ Apply corrections if needed
|
|
||||||
├─ Return merged state
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
[Client] Reconciles state
|
|
||||||
│
|
|
||||||
├─ If conflicts: server wins
|
|
||||||
├─ Update localStorage
|
|
||||||
├─ Show notification of changes
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Progress synced
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 5: Implementation Roadmap
|
|
||||||
|
|
||||||
### Phase 1: Basic Auth (Week 1)
|
|
||||||
- [ ] User entity & table
|
|
||||||
- [ ] Login endpoint with wx.login() code exchange
|
|
||||||
- [ ] JWT token generation
|
|
||||||
- [ ] JwtAuthGuard for protected routes
|
|
||||||
- [ ] Client login integration
|
|
||||||
- [ ] Token storage in StorageManager
|
|
||||||
|
|
||||||
### Phase 2: Progress Tracking (Week 2)
|
|
||||||
- [ ] UserProgress entity & table
|
|
||||||
- [ ] GET /api/v1/users/me/progress
|
|
||||||
- [ ] POST /api/v1/levels/:id/submit
|
|
||||||
- [ ] Update client PageLevel.ts to submit answers
|
|
||||||
- [ ] Sync endpoint for merging progress
|
|
||||||
- [ ] Offline submission queue
|
|
||||||
|
|
||||||
### Phase 3: Hint System Integration (Week 3)
|
|
||||||
- [ ] LevelHint entity & tracking
|
|
||||||
- [ ] POST /api/v1/levels/:id/reveal-hint
|
|
||||||
- [ ] Life deduction logic
|
|
||||||
- [ ] Client hint unlock cost
|
|
||||||
|
|
||||||
### Phase 4: Leaderboard & Stats (Week 4)
|
|
||||||
- [ ] Statistics calculation
|
|
||||||
- [ ] Leaderboard endpoint
|
|
||||||
- [ ] Client leaderboard page
|
|
||||||
- [ ] Personal statistics page
|
|
||||||
|
|
||||||
### Phase 5: Polish & Optimization (Week 5)
|
|
||||||
- [ ] Caching layer (@nestjs/cache-manager)
|
|
||||||
- [ ] Rate limiting (@nestjs/throttler)
|
|
||||||
- [ ] Request logging middleware
|
|
||||||
- [ ] Performance monitoring
|
|
||||||
- [ ] Database indexing optimization
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 6: Environment Configuration
|
|
||||||
|
|
||||||
### Server .env.production
|
|
||||||
|
|
||||||
```bash
|
|
||||||
NODE_ENV=production
|
|
||||||
PORT=3000
|
|
||||||
|
|
||||||
# Database
|
|
||||||
DB_HOST=production-db-host
|
|
||||||
DB_PORT=3306
|
|
||||||
DB_USERNAME=prod_user
|
|
||||||
DB_PASSWORD=secure_password
|
|
||||||
DB_DATABASE=meme_mind_prod
|
|
||||||
|
|
||||||
# Authentication
|
|
||||||
JWT_SECRET=very-secure-secret-key-32-chars-min
|
|
||||||
JWT_EXPIRATION=7d
|
|
||||||
|
|
||||||
# WeChat
|
|
||||||
WECHAT_APPID=your_wechat_appid
|
|
||||||
WECHAT_SECRET=your_wechat_secret
|
|
||||||
|
|
||||||
# API
|
|
||||||
API_BASE_URL=https://ilookai.cn/api
|
|
||||||
CORS_ORIGIN=https://yourdomain.com
|
|
||||||
```
|
|
||||||
|
|
||||||
### Client Storage Keys
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// New keys needed:
|
|
||||||
- 'auth_token' → JWT token
|
|
||||||
- 'user_id' → Current user ID
|
|
||||||
- 'offline_submissions' → Queue of submissions to send
|
|
||||||
- 'last_sync' → Timestamp of last sync
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Part 7: Error Handling
|
|
||||||
|
|
||||||
### Common API Errors
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 401 Unauthorized
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
data: null,
|
|
||||||
message: 'Invalid token',
|
|
||||||
path: '/api/v1/users/me/progress'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 404 Not Found
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
data: null,
|
|
||||||
message: 'Level with id "xyz" not found',
|
|
||||||
path: '/api/v1/levels/xyz'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 400 Bad Request (validation)
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
data: null,
|
|
||||||
message: 'Validation failed: answer must be a string',
|
|
||||||
path: '/api/v1/levels/123/submit'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 500 Server Error
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
data: null,
|
|
||||||
message: 'Internal server error',
|
|
||||||
path: '/api/v1/levels'
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Client Handling
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
async function handleApiError(error: unknown) {
|
|
||||||
if (axios.isAxiosError(error)) {
|
|
||||||
const status = error.response?.status;
|
|
||||||
const message = error.response?.data?.message;
|
|
||||||
|
|
||||||
if (status === 401) {
|
|
||||||
// Token expired - redirect to login
|
|
||||||
StorageManager.clearToken();
|
|
||||||
navigateTo('PageHome');
|
|
||||||
} else if (status === 404) {
|
|
||||||
showToast(`Not found: ${message}`);
|
|
||||||
} else if (status === 400) {
|
|
||||||
showToast(`Invalid input: ${message}`);
|
|
||||||
} else {
|
|
||||||
showToast(`Error: ${message || 'Unknown error'}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
The current integration only handles reading level data. To support the full game loop with:
|
|
||||||
- ✅ User authentication (JWT)
|
|
||||||
- ✅ Answer submission & validation
|
|
||||||
- ✅ Progress tracking & sync
|
|
||||||
- ✅ Hint system with cost management
|
|
||||||
- ✅ Leaderboard & statistics
|
|
||||||
|
|
||||||
You need to implement the endpoints, models, and client logic described in this guide. The roadmap suggests a 5-week implementation with phases for each feature.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Generated: 2026-04-05 | For MemeMind Project*
|
|
||||||
1191
SERVER_ANALYSIS.md
1191
SERVER_ANALYSIS.md
File diff suppressed because it is too large
Load Diff
291
docs/api-changelog-v1.1.0.md
Normal file
291
docs/api-changelog-v1.1.0.md
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
# MemeMind API 变更文档 — 双图关卡 & 体力上限调整
|
||||||
|
|
||||||
|
> **版本**:v1.1.0
|
||||||
|
> **日期**:2026-04-19
|
||||||
|
> **影响范围**:关卡列表、进入关卡、分享挑战、体力系统
|
||||||
|
> **兼容性**:⚠️ Breaking Change — 客户端必须适配后方可上线
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、变更概览
|
||||||
|
|
||||||
|
| 变更项 | 旧值 | 新值 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 关卡图片数量 | 1 张(`imageUrl`) | 2 张(`image1Url` + `image2Url`) |
|
||||||
|
| 图片文本说明 | 无 | 每张图片各有一个 `description` 字段 |
|
||||||
|
| 谐音梗说明 | 无 | 新增 `punchline` 字段 |
|
||||||
|
| 体力上限 | 5 | **50** |
|
||||||
|
| 新用户默认体力 | 5 | **50** |
|
||||||
|
| 体力恢复速率 | 每 10 分钟 1 点 | **不变** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、体力系统变更
|
||||||
|
|
||||||
|
### StaminaInfo 结构(不变,数值范围扩大)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface StaminaInfo {
|
||||||
|
current: number; // 0 ~ 50(原 0 ~ 5)
|
||||||
|
max: number; // 固定 50(原 5)
|
||||||
|
nextRecoverAt: string | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**客户端注意**:
|
||||||
|
- 体力 UI 需要适配 0–50 的显示范围
|
||||||
|
- 恢复速率不变,满体力恢复时间从 50 分钟变为 500 分钟(约 8.3 小时)
|
||||||
|
- 进入关卡仍消耗 1 点体力,已通关关卡仍免费
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、接口字段变更
|
||||||
|
|
||||||
|
### 3.1 `GET /api/v1/levels` — 获取关卡列表
|
||||||
|
|
||||||
|
**删除字段**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| ~~`imageUrl`~~ | ~~string~~ | 已删除,替换为下方双图字段 |
|
||||||
|
|
||||||
|
**新增字段**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `image1Url` | string | 图片1 URL |
|
||||||
|
| `image1Description` | string \| null | 图片1 文本说明 |
|
||||||
|
| `image2Url` | string | 图片2 URL |
|
||||||
|
| `image2Description` | string \| null | 图片2 文本说明 |
|
||||||
|
| `punchline` | string \| null | 谐音梗说明(**仅通关后返回**,未通关为 null) |
|
||||||
|
|
||||||
|
**完整响应示例**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"levels": [
|
||||||
|
{
|
||||||
|
"id": "level_001",
|
||||||
|
"level": 1,
|
||||||
|
"image1Url": "https://cdn.example.com/levels/001_1.png",
|
||||||
|
"image1Description": "一只猫在看鱼",
|
||||||
|
"image2Url": "https://cdn.example.com/levels/001_2.png",
|
||||||
|
"image2Description": "一条鱼在飞",
|
||||||
|
"answer": "猫和鱼",
|
||||||
|
"punchline": "谐音梗:鱼跃龙门 → 鱼越猫门",
|
||||||
|
"hint1": "这是一个经典的...",
|
||||||
|
"hint2": "和某个动物有关",
|
||||||
|
"hint3": null,
|
||||||
|
"completed": true,
|
||||||
|
"timeSpent": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "level_002",
|
||||||
|
"level": 2,
|
||||||
|
"image1Url": "https://cdn.example.com/levels/002_1.png",
|
||||||
|
"image1Description": "一个人在走路",
|
||||||
|
"image2Url": "https://cdn.example.com/levels/002_2.png",
|
||||||
|
"image2Description": "一辆车在跑",
|
||||||
|
"answer": null,
|
||||||
|
"punchline": null,
|
||||||
|
"hint1": null,
|
||||||
|
"hint2": null,
|
||||||
|
"hint3": null,
|
||||||
|
"completed": false,
|
||||||
|
"timeSpent": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 2
|
||||||
|
},
|
||||||
|
"message": null,
|
||||||
|
"timestamp": "2026-04-19T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **可见性规则**:`answer`、`punchline`、`hint1`、`hint2`、`hint3` 仅在 `completed: true` 时返回,未通关均为 `null`。`image1Url`、`image1Description`、`image2Url`、`image2Description` 始终返回。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 `POST /api/v1/levels/{id}/enter` — 进入关卡
|
||||||
|
|
||||||
|
**删除字段**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| ~~`imageUrl`~~ | ~~string~~ | 已删除 |
|
||||||
|
|
||||||
|
**新增字段**:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `image1Url` | string | 图片1 URL |
|
||||||
|
| `image1Description` | string \| null | 图片1 文本说明 |
|
||||||
|
| `image2Url` | string | 图片2 URL |
|
||||||
|
| `image2Description` | string \| null | 图片2 文本说明 |
|
||||||
|
| `punchline` | string \| null | 谐音梗说明 |
|
||||||
|
|
||||||
|
**完整响应示例**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"id": "level_002",
|
||||||
|
"level": 2,
|
||||||
|
"image1Url": "https://cdn.example.com/levels/002_1.png",
|
||||||
|
"image1Description": "一个人在走路",
|
||||||
|
"image2Url": "https://cdn.example.com/levels/002_2.png",
|
||||||
|
"image2Description": "一辆车在跑",
|
||||||
|
"answer": "人车赛跑",
|
||||||
|
"punchline": "谐音梗:车水马龙 → 车水人龙",
|
||||||
|
"hint1": "第一个线索",
|
||||||
|
"hint2": "第二个线索",
|
||||||
|
"hint3": null,
|
||||||
|
"stamina": {
|
||||||
|
"current": 47,
|
||||||
|
"max": 50,
|
||||||
|
"nextRecoverAt": "2026-04-19T12:10:00.000Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"message": null,
|
||||||
|
"timestamp": "2026-04-19T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**:进入关卡时 `answer` 和 `punchline` 始终返回(无论是否通关),因为用户已消耗体力进入。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 `POST /api/v1/share/{shareCode}/join` — 加入分享挑战
|
||||||
|
|
||||||
|
分享挑战中的关卡数据同步变更。
|
||||||
|
|
||||||
|
**删除字段**(`levels[]` 中每个关卡):
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| ~~`imageUrl`~~ | ~~string~~ | 已删除 |
|
||||||
|
|
||||||
|
**新增字段**(`levels[]` 中每个关卡):
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `image1Url` | string | 图片1 URL |
|
||||||
|
| `image1Description` | string \| null | 图片1 文本说明 |
|
||||||
|
| `image2Url` | string | 图片2 URL |
|
||||||
|
| `image2Description` | string \| null | 图片2 文本说明 |
|
||||||
|
| `punchline` | string \| null | 谐音梗说明 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.4 未变更的接口
|
||||||
|
|
||||||
|
以下接口**无任何变更**,客户端无需修改:
|
||||||
|
|
||||||
|
| 接口 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `POST /api/v1/auth/wx-login` | 登录(新用户 stamina 初始为 50,但 login 返回结构不变) |
|
||||||
|
| `GET /api/v1/user/profile` | 用户资料(stamina.max 变为 50,结构不变) |
|
||||||
|
| `GET /api/v1/user/game-data` | 游戏数据(stamina.max 变为 50,结构不变) |
|
||||||
|
| `POST /api/v1/levels/{id}/complete` | 通关上报(结构完全不变) |
|
||||||
|
| `GET /api/v1/game-configs` | 游戏配置(不变) |
|
||||||
|
| `GET /api/v1/game-configs/{key}` | 单个配置(不变) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、客户端适配清单
|
||||||
|
|
||||||
|
### 必须修改
|
||||||
|
|
||||||
|
- [ ] 所有使用 `imageUrl` 的地方改为 `image1Url` + `image2Url`
|
||||||
|
- [ ] 关卡详情页展示两张图片,每张图片下方展示 `image1Description` / `image2Description`
|
||||||
|
- [ ] 通关后展示 `punchline`(谐音梗说明)
|
||||||
|
- [ ] 体力 UI 适配 0–50 范围(进度条、数字显示等)
|
||||||
|
- [ ] 更新 TypeScript 接口定义(见下方)
|
||||||
|
|
||||||
|
### 建议修改
|
||||||
|
|
||||||
|
- [ ] 体力恢复倒计时逻辑无需修改(恢复速率不变)
|
||||||
|
- [ ] `punchline` 为 `null` 时不展示(未配置谐音梗的关卡)
|
||||||
|
- [ ] `image2Url` 为空字符串时做兜底处理(历史关卡可能尚未配置第二张图)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、客户端 TypeScript 接口定义
|
||||||
|
|
||||||
|
直接复制替换旧接口:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/** 关卡列表项 */
|
||||||
|
interface LevelListItem {
|
||||||
|
id: string;
|
||||||
|
level: number;
|
||||||
|
image1Url: string;
|
||||||
|
image1Description: string | null;
|
||||||
|
image2Url: string;
|
||||||
|
image2Description: string | null;
|
||||||
|
answer: string | null;
|
||||||
|
punchline: string | null;
|
||||||
|
hint1: string | null;
|
||||||
|
hint2: string | null;
|
||||||
|
hint3: string | null;
|
||||||
|
completed: boolean;
|
||||||
|
timeSpent: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 进入关卡响应 */
|
||||||
|
interface EnterLevelResponse {
|
||||||
|
id: string;
|
||||||
|
level: number;
|
||||||
|
image1Url: string;
|
||||||
|
image1Description: string | null;
|
||||||
|
image2Url: string;
|
||||||
|
image2Description: string | null;
|
||||||
|
answer: string;
|
||||||
|
punchline: string | null;
|
||||||
|
hint1: string | null;
|
||||||
|
hint2: string | null;
|
||||||
|
hint3: string | null;
|
||||||
|
stamina: StaminaInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 体力信息(结构不变,数值范围 0-50) */
|
||||||
|
interface StaminaInfo {
|
||||||
|
current: number;
|
||||||
|
max: number; // 50
|
||||||
|
nextRecoverAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分享关卡 */
|
||||||
|
interface ShareLevel {
|
||||||
|
id: string;
|
||||||
|
level: number;
|
||||||
|
image1Url: string;
|
||||||
|
image1Description: string | null;
|
||||||
|
image2Url: string;
|
||||||
|
image2Description: string | null;
|
||||||
|
answer: string;
|
||||||
|
punchline: string | null;
|
||||||
|
hint1: string | null;
|
||||||
|
hint2: string | null;
|
||||||
|
hint3: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、字段映射速查表
|
||||||
|
|
||||||
|
方便全局搜索替换:
|
||||||
|
|
||||||
|
| 旧字段 | 新字段 | 备注 |
|
||||||
|
|--------|--------|------|
|
||||||
|
| `imageUrl` | `image1Url` | 原图片字段,直接重命名 |
|
||||||
|
| — | `image1Description` | 新增,图片1 说明文字 |
|
||||||
|
| — | `image2Url` | 新增,第二张图片 |
|
||||||
|
| — | `image2Description` | 新增,图片2 说明文字 |
|
||||||
|
| — | `punchline` | 新增,谐音梗说明 |
|
||||||
|
| `stamina.max = 5` | `stamina.max = 50` | 数值变更 |
|
||||||
@@ -91,8 +91,8 @@ Authorization: Bearer <token>
|
|||||||
|
|
||||||
| 属性 | 值 |
|
| 属性 | 值 |
|
||||||
|------|-----|
|
|------|-----|
|
||||||
| 默认体力 | 5(新用户注册时) |
|
| 默认体力 | 50(新用户注册时) |
|
||||||
| 上限 | 5 |
|
| 上限 | 50 |
|
||||||
| 恢复速度 | 每 **10 分钟** 恢复 1 点 |
|
| 恢复速度 | 每 **10 分钟** 恢复 1 点 |
|
||||||
| 消耗 | 进入**未通关**关卡时消耗 1 点 |
|
| 消耗 | 进入**未通关**关卡时消耗 1 点 |
|
||||||
| 已通关关卡 | 再次进入不消耗体力 |
|
| 已通关关卡 | 再次进入不消耗体力 |
|
||||||
@@ -104,7 +104,7 @@ Authorization: Bearer <token>
|
|||||||
```typescript
|
```typescript
|
||||||
interface StaminaInfo {
|
interface StaminaInfo {
|
||||||
current: number; // 当前体力值(已计算恢复)
|
current: number; // 当前体力值(已计算恢复)
|
||||||
max: number; // 体力上限,固定为 5
|
max: number; // 体力上限,固定为 50
|
||||||
nextRecoverAt: string | null; // 下一点体力恢复的时间(ISO 8601),满体力时为 null
|
nextRecoverAt: string | null; // 下一点体力恢复的时间(ISO 8601),满体力时为 null
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -113,8 +113,8 @@ interface StaminaInfo {
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"current": 3,
|
"current": 45,
|
||||||
"max": 5,
|
"max": 50,
|
||||||
"nextRecoverAt": "2026-04-10T12:10:00.000Z"
|
"nextRecoverAt": "2026-04-10T12:10:00.000Z"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -168,7 +168,7 @@ interface StaminaInfo {
|
|||||||
"user": {
|
"user": {
|
||||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||||
"nickname": null,
|
"nickname": null,
|
||||||
"stamina": 5
|
"stamina": 50
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"message": null,
|
"message": null,
|
||||||
@@ -211,8 +211,8 @@ interface StaminaInfo {
|
|||||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||||
"nickname": null,
|
"nickname": null,
|
||||||
"stamina": {
|
"stamina": {
|
||||||
"current": 3,
|
"current": 45,
|
||||||
"max": 5,
|
"max": 50,
|
||||||
"nextRecoverAt": "2026-04-10T12:10:00.000Z"
|
"nextRecoverAt": "2026-04-10T12:10:00.000Z"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -258,8 +258,8 @@ interface StaminaInfo {
|
|||||||
"user": {
|
"user": {
|
||||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||||
"stamina": {
|
"stamina": {
|
||||||
"current": 5,
|
"current": 50,
|
||||||
"max": 5,
|
"max": 50,
|
||||||
"nextRecoverAt": null
|
"nextRecoverAt": null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -278,7 +278,7 @@ interface StaminaInfo {
|
|||||||
|
|
||||||
### 4. 获取关卡列表
|
### 4. 获取关卡列表
|
||||||
|
|
||||||
获取所有关卡列表。**已通关的关卡**返回答案和线索,**未通关的关卡**不返回敏感数据。
|
获取所有关卡列表。**已通关的关卡**返回答案、谐音梗说明和线索,**未通关的关卡**不返回敏感数据。
|
||||||
|
|
||||||
**接口地址**:`GET /api/v1/levels`
|
**接口地址**:`GET /api/v1/levels`
|
||||||
|
|
||||||
@@ -297,8 +297,12 @@ interface StaminaInfo {
|
|||||||
interface LevelListItem {
|
interface LevelListItem {
|
||||||
id: string; // 关卡 ID
|
id: string; // 关卡 ID
|
||||||
level: number; // 关卡编号(从 1 开始)
|
level: number; // 关卡编号(从 1 开始)
|
||||||
imageUrl: string; // 关卡图片 URL
|
image1Url: string; // 图片1 URL
|
||||||
|
image1Description: string | null; // 图片1 文本说明
|
||||||
|
image2Url: string; // 图片2 URL
|
||||||
|
image2Description: string | null; // 图片2 文本说明
|
||||||
answer: string | null; // 答案(仅已通关时返回,否则 null)
|
answer: string | null; // 答案(仅已通关时返回,否则 null)
|
||||||
|
punchline: string | null; // 谐音梗说明(仅已通关时返回,否则 null)
|
||||||
hint1: string | null; // 线索1(仅已通关时返回,否则 null)
|
hint1: string | null; // 线索1(仅已通关时返回,否则 null)
|
||||||
hint2: string | null; // 线索2(仅已通关时返回,否则 null)
|
hint2: string | null; // 线索2(仅已通关时返回,否则 null)
|
||||||
hint3: string | null; // 线索3(仅已通关时返回,否则 null)
|
hint3: string | null; // 线索3(仅已通关时返回,否则 null)
|
||||||
@@ -317,8 +321,12 @@ interface LevelListItem {
|
|||||||
{
|
{
|
||||||
"id": "level_001",
|
"id": "level_001",
|
||||||
"level": 1,
|
"level": 1,
|
||||||
"imageUrl": "https://cdn.example.com/levels/001.png",
|
"image1Url": "https://cdn.example.com/levels/001_1.png",
|
||||||
|
"image1Description": "一只猫在看鱼",
|
||||||
|
"image2Url": "https://cdn.example.com/levels/001_2.png",
|
||||||
|
"image2Description": "一条鱼在飞",
|
||||||
"answer": "梗答案",
|
"answer": "梗答案",
|
||||||
|
"punchline": "谐音梗:鱼和猫的故事",
|
||||||
"hint1": "这是一个经典的...",
|
"hint1": "这是一个经典的...",
|
||||||
"hint2": "和某个明星有关",
|
"hint2": "和某个明星有关",
|
||||||
"hint3": null,
|
"hint3": null,
|
||||||
@@ -328,8 +336,12 @@ interface LevelListItem {
|
|||||||
{
|
{
|
||||||
"id": "level_002",
|
"id": "level_002",
|
||||||
"level": 2,
|
"level": 2,
|
||||||
"imageUrl": "https://cdn.example.com/levels/002.png",
|
"image1Url": "https://cdn.example.com/levels/002_1.png",
|
||||||
|
"image1Description": "一个人在走路",
|
||||||
|
"image2Url": "https://cdn.example.com/levels/002_2.png",
|
||||||
|
"image2Description": "一辆车在跑",
|
||||||
"answer": null,
|
"answer": null,
|
||||||
|
"punchline": null,
|
||||||
"hint1": null,
|
"hint1": null,
|
||||||
"hint2": null,
|
"hint2": null,
|
||||||
"hint3": null,
|
"hint3": null,
|
||||||
@@ -346,8 +358,9 @@ interface LevelListItem {
|
|||||||
|
|
||||||
**客户端使用说明**:
|
**客户端使用说明**:
|
||||||
- 关卡选择页面使用此接口获取关卡列表
|
- 关卡选择页面使用此接口获取关卡列表
|
||||||
|
- 每个关卡有两张图片(`image1Url`、`image2Url`)和对应的文本说明
|
||||||
- 根据 `completed` 字段展示不同的 UI 状态(已通关/未通关)
|
- 根据 `completed` 字段展示不同的 UI 状态(已通关/未通关)
|
||||||
- 未通关关卡的 `answer`、`hint1`、`hint2`、`hint3` 均为 `null`,**客户端不应缓存这些字段**
|
- 未通关关卡的 `answer`、`punchline`、`hint1`、`hint2`、`hint3` 均为 `null`,**客户端不应缓存这些字段**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -373,8 +386,12 @@ interface LevelListItem {
|
|||||||
{
|
{
|
||||||
id: string;
|
id: string;
|
||||||
level: number;
|
level: number;
|
||||||
imageUrl: string;
|
image1Url: string;
|
||||||
|
image1Description: string | null;
|
||||||
|
image2Url: string;
|
||||||
|
image2Description: string | null;
|
||||||
answer: string;
|
answer: string;
|
||||||
|
punchline: string | null;
|
||||||
hint1: string | null;
|
hint1: string | null;
|
||||||
hint2: string | null;
|
hint2: string | null;
|
||||||
hint3: string | null;
|
hint3: string | null;
|
||||||
@@ -390,14 +407,18 @@ interface LevelListItem {
|
|||||||
"data": {
|
"data": {
|
||||||
"id": "level_002",
|
"id": "level_002",
|
||||||
"level": 2,
|
"level": 2,
|
||||||
"imageUrl": "https://cdn.example.com/levels/002.png",
|
"image1Url": "https://cdn.example.com/levels/002_1.png",
|
||||||
|
"image1Description": "一个人在走路",
|
||||||
|
"image2Url": "https://cdn.example.com/levels/002_2.png",
|
||||||
|
"image2Description": "一辆车在跑",
|
||||||
"answer": "这是答案",
|
"answer": "这是答案",
|
||||||
|
"punchline": "谐音梗:走和跑的故事",
|
||||||
"hint1": "第一个线索",
|
"hint1": "第一个线索",
|
||||||
"hint2": "第二个线索",
|
"hint2": "第二个线索",
|
||||||
"hint3": null,
|
"hint3": null,
|
||||||
"stamina": {
|
"stamina": {
|
||||||
"current": 2,
|
"current": 47,
|
||||||
"max": 5,
|
"max": 50,
|
||||||
"nextRecoverAt": "2026-04-10T12:10:00.000Z"
|
"nextRecoverAt": "2026-04-10T12:10:00.000Z"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -658,7 +679,9 @@ interface GameState {
|
|||||||
currentLevel: { // 当前正在玩的关卡
|
currentLevel: { // 当前正在玩的关卡
|
||||||
id: string;
|
id: string;
|
||||||
answer: string;
|
answer: string;
|
||||||
|
punchline: string | null;
|
||||||
hints: (string | null)[];
|
hints: (string | null)[];
|
||||||
|
images: { url: string; description: string | null }[];
|
||||||
startTime: number; // 开始时间戳,用于计算 timeSpent
|
startTime: number; // 开始时间戳,用于计算 timeSpent
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
@@ -823,8 +846,12 @@ async function loadGameData(): Promise<GameData> {
|
|||||||
interface LevelListItem {
|
interface LevelListItem {
|
||||||
id: string;
|
id: string;
|
||||||
level: number;
|
level: number;
|
||||||
imageUrl: string;
|
image1Url: string;
|
||||||
|
image1Description: string | null;
|
||||||
|
image2Url: string;
|
||||||
|
image2Description: string | null;
|
||||||
answer: string | null;
|
answer: string | null;
|
||||||
|
punchline: string | null;
|
||||||
hint1: string | null;
|
hint1: string | null;
|
||||||
hint2: string | null;
|
hint2: string | null;
|
||||||
hint3: string | null;
|
hint3: string | null;
|
||||||
@@ -847,8 +874,12 @@ async function getLevels(): Promise<LevelListItem[]> {
|
|||||||
interface EnterLevelResponse {
|
interface EnterLevelResponse {
|
||||||
id: string;
|
id: string;
|
||||||
level: number;
|
level: number;
|
||||||
imageUrl: string;
|
image1Url: string;
|
||||||
|
image1Description: string | null;
|
||||||
|
image2Url: string;
|
||||||
|
image2Description: string | null;
|
||||||
answer: string;
|
answer: string;
|
||||||
|
punchline: string | null;
|
||||||
hint1: string | null;
|
hint1: string | null;
|
||||||
hint2: string | null;
|
hint2: string | null;
|
||||||
hint3: string | null;
|
hint3: string | null;
|
||||||
@@ -969,4 +1000,6 @@ export class GameEntry extends Component {
|
|||||||
4. **已通关关卡免费进入**:已通关关卡再次进入不消耗体力
|
4. **已通关关卡免费进入**:已通关关卡再次进入不消耗体力
|
||||||
5. **通关上报仅限成功**:只在用户答对后调用 `complete` 接口,答错不需要上报
|
5. **通关上报仅限成功**:只在用户答对后调用 `complete` 接口,答错不需要上报
|
||||||
6. **hint 字段**:`hint1/hint2/hint3` 可能为 `null`,表示该线索未配置
|
6. **hint 字段**:`hint1/hint2/hint3` 可能为 `null`,表示该线索未配置
|
||||||
7. **网络异常处理**:建议所有接口调用加 loading 状态,并处理 401(重新登录)和网络错误
|
7. **punchline 字段**:谐音梗说明,仅已通关时返回,未通关时为 `null`
|
||||||
|
8. **双图结构**:每个关卡有两张图片(`image1Url`、`image2Url`),分别有对应的文本说明
|
||||||
|
9. **网络异常处理**:建议所有接口调用加 loading 状态,并处理 401(重新登录)和网络错误
|
||||||
|
|||||||
5
src/common/constants/game.constants.ts
Normal file
5
src/common/constants/game.constants.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/** Maximum stamina a user can have */
|
||||||
|
export const MAX_STAMINA = 50;
|
||||||
|
|
||||||
|
/** Stamina recovery interval: 1 point every 10 minutes */
|
||||||
|
export const RECOVER_INTERVAL_MS = 10 * 60 * 1000;
|
||||||
@@ -13,9 +13,7 @@ describe('HttpExceptionFilter', () => {
|
|||||||
const mockJson = jest.fn();
|
const mockJson = jest.fn();
|
||||||
const mockStatus = jest.fn().mockReturnValue({ json: mockJson });
|
const mockStatus = jest.fn().mockReturnValue({ json: mockJson });
|
||||||
const mockGetResponse = jest.fn().mockReturnValue({ status: mockStatus });
|
const mockGetResponse = jest.fn().mockReturnValue({ status: mockStatus });
|
||||||
const mockGetRequest = jest
|
const mockGetRequest = jest.fn().mockReturnValue({ url: '/api/v1/test' });
|
||||||
.fn()
|
|
||||||
.mockReturnValue({ url: '/api/v1/test' });
|
|
||||||
|
|
||||||
const mockHost: ArgumentsHost = {
|
const mockHost: ArgumentsHost = {
|
||||||
switchToHttp: () => ({
|
switchToHttp: () => ({
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ describe('JwtAuthGuard', () => {
|
|||||||
mockJwtService.verifyAsync.mockResolvedValue(payload);
|
mockJwtService.verifyAsync.mockResolvedValue(payload);
|
||||||
|
|
||||||
const context = createMockContext('Bearer valid-token');
|
const context = createMockContext('Bearer valid-token');
|
||||||
const request = context.switchToHttp().getRequest() as Record<string, unknown>;
|
const request = context.switchToHttp().getRequest();
|
||||||
await guard.canActivate(context);
|
await guard.canActivate(context);
|
||||||
|
|
||||||
expect(request.user).toEqual(payload);
|
expect(request.user).toEqual(payload);
|
||||||
|
|||||||
32
src/database/migrations/003_level_dual_image_stamina.sql
Normal file
32
src/database/migrations/003_level_dual_image_stamina.sql
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
-- Migration: 003_level_dual_image_stamina
|
||||||
|
-- Description: Level dual-image support + stamina max 50 (old max was 5)
|
||||||
|
|
||||||
|
-- 1. Rename image_url → image1_url and expand to VARCHAR(500)
|
||||||
|
ALTER TABLE levels CHANGE COLUMN image_url image1_url VARCHAR(500) NOT NULL;
|
||||||
|
|
||||||
|
-- 2. Add image1_description after image1_url
|
||||||
|
ALTER TABLE levels ADD COLUMN image1_description VARCHAR(500) NULL AFTER image1_url;
|
||||||
|
|
||||||
|
-- 3. Add image2_url with default empty string
|
||||||
|
ALTER TABLE levels ADD COLUMN image2_url VARCHAR(500) NOT NULL DEFAULT '' AFTER image1_description;
|
||||||
|
|
||||||
|
-- 4. Add image2_description after image2_url
|
||||||
|
ALTER TABLE levels ADD COLUMN image2_description VARCHAR(500) NULL AFTER image2_url;
|
||||||
|
|
||||||
|
-- 5. Add punchline (谐音梗说明) after answer
|
||||||
|
ALTER TABLE levels ADD COLUMN punchline VARCHAR(500) NULL AFTER answer;
|
||||||
|
|
||||||
|
-- 6. Update stamina default from 5 to 50
|
||||||
|
ALTER TABLE wx_users ALTER COLUMN stamina SET DEFAULT 50;
|
||||||
|
|
||||||
|
-- 7. Bump users at or below old max (5) to new max (50)
|
||||||
|
UPDATE wx_users SET stamina = 50 WHERE stamina <= 5;
|
||||||
|
|
||||||
|
-- ROLLBACK (manual):
|
||||||
|
-- ALTER TABLE levels CHANGE COLUMN image1_url image_url VARCHAR(191) NOT NULL;
|
||||||
|
-- ALTER TABLE levels DROP COLUMN image1_description;
|
||||||
|
-- ALTER TABLE levels DROP COLUMN image2_url;
|
||||||
|
-- ALTER TABLE levels DROP COLUMN image2_description;
|
||||||
|
-- ALTER TABLE levels DROP COLUMN punchline;
|
||||||
|
-- ALTER TABLE wx_users ALTER COLUMN stamina SET DEFAULT 5;
|
||||||
|
-- UPDATE wx_users SET stamina = 5 WHERE stamina = 50;
|
||||||
@@ -3,6 +3,7 @@ import { JwtService } from '@nestjs/jwt';
|
|||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { ApiResponseDto } from '../../common/dto/api-response.dto';
|
import { ApiResponseDto } from '../../common/dto/api-response.dto';
|
||||||
|
import { MAX_STAMINA } from '../../common/constants/game.constants';
|
||||||
|
|
||||||
describe('AuthController', () => {
|
describe('AuthController', () => {
|
||||||
let controller: AuthController;
|
let controller: AuthController;
|
||||||
@@ -31,7 +32,7 @@ describe('AuthController', () => {
|
|||||||
it('should return success response with token and user info', async () => {
|
it('should return success response with token and user info', async () => {
|
||||||
const loginResponse = {
|
const loginResponse = {
|
||||||
token: 'jwt-token',
|
token: 'jwt-token',
|
||||||
user: { id: 'user-uuid-1', nickname: 'Test', stamina: 5 },
|
user: { id: 'user-uuid-1', nickname: 'Test', stamina: MAX_STAMINA },
|
||||||
};
|
};
|
||||||
mockAuthService.wxLogin.mockResolvedValue(loginResponse);
|
mockAuthService.wxLogin.mockResolvedValue(loginResponse);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { Body, Controller, Post } from '@nestjs/common';
|
import { Body, Controller, Post } from '@nestjs/common';
|
||||||
import {
|
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
ApiOperation,
|
|
||||||
ApiResponse,
|
|
||||||
ApiTags,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { WxLoginRequestDto, WxLoginResponseDto } from './dto/wx-login.dto';
|
import { WxLoginRequestDto, WxLoginResponseDto } from './dto/wx-login.dto';
|
||||||
import { ApiResponseDto } from '../../common/dto/api-response.dto';
|
import { ApiResponseDto } from '../../common/dto/api-response.dto';
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ import { UserLevelProgressRepository } from './repositories/user-level-progress.
|
|||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, UserRepository, UserLevelProgressRepository],
|
providers: [AuthService, UserRepository, UserLevelProgressRepository],
|
||||||
exports: [JwtModule, AuthService, UserRepository, UserLevelProgressRepository],
|
exports: [
|
||||||
|
JwtModule,
|
||||||
|
AuthService,
|
||||||
|
UserRepository,
|
||||||
|
UserLevelProgressRepository,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import axios from 'axios';
|
|||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { UserRepository } from './repositories/user.repository';
|
import { UserRepository } from './repositories/user.repository';
|
||||||
import { User } from './entities/user.entity';
|
import { User } from './entities/user.entity';
|
||||||
|
import { MAX_STAMINA } from '../../common/constants/game.constants';
|
||||||
|
|
||||||
jest.mock('axios');
|
jest.mock('axios');
|
||||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||||
@@ -19,7 +20,7 @@ describe('AuthService', () => {
|
|||||||
sessionKey: 'session-key-abc',
|
sessionKey: 'session-key-abc',
|
||||||
nickname: 'TestUser',
|
nickname: 'TestUser',
|
||||||
avatarUrl: null,
|
avatarUrl: null,
|
||||||
stamina: 5,
|
stamina: MAX_STAMINA,
|
||||||
staminaUpdatedAt: null,
|
staminaUpdatedAt: null,
|
||||||
createdAt: new Date('2026-01-01'),
|
createdAt: new Date('2026-01-01'),
|
||||||
updatedAt: new Date('2026-01-01'),
|
updatedAt: new Date('2026-01-01'),
|
||||||
@@ -65,7 +66,7 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
describe('wxLogin', () => {
|
describe('wxLogin', () => {
|
||||||
it('should create a new user and return JWT token on first login', async () => {
|
it('should create a new user and return JWT token on first login', async () => {
|
||||||
const newUser = { ...mockUser, stamina: 5 };
|
const newUser = { ...mockUser };
|
||||||
mockedAxios.get.mockResolvedValue({
|
mockedAxios.get.mockResolvedValue({
|
||||||
data: { openid: 'wx-openid-123', session_key: 'session-key-abc' },
|
data: { openid: 'wx-openid-123', session_key: 'session-key-abc' },
|
||||||
});
|
});
|
||||||
@@ -78,11 +79,11 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
expect(result.token).toBe('jwt-token-xyz');
|
expect(result.token).toBe('jwt-token-xyz');
|
||||||
expect(result.user.id).toBe('user-uuid-1');
|
expect(result.user.id).toBe('user-uuid-1');
|
||||||
expect(result.user.stamina).toBe(5);
|
expect(result.user.stamina).toBe(MAX_STAMINA);
|
||||||
expect(mockUserRepository.create).toHaveBeenCalledWith({
|
expect(mockUserRepository.create).toHaveBeenCalledWith({
|
||||||
openid: 'wx-openid-123',
|
openid: 'wx-openid-123',
|
||||||
sessionKey: 'session-key-abc',
|
sessionKey: 'session-key-abc',
|
||||||
stamina: 5,
|
stamina: MAX_STAMINA,
|
||||||
});
|
});
|
||||||
expect(mockJwtService.signAsync).toHaveBeenCalledWith({
|
expect(mockJwtService.signAsync).toHaveBeenCalledWith({
|
||||||
sub: 'user-uuid-1',
|
sub: 'user-uuid-1',
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import {
|
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
UnauthorizedException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { UserRepository } from './repositories/user.repository';
|
import { UserRepository } from './repositories/user.repository';
|
||||||
import { WxLoginResponseDto, UserInfoDto } from './dto/wx-login.dto';
|
import { WxLoginResponseDto, UserInfoDto } from './dto/wx-login.dto';
|
||||||
import { JwtPayload } from '../../common/guards/jwt-auth.guard';
|
import { JwtPayload } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { MAX_STAMINA } from '../../common/constants/game.constants';
|
||||||
|
|
||||||
interface WxSessionResponse {
|
interface WxSessionResponse {
|
||||||
openid?: string;
|
openid?: string;
|
||||||
@@ -53,7 +50,7 @@ export class AuthService {
|
|||||||
user = this.userRepository.create({
|
user = this.userRepository.create({
|
||||||
openid: wxSession.openid,
|
openid: wxSession.openid,
|
||||||
sessionKey: wxSession.session_key ?? null,
|
sessionKey: wxSession.session_key ?? null,
|
||||||
stamina: 5, // 新用户默认 5 体力值
|
stamina: MAX_STAMINA,
|
||||||
});
|
});
|
||||||
user = await this.userRepository.save(user);
|
user = await this.userRepository.save(user);
|
||||||
this.logger.log(`新用户注册: ${user.id}`);
|
this.logger.log(`新用户注册: ${user.id}`);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
Index,
|
Index,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
import { MAX_STAMINA } from '../../../common/constants/game.constants';
|
||||||
|
|
||||||
@Entity('wx_users')
|
@Entity('wx_users')
|
||||||
export class User {
|
export class User {
|
||||||
@@ -25,8 +26,8 @@ export class User {
|
|||||||
@Column({ type: 'text', name: 'avatar_url', nullable: true })
|
@Column({ type: 'text', name: 'avatar_url', nullable: true })
|
||||||
avatarUrl!: string | null;
|
avatarUrl!: string | null;
|
||||||
|
|
||||||
/** 体力值(默认 5,上限 5) */
|
/** 体力值(默认 MAX_STAMINA,上限 MAX_STAMINA) */
|
||||||
@Column({ type: 'int', default: 5 })
|
@Column({ type: 'int', default: MAX_STAMINA })
|
||||||
stamina!: number;
|
stamina!: number;
|
||||||
|
|
||||||
/** 体力值最后更新时间(用于计算恢复) */
|
/** 体力值最后更新时间(用于计算恢复) */
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ import { GameConfig } from '../wechat-game/entities/game-config.entity';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GameConfigService {
|
export class GameConfigService {
|
||||||
constructor(
|
constructor(private readonly gameConfigRepository: GameConfigRepository) {}
|
||||||
private readonly gameConfigRepository: GameConfigRepository,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async getAllConfigs(): Promise<GameConfigListResponseDto> {
|
async getAllConfigs(): Promise<GameConfigListResponseDto> {
|
||||||
const configs = await this.gameConfigRepository.findActiveConfigs();
|
const configs = await this.gameConfigRepository.findActiveConfigs();
|
||||||
|
|||||||
@@ -8,12 +8,24 @@ export class EnterLevelResponseDto {
|
|||||||
@ApiProperty({ description: '关卡编号' })
|
@ApiProperty({ description: '关卡编号' })
|
||||||
level!: number;
|
level!: number;
|
||||||
|
|
||||||
@ApiProperty({ description: '图片 URL' })
|
@ApiProperty({ description: '图片1 URL' })
|
||||||
imageUrl!: string;
|
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: '答案' })
|
@ApiProperty({ description: '答案' })
|
||||||
answer!: string;
|
answer!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '谐音梗说明', nullable: true })
|
||||||
|
punchline!: string | null;
|
||||||
|
|
||||||
@ApiProperty({ description: '线索1', nullable: true })
|
@ApiProperty({ description: '线索1', nullable: true })
|
||||||
hint1!: string | null;
|
hint1!: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,24 @@ export class LevelListItemDto {
|
|||||||
@ApiProperty({ description: '关卡编号' })
|
@ApiProperty({ description: '关卡编号' })
|
||||||
level!: number;
|
level!: number;
|
||||||
|
|
||||||
@ApiProperty({ description: '图片 URL' })
|
@ApiProperty({ description: '图片1 URL' })
|
||||||
imageUrl!: string;
|
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: '答案(仅已通关时返回)', nullable: true })
|
@ApiProperty({ description: '答案(仅已通关时返回)', nullable: true })
|
||||||
answer!: string | null;
|
answer!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '谐音梗说明(仅已通关时返回)', nullable: true })
|
||||||
|
punchline!: string | null;
|
||||||
|
|
||||||
@ApiProperty({ description: '线索1(仅已通关时返回)', nullable: true })
|
@ApiProperty({ description: '线索1(仅已通关时返回)', nullable: true })
|
||||||
hint1!: string | null;
|
hint1!: string | null;
|
||||||
|
|
||||||
@@ -25,7 +37,10 @@ export class LevelListItemDto {
|
|||||||
@ApiProperty({ description: '是否已通关' })
|
@ApiProperty({ description: '是否已通关' })
|
||||||
completed!: boolean;
|
completed!: boolean;
|
||||||
|
|
||||||
@ApiProperty({ description: '通关时长(秒),未通关时为 null', nullable: true })
|
@ApiProperty({
|
||||||
|
description: '通关时长(秒),未通关时为 null',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
timeSpent!: number | null;
|
timeSpent!: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import {
|
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Param,
|
|
||||||
Post,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import {
|
import {
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
@@ -49,7 +42,8 @@ export class LevelController {
|
|||||||
@Post(':id/enter')
|
@Post(':id/enter')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: '进入关卡',
|
summary: '进入关卡',
|
||||||
description: '消耗 1 体力进入关卡,返回完整关卡详情(线索+答案)。已通关关卡不消耗体力。',
|
description:
|
||||||
|
'消耗 1 体力进入关卡,返回完整关卡详情(线索+答案)。已通关关卡不消耗体力。',
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 200, description: '成功' })
|
@ApiResponse({ status: 200, description: '成功' })
|
||||||
@ApiResponse({ status: 400, description: '体力不足' })
|
@ApiResponse({ status: 400, description: '体力不足' })
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import {
|
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
Logger,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { LevelRepository } from '../wechat-game/repositories/level.repository';
|
import { LevelRepository } from '../wechat-game/repositories/level.repository';
|
||||||
import { UserLevelProgressRepository } from '../auth/repositories/user-level-progress.repository';
|
import { UserLevelProgressRepository } from '../auth/repositories/user-level-progress.repository';
|
||||||
import { UserService } from '../user/user.service';
|
import { UserService } from '../user/user.service';
|
||||||
@@ -12,6 +8,10 @@ import {
|
|||||||
CompleteLevelRequestDto,
|
CompleteLevelRequestDto,
|
||||||
CompleteLevelResponseDto,
|
CompleteLevelResponseDto,
|
||||||
} from './dto/complete-level.dto';
|
} from './dto/complete-level.dto';
|
||||||
|
import {
|
||||||
|
pickLevelImageFields,
|
||||||
|
pickLevelImageFieldsMasked,
|
||||||
|
} from '../wechat-game/level-fields.helper';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LevelService {
|
export class LevelService {
|
||||||
@@ -32,9 +32,7 @@ export class LevelService {
|
|||||||
this.userLevelProgressRepository.findByUserId(userId),
|
this.userLevelProgressRepository.findByUserId(userId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const progressMap = new Map(
|
const progressMap = new Map(progressList.map((p) => [p.levelId, p]));
|
||||||
progressList.map((p) => [p.levelId, p]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const items: LevelListItemDto[] = levels.map((level, index) => {
|
const items: LevelListItemDto[] = levels.map((level, index) => {
|
||||||
const progress = progressMap.get(level.id);
|
const progress = progressMap.get(level.id);
|
||||||
@@ -43,11 +41,8 @@ export class LevelService {
|
|||||||
return {
|
return {
|
||||||
id: level.id,
|
id: level.id,
|
||||||
level: index + 1,
|
level: index + 1,
|
||||||
imageUrl: level.imageUrl,
|
...pickLevelImageFieldsMasked(level, completed),
|
||||||
answer: completed ? level.answer : null,
|
answer: completed ? level.answer : null,
|
||||||
hint1: completed ? level.hint1 : null,
|
|
||||||
hint2: completed ? level.hint2 : null,
|
|
||||||
hint3: completed ? level.hint3 : null,
|
|
||||||
completed,
|
completed,
|
||||||
timeSpent: completed ? progress.timeSpent : null,
|
timeSpent: completed ? progress.timeSpent : null,
|
||||||
};
|
};
|
||||||
@@ -63,7 +58,6 @@ export class LevelService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
levelId: string,
|
levelId: string,
|
||||||
): Promise<EnterLevelResponseDto> {
|
): Promise<EnterLevelResponseDto> {
|
||||||
// 1. 并行查找关卡和通关记录
|
|
||||||
const [level, existing] = await Promise.all([
|
const [level, existing] = await Promise.all([
|
||||||
this.levelRepository.findById(levelId),
|
this.levelRepository.findById(levelId),
|
||||||
this.userLevelProgressRepository.findByUserAndLevel(userId, levelId),
|
this.userLevelProgressRepository.findByUserAndLevel(userId, levelId),
|
||||||
@@ -76,11 +70,10 @@ export class LevelService {
|
|||||||
let staminaInfo;
|
let staminaInfo;
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// 已通关,不消耗体力,直接返回
|
// Already completed — no stamina cost
|
||||||
const user = await this.userService.findUserOrThrow(userId);
|
const user = await this.userService.findUserOrThrow(userId);
|
||||||
staminaInfo = this.userService.computeStamina(user);
|
staminaInfo = this.userService.computeStamina(user);
|
||||||
} else {
|
} else {
|
||||||
// 未通关,消耗体力(返回值已包含 stamina 信息,无需重复计算)
|
|
||||||
const result = await this.userService.consumeStamina(userId);
|
const result = await this.userService.consumeStamina(userId);
|
||||||
staminaInfo = result.stamina;
|
staminaInfo = result.stamina;
|
||||||
this.logger.log(`用户 ${userId} 进入关卡 ${levelId},消耗 1 体力`);
|
this.logger.log(`用户 ${userId} 进入关卡 ${levelId},消耗 1 体力`);
|
||||||
@@ -89,11 +82,8 @@ export class LevelService {
|
|||||||
return {
|
return {
|
||||||
id: level.id,
|
id: level.id,
|
||||||
level: level.sortOrder,
|
level: level.sortOrder,
|
||||||
imageUrl: level.imageUrl,
|
...pickLevelImageFields(level),
|
||||||
answer: level.answer,
|
answer: level.answer,
|
||||||
hint1: level.hint1,
|
|
||||||
hint2: level.hint2,
|
|
||||||
hint3: level.hint3,
|
|
||||||
stamina: staminaInfo,
|
stamina: staminaInfo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -106,7 +96,6 @@ export class LevelService {
|
|||||||
levelId: string,
|
levelId: string,
|
||||||
dto: CompleteLevelRequestDto,
|
dto: CompleteLevelRequestDto,
|
||||||
): Promise<CompleteLevelResponseDto> {
|
): Promise<CompleteLevelResponseDto> {
|
||||||
// 并行验证关卡存在和检查通关记录
|
|
||||||
const [level, existing] = await Promise.all([
|
const [level, existing] = await Promise.all([
|
||||||
this.levelRepository.findById(levelId),
|
this.levelRepository.findById(levelId),
|
||||||
this.userLevelProgressRepository.findByUserAndLevel(userId, levelId),
|
this.userLevelProgressRepository.findByUserAndLevel(userId, levelId),
|
||||||
@@ -125,7 +114,6 @@ export class LevelService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 记录通关进度
|
|
||||||
const progress = this.userLevelProgressRepository.create({
|
const progress = this.userLevelProgressRepository.create({
|
||||||
userId,
|
userId,
|
||||||
levelId,
|
levelId,
|
||||||
@@ -133,9 +121,7 @@ export class LevelService {
|
|||||||
});
|
});
|
||||||
await this.userLevelProgressRepository.save(progress);
|
await this.userLevelProgressRepository.save(progress);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`用户 ${userId} 通关 ${levelId},用时 ${dto.timeSpent} 秒`);
|
||||||
`用户 ${userId} 通关 ${levelId},用时 ${dto.timeSpent} 秒`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
firstClear: true,
|
firstClear: true,
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
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 {
|
export class ReportLevelProgressDto {
|
||||||
@ApiProperty({ description: '分享码' })
|
@ApiProperty({ description: '分享码' })
|
||||||
|
|||||||
@@ -18,12 +18,24 @@ export class ShareLevelDto {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
level!: number;
|
level!: number;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty({ description: '图片1 URL' })
|
||||||
imageUrl!: string;
|
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()
|
@ApiProperty()
|
||||||
answer!: string;
|
answer!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '谐音梗说明', nullable: true })
|
||||||
|
punchline!: string | null;
|
||||||
|
|
||||||
@ApiProperty({ nullable: true })
|
@ApiProperty({ nullable: true })
|
||||||
hint1!: string | null;
|
hint1!: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ export class ShareLevelProgress {
|
|||||||
@Column({ type: 'int', default: 0, name: 'time_spent' })
|
@Column({ type: 'int', default: 0, name: 'time_spent' })
|
||||||
timeSpent!: number;
|
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;
|
completedAt!: Date | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,12 @@ describe('ShareService', () => {
|
|||||||
|
|
||||||
const mockLevels: Level[] = Array.from({ length: 6 }, (_, i) => ({
|
const mockLevels: Level[] = Array.from({ length: 6 }, (_, i) => ({
|
||||||
id: `level-${i + 1}`,
|
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}`,
|
answer: `答案${i + 1}`,
|
||||||
|
punchline: null,
|
||||||
hint1: `提示${i + 1}`,
|
hint1: `提示${i + 1}`,
|
||||||
hint2: null,
|
hint2: null,
|
||||||
hint3: null,
|
hint3: null,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ShareConfigRepository } from './repositories/share-config.repository';
|
|||||||
import { ShareParticipantRepository } from './repositories/share-participant.repository';
|
import { ShareParticipantRepository } from './repositories/share-participant.repository';
|
||||||
import { ShareLevelProgressRepository } from './repositories/share-level-progress.repository';
|
import { ShareLevelProgressRepository } from './repositories/share-level-progress.repository';
|
||||||
import { LevelRepository } from '../wechat-game/repositories/level.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 { CreateShareDto } from './dto/create-share.dto';
|
||||||
import { ReportLevelProgressDto } from './dto/report-level-progress.dto';
|
import { ReportLevelProgressDto } from './dto/report-level-progress.dto';
|
||||||
import {
|
import {
|
||||||
@@ -83,7 +84,7 @@ export class ShareService {
|
|||||||
await this.shareParticipantRepository.addParticipant(config.id, userId);
|
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 allLevels = await this.levelRepository.findByIds(config.levelIds);
|
||||||
const levelMap = new Map(allLevels.map((l) => [l.id, l]));
|
const levelMap = new Map(allLevels.map((l) => [l.id, l]));
|
||||||
|
|
||||||
@@ -95,11 +96,8 @@ export class ShareService {
|
|||||||
return {
|
return {
|
||||||
id: level.id,
|
id: level.id,
|
||||||
level: index + 1,
|
level: index + 1,
|
||||||
imageUrl: level.imageUrl,
|
...pickLevelImageFields(level),
|
||||||
answer: level.answer,
|
answer: level.answer,
|
||||||
hint1: level.hint1,
|
|
||||||
hint2: level.hint2,
|
|
||||||
hint3: level.hint3,
|
|
||||||
sortOrder: level.sortOrder,
|
sortOrder: level.sortOrder,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ export class StaminaInfoDto {
|
|||||||
@ApiProperty({ description: '体力值上限' })
|
@ApiProperty({ description: '体力值上限' })
|
||||||
max!: number;
|
max!: number;
|
||||||
|
|
||||||
@ApiProperty({ description: '下次恢复时间(ISO 字符串),满体力时为 null', nullable: true })
|
@ApiProperty({
|
||||||
|
description: '下次恢复时间(ISO 字符串),满体力时为 null',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
nextRecoverAt!: string | null;
|
nextRecoverAt!: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import {
|
|||||||
ApiTags,
|
ApiTags,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
import { UserProfileResponseDto, GameDataResponseDto } from './dto/user-profile.dto';
|
import {
|
||||||
|
UserProfileResponseDto,
|
||||||
|
GameDataResponseDto,
|
||||||
|
} from './dto/user-profile.dto';
|
||||||
import { ApiResponseDto } from '../../common/dto/api-response.dto';
|
import { ApiResponseDto } from '../../common/dto/api-response.dto';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
import type { JwtPayload } from '../../common/guards/jwt-auth.guard';
|
import type { JwtPayload } from '../../common/guards/jwt-auth.guard';
|
||||||
|
|||||||
@@ -11,9 +11,12 @@ import {
|
|||||||
UserProfileResponseDto,
|
UserProfileResponseDto,
|
||||||
GameDataResponseDto,
|
GameDataResponseDto,
|
||||||
} from './dto/user-profile.dto';
|
} from './dto/user-profile.dto';
|
||||||
|
import {
|
||||||
|
MAX_STAMINA,
|
||||||
|
RECOVER_INTERVAL_MS,
|
||||||
|
} from '../../common/constants/game.constants';
|
||||||
|
|
||||||
export const MAX_STAMINA = 5;
|
export { MAX_STAMINA, RECOVER_INTERVAL_MS };
|
||||||
export const RECOVER_INTERVAL_MS = 10 * 60 * 1000; // 10 分钟
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService {
|
export class UserService {
|
||||||
@@ -53,12 +56,15 @@ export class UserService {
|
|||||||
return { current: currentStamina, max: MAX_STAMINA, nextRecoverAt };
|
return { current: currentStamina, max: MAX_STAMINA, nextRecoverAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static readonly MAX_STAMINA_RETRIES = 3;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 消耗 1 点体力,返回消耗后的体力信息。
|
* 消耗 1 点体力,返回消耗后的体力信息。
|
||||||
* 使用原子更新防止并发竞态条件(双击进入关卡场景)。
|
* 使用原子更新防止并发竞态条件(双击进入关卡场景)。
|
||||||
*/
|
*/
|
||||||
async consumeStamina(
|
async consumeStamina(
|
||||||
userId: string,
|
userId: string,
|
||||||
|
retries = 0,
|
||||||
): Promise<{ user: User; stamina: StaminaInfoDto }> {
|
): Promise<{ user: User; stamina: StaminaInfoDto }> {
|
||||||
const user = await this.findUserOrThrow(userId);
|
const user = await this.findUserOrThrow(userId);
|
||||||
const staminaInfo = this.computeStamina(user);
|
const staminaInfo = this.computeStamina(user);
|
||||||
@@ -70,7 +76,6 @@ export class UserService {
|
|||||||
const newStamina = staminaInfo.current - 1;
|
const newStamina = staminaInfo.current - 1;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
// 原子更新:使用 WHERE 条件确保并发安全
|
|
||||||
const result = await this.userRepository.updateStaminaAtomic(
|
const result = await this.userRepository.updateStaminaAtomic(
|
||||||
userId,
|
userId,
|
||||||
user.stamina,
|
user.stamina,
|
||||||
@@ -79,14 +84,30 @@ export class UserService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result.affected === 0) {
|
if (result.affected === 0) {
|
||||||
// 并发冲突,重试一次
|
if (retries >= UserService.MAX_STAMINA_RETRIES) {
|
||||||
return this.consumeStamina(userId);
|
throw new BadRequestException('操作冲突,请重试');
|
||||||
|
}
|
||||||
|
return this.consumeStamina(userId, retries + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedUser = { ...user, stamina: newStamina, staminaUpdatedAt: now };
|
const updatedStamina: StaminaInfoDto =
|
||||||
const updatedStamina = this.computeStamina(updatedUser as User);
|
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: updatedUser as User, stamina: updatedStamina };
|
return {
|
||||||
|
user: Object.assign(Object.create(Object.getPrototypeOf(user)), user, {
|
||||||
|
stamina: newStamina,
|
||||||
|
staminaUpdatedAt: now,
|
||||||
|
}),
|
||||||
|
stamina: updatedStamina,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserProfile(userId: string): Promise<UserProfileResponseDto> {
|
async getUserProfile(userId: string): Promise<UserProfileResponseDto> {
|
||||||
|
|||||||
@@ -7,12 +7,24 @@ export class LevelResponseDto {
|
|||||||
@ApiProperty({ description: '关卡ID' })
|
@ApiProperty({ description: '关卡ID' })
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
@ApiProperty({ description: '图片URL' })
|
@ApiProperty({ description: '图片1 URL' })
|
||||||
imageUrl!: string;
|
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: '答案' })
|
@ApiProperty({ description: '答案' })
|
||||||
answer!: string;
|
answer!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '谐音梗说明', nullable: true })
|
||||||
|
punchline!: string | null;
|
||||||
|
|
||||||
@ApiProperty({ description: '提示1', nullable: true })
|
@ApiProperty({ description: '提示1', nullable: true })
|
||||||
hint1!: string | null;
|
hint1!: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,34 @@ export class Level {
|
|||||||
@PrimaryColumn({ type: 'varchar', length: 191 })
|
@PrimaryColumn({ type: 'varchar', length: 191 })
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 191, name: 'image_url' })
|
@Column({ type: 'varchar', length: 500, name: 'image1_url' })
|
||||||
imageUrl!: string;
|
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 })
|
@Column({ type: 'varchar', length: 191 })
|
||||||
answer!: string;
|
answer!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 500, nullable: true })
|
||||||
|
punchline!: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 191, nullable: true })
|
@Column({ type: 'varchar', length: 191, nullable: true })
|
||||||
hint1!: string | null;
|
hint1!: string | null;
|
||||||
|
|
||||||
|
|||||||
49
src/modules/wechat-game/level-fields.helper.ts
Normal file
49
src/modules/wechat-game/level-fields.helper.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
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.
|
||||||
|
*/
|
||||||
|
export function pickLevelImageFieldsMasked(
|
||||||
|
level: Level,
|
||||||
|
completed: boolean,
|
||||||
|
): LevelImageFields {
|
||||||
|
return {
|
||||||
|
image1Url: level.image1Url,
|
||||||
|
image1Description: level.image1Description,
|
||||||
|
image2Url: level.image2Url,
|
||||||
|
image2Description: level.image2Description,
|
||||||
|
punchline: completed ? level.punchline : null,
|
||||||
|
hint1: completed ? level.hint1 : null,
|
||||||
|
hint2: completed ? level.hint2 : null,
|
||||||
|
hint3: completed ? level.hint3 : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user