feat: 支持登录、个人信息存储
This commit is contained in:
@@ -8,3 +8,8 @@ DB_DATABASE=mememind
|
|||||||
# Application Configuration
|
# Application Configuration
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
|
|
||||||
|
WX_APPID=wx0f9c909d20d19396
|
||||||
|
WX_SECRET=c5635680747cccf351f5f323c01178e6
|
||||||
|
JWT_SECRET=mp-xieyingen
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# 生产环境配置
|
|
||||||
# 复制此文件为 .env.production 并修改配置
|
|
||||||
|
|
||||||
NODE_ENV=production
|
|
||||||
PORT=3000
|
|
||||||
|
|
||||||
# 数据库配置
|
|
||||||
DB_HOST=localhost
|
|
||||||
DB_PORT=3306
|
|
||||||
DB_USERNAME=your_db_username
|
|
||||||
DB_PASSWORD=your_db_password
|
|
||||||
DB_DATABASE=meme_mind
|
|
||||||
706
ARCHITECTURE_DIAGRAMS.md
Normal file
706
ARCHITECTURE_DIAGRAMS.md
Normal file
@@ -0,0 +1,706 @@
|
|||||||
|
# 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*
|
||||||
836
CLIENT_SERVER_INTEGRATION.md
Normal file
836
CLIENT_SERVER_INTEGRATION.md
Normal file
@@ -0,0 +1,836 @@
|
|||||||
|
# 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
Normal file
1191
SERVER_ANALYSIS.md
Normal file
File diff suppressed because it is too large
Load Diff
33
migrations/20260405_add_auth_tables.sql
Normal file
33
migrations/20260405_add_auth_tables.sql
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
-- MemeMind Server: Auth 模块数据库迁移
|
||||||
|
-- 新增 wx_users 表和 wx_user_level_progress 表
|
||||||
|
-- 执行时间: 2026-04-05
|
||||||
|
|
||||||
|
-- ==========================================
|
||||||
|
-- 1. 创建 wx_users 表
|
||||||
|
-- ==========================================
|
||||||
|
CREATE TABLE IF NOT EXISTS `wx_users` (
|
||||||
|
`id` varchar(36) NOT NULL,
|
||||||
|
`openid` varchar(128) NOT NULL,
|
||||||
|
`session_key` varchar(255) DEFAULT NULL,
|
||||||
|
`nickname` varchar(100) DEFAULT NULL,
|
||||||
|
`avatar_url` text DEFAULT NULL,
|
||||||
|
`points` int NOT NULL DEFAULT 10,
|
||||||
|
`created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
`updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `idx_user_openid` (`openid`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ==========================================
|
||||||
|
-- 2. 创建 wx_user_level_progress 表
|
||||||
|
-- ==========================================
|
||||||
|
CREATE TABLE IF NOT EXISTS `wx_user_level_progress` (
|
||||||
|
`id` varchar(36) NOT NULL,
|
||||||
|
`user_id` varchar(191) NOT NULL,
|
||||||
|
`level_id` varchar(191) NOT NULL,
|
||||||
|
`completed_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `idx_user_level` (`user_id`, `level_id`),
|
||||||
|
KEY `FK_wx_ulp_user` (`user_id`),
|
||||||
|
CONSTRAINT `FK_wx_ulp_user` FOREIGN KEY (`user_id`) REFERENCES `wx_users` (`id`) ON DELETE CASCADE ON UPDATE NO ACTION
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -24,9 +24,11 @@
|
|||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^4.0.3",
|
"@nestjs/config": "^4.0.3",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/jwt": "^11.0.2",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/swagger": "^11.2.6",
|
"@nestjs/swagger": "^11.2.6",
|
||||||
"@nestjs/typeorm": "^11.0.0",
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
|
"axios": "^1.14.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"mysql2": "^3.19.1",
|
"mysql2": "^3.19.1",
|
||||||
|
|||||||
147
pnpm-lock.yaml
generated
147
pnpm-lock.yaml
generated
@@ -17,6 +17,9 @@ importers:
|
|||||||
'@nestjs/core':
|
'@nestjs/core':
|
||||||
specifier: ^11.0.1
|
specifier: ^11.0.1
|
||||||
version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@nestjs/jwt':
|
||||||
|
specifier: ^11.0.2
|
||||||
|
version: 11.0.2(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||||
'@nestjs/platform-express':
|
'@nestjs/platform-express':
|
||||||
specifier: ^11.0.1
|
specifier: ^11.0.1
|
||||||
version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
|
version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
|
||||||
@@ -26,6 +29,9 @@ importers:
|
|||||||
'@nestjs/typeorm':
|
'@nestjs/typeorm':
|
||||||
specifier: ^11.0.0
|
specifier: ^11.0.0
|
||||||
version: 11.0.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.28(mysql2@3.19.1(@types/node@22.19.15))(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.9.3)))
|
version: 11.0.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.28(mysql2@3.19.1(@types/node@22.19.15))(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.9.3)))
|
||||||
|
axios:
|
||||||
|
specifier: ^1.14.0
|
||||||
|
version: 1.14.0
|
||||||
class-transformer:
|
class-transformer:
|
||||||
specifier: ^0.5.1
|
specifier: ^0.5.1
|
||||||
version: 0.5.1
|
version: 0.5.1
|
||||||
@@ -706,6 +712,11 @@ packages:
|
|||||||
'@nestjs/websockets':
|
'@nestjs/websockets':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@nestjs/jwt@11.0.2':
|
||||||
|
resolution: {integrity: sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
|
||||||
|
|
||||||
'@nestjs/mapped-types@2.1.0':
|
'@nestjs/mapped-types@2.1.0':
|
||||||
resolution: {integrity: sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==}
|
resolution: {integrity: sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -880,9 +891,15 @@ packages:
|
|||||||
'@types/json-schema@7.0.15':
|
'@types/json-schema@7.0.15':
|
||||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||||
|
|
||||||
|
'@types/jsonwebtoken@9.0.10':
|
||||||
|
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
|
||||||
|
|
||||||
'@types/methods@1.1.4':
|
'@types/methods@1.1.4':
|
||||||
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
|
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
|
||||||
|
|
||||||
|
'@types/ms@2.1.0':
|
||||||
|
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||||
|
|
||||||
'@types/node@22.19.15':
|
'@types/node@22.19.15':
|
||||||
resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
|
resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
|
||||||
|
|
||||||
@@ -1017,49 +1034,41 @@ packages:
|
|||||||
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
|
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
|
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
|
||||||
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
|
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
|
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
|
||||||
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
|
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
|
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
|
||||||
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
|
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
|
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
|
||||||
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
|
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
|
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
|
||||||
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
|
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
|
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
|
||||||
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
|
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
|
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
|
||||||
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
|
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
|
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
|
||||||
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
|
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
|
||||||
@@ -1260,6 +1269,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}
|
resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}
|
||||||
engines: {node: '>= 6.0.0'}
|
engines: {node: '>= 6.0.0'}
|
||||||
|
|
||||||
|
axios@1.14.0:
|
||||||
|
resolution: {integrity: sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==}
|
||||||
|
|
||||||
babel-jest@30.3.0:
|
babel-jest@30.3.0:
|
||||||
resolution: {integrity: sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==}
|
resolution: {integrity: sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==}
|
||||||
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
|
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||||
@@ -1333,6 +1345,9 @@ packages:
|
|||||||
bser@2.1.1:
|
bser@2.1.1:
|
||||||
resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==}
|
resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==}
|
||||||
|
|
||||||
|
buffer-equal-constant-time@1.0.1:
|
||||||
|
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||||
|
|
||||||
buffer-from@1.1.2:
|
buffer-from@1.1.2:
|
||||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||||
|
|
||||||
@@ -1597,6 +1612,9 @@ packages:
|
|||||||
eastasianwidth@0.2.0:
|
eastasianwidth@0.2.0:
|
||||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||||
|
|
||||||
|
ecdsa-sig-formatter@1.0.11:
|
||||||
|
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||||
|
|
||||||
ee-first@1.1.1:
|
ee-first@1.1.1:
|
||||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||||
|
|
||||||
@@ -1822,6 +1840,15 @@ packages:
|
|||||||
flatted@3.4.1:
|
flatted@3.4.1:
|
||||||
resolution: {integrity: sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==}
|
resolution: {integrity: sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==}
|
||||||
|
|
||||||
|
follow-redirects@1.15.11:
|
||||||
|
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
|
||||||
|
engines: {node: '>=4.0'}
|
||||||
|
peerDependencies:
|
||||||
|
debug: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
debug:
|
||||||
|
optional: true
|
||||||
|
|
||||||
for-each@0.3.5:
|
for-each@0.3.5:
|
||||||
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2263,6 +2290,16 @@ packages:
|
|||||||
jsonfile@6.2.0:
|
jsonfile@6.2.0:
|
||||||
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
|
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
|
||||||
|
|
||||||
|
jsonwebtoken@9.0.3:
|
||||||
|
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
|
||||||
|
engines: {node: '>=12', npm: '>=6'}
|
||||||
|
|
||||||
|
jwa@2.0.1:
|
||||||
|
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
|
||||||
|
|
||||||
|
jws@4.0.1:
|
||||||
|
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||||
|
|
||||||
@@ -2296,12 +2333,33 @@ packages:
|
|||||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
lodash.includes@4.3.0:
|
||||||
|
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||||
|
|
||||||
|
lodash.isboolean@3.0.3:
|
||||||
|
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
|
||||||
|
|
||||||
|
lodash.isinteger@4.0.4:
|
||||||
|
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
|
||||||
|
|
||||||
|
lodash.isnumber@3.0.3:
|
||||||
|
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
|
||||||
|
|
||||||
|
lodash.isplainobject@4.0.6:
|
||||||
|
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||||
|
|
||||||
|
lodash.isstring@4.0.1:
|
||||||
|
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
|
||||||
|
|
||||||
lodash.memoize@4.1.2:
|
lodash.memoize@4.1.2:
|
||||||
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
|
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
|
||||||
|
|
||||||
lodash.merge@4.6.2:
|
lodash.merge@4.6.2:
|
||||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||||
|
|
||||||
|
lodash.once@4.1.1:
|
||||||
|
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
||||||
|
|
||||||
lodash@4.17.23:
|
lodash@4.17.23:
|
||||||
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
||||||
|
|
||||||
@@ -2610,6 +2668,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
|
|
||||||
|
proxy-from-env@2.1.0:
|
||||||
|
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
punycode@2.3.1:
|
punycode@2.3.1:
|
||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -3995,6 +4057,12 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
|
'@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
|
||||||
|
|
||||||
|
'@nestjs/jwt@11.0.2(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))':
|
||||||
|
dependencies:
|
||||||
|
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@types/jsonwebtoken': 9.0.10
|
||||||
|
jsonwebtoken: 9.0.3
|
||||||
|
|
||||||
'@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)':
|
'@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
@@ -4184,8 +4252,15 @@ snapshots:
|
|||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
||||||
|
'@types/jsonwebtoken@9.0.10':
|
||||||
|
dependencies:
|
||||||
|
'@types/ms': 2.1.0
|
||||||
|
'@types/node': 22.19.15
|
||||||
|
|
||||||
'@types/methods@1.1.4': {}
|
'@types/methods@1.1.4': {}
|
||||||
|
|
||||||
|
'@types/ms@2.1.0': {}
|
||||||
|
|
||||||
'@types/node@22.19.15':
|
'@types/node@22.19.15':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
@@ -4563,6 +4638,14 @@ snapshots:
|
|||||||
|
|
||||||
aws-ssl-profiles@1.1.2: {}
|
aws-ssl-profiles@1.1.2: {}
|
||||||
|
|
||||||
|
axios@1.14.0:
|
||||||
|
dependencies:
|
||||||
|
follow-redirects: 1.15.11
|
||||||
|
form-data: 4.0.5
|
||||||
|
proxy-from-env: 2.1.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- debug
|
||||||
|
|
||||||
babel-jest@30.3.0(@babel/core@7.29.0):
|
babel-jest@30.3.0(@babel/core@7.29.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.29.0
|
'@babel/core': 7.29.0
|
||||||
@@ -4676,6 +4759,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
node-int64: 0.4.0
|
node-int64: 0.4.0
|
||||||
|
|
||||||
|
buffer-equal-constant-time@1.0.1: {}
|
||||||
|
|
||||||
buffer-from@1.1.2: {}
|
buffer-from@1.1.2: {}
|
||||||
|
|
||||||
buffer@5.7.1:
|
buffer@5.7.1:
|
||||||
@@ -4894,6 +4979,10 @@ snapshots:
|
|||||||
|
|
||||||
eastasianwidth@0.2.0: {}
|
eastasianwidth@0.2.0: {}
|
||||||
|
|
||||||
|
ecdsa-sig-formatter@1.0.11:
|
||||||
|
dependencies:
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
ee-first@1.1.1: {}
|
ee-first@1.1.1: {}
|
||||||
|
|
||||||
electron-to-chromium@1.5.313: {}
|
electron-to-chromium@1.5.313: {}
|
||||||
@@ -5156,6 +5245,8 @@ snapshots:
|
|||||||
|
|
||||||
flatted@3.4.1: {}
|
flatted@3.4.1: {}
|
||||||
|
|
||||||
|
follow-redirects@1.15.11: {}
|
||||||
|
|
||||||
for-each@0.3.5:
|
for-each@0.3.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-callable: 1.2.7
|
is-callable: 1.2.7
|
||||||
@@ -5774,6 +5865,30 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
|
|
||||||
|
jsonwebtoken@9.0.3:
|
||||||
|
dependencies:
|
||||||
|
jws: 4.0.1
|
||||||
|
lodash.includes: 4.3.0
|
||||||
|
lodash.isboolean: 3.0.3
|
||||||
|
lodash.isinteger: 4.0.4
|
||||||
|
lodash.isnumber: 3.0.3
|
||||||
|
lodash.isplainobject: 4.0.6
|
||||||
|
lodash.isstring: 4.0.1
|
||||||
|
lodash.once: 4.1.1
|
||||||
|
ms: 2.1.3
|
||||||
|
semver: 7.7.4
|
||||||
|
|
||||||
|
jwa@2.0.1:
|
||||||
|
dependencies:
|
||||||
|
buffer-equal-constant-time: 1.0.1
|
||||||
|
ecdsa-sig-formatter: 1.0.11
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
|
jws@4.0.1:
|
||||||
|
dependencies:
|
||||||
|
jwa: 2.0.1
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
json-buffer: 3.0.1
|
json-buffer: 3.0.1
|
||||||
@@ -5801,10 +5916,24 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
p-locate: 5.0.0
|
p-locate: 5.0.0
|
||||||
|
|
||||||
|
lodash.includes@4.3.0: {}
|
||||||
|
|
||||||
|
lodash.isboolean@3.0.3: {}
|
||||||
|
|
||||||
|
lodash.isinteger@4.0.4: {}
|
||||||
|
|
||||||
|
lodash.isnumber@3.0.3: {}
|
||||||
|
|
||||||
|
lodash.isplainobject@4.0.6: {}
|
||||||
|
|
||||||
|
lodash.isstring@4.0.1: {}
|
||||||
|
|
||||||
lodash.memoize@4.1.2: {}
|
lodash.memoize@4.1.2: {}
|
||||||
|
|
||||||
lodash.merge@4.6.2: {}
|
lodash.merge@4.6.2: {}
|
||||||
|
|
||||||
|
lodash.once@4.1.1: {}
|
||||||
|
|
||||||
lodash@4.17.23: {}
|
lodash@4.17.23: {}
|
||||||
|
|
||||||
log-symbols@4.1.0:
|
log-symbols@4.1.0:
|
||||||
@@ -6069,6 +6198,8 @@ snapshots:
|
|||||||
forwarded: 0.2.0
|
forwarded: 0.2.0
|
||||||
ipaddr.js: 1.9.1
|
ipaddr.js: 1.9.1
|
||||||
|
|
||||||
|
proxy-from-env@2.1.0: {}
|
||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
pure-rand@7.0.1: {}
|
pure-rand@7.0.1: {}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { AppConfigModule } from './config/config.module';
|
import { AppConfigModule } from './config/config.module';
|
||||||
import { WechatGameModule } from './modules/wechat-game/wechat-game.module';
|
import { WechatGameModule } from './modules/wechat-game/wechat-game.module';
|
||||||
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -24,6 +25,7 @@ import { WechatGameModule } from './modules/wechat-game/wechat-game.module';
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
WechatGameModule,
|
WechatGameModule,
|
||||||
|
AuthModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
13
src/common/decorators/current-user.decorator.ts
Normal file
13
src/common/decorators/current-user.decorator.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { JwtPayload } from '../guards/jwt-auth.guard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从请求中提取当前登录用户信息的装饰器
|
||||||
|
* 使用方式: @CurrentUser() user: JwtPayload
|
||||||
|
*/
|
||||||
|
export const CurrentUser = createParamDecorator(
|
||||||
|
(_data: unknown, ctx: ExecutionContext): JwtPayload => {
|
||||||
|
const request = ctx.switchToHttp().getRequest();
|
||||||
|
return request.user;
|
||||||
|
},
|
||||||
|
);
|
||||||
45
src/common/guards/jwt-auth.guard.ts
Normal file
45
src/common/guards/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { Request } from 'express';
|
||||||
|
|
||||||
|
export interface JwtPayload {
|
||||||
|
sub: string; // user id
|
||||||
|
openid: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard implements CanActivate {
|
||||||
|
constructor(private readonly jwtService: JwtService) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
const token = this.extractToken(request);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new UnauthorizedException('未提供访问令牌');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await this.jwtService.verifyAsync<JwtPayload>(token);
|
||||||
|
// 将用户信息挂载到 request 上
|
||||||
|
(request as any).user = payload;
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException('访问令牌无效或已过期');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractToken(request: Request): string | null {
|
||||||
|
const authorization = request.headers.authorization;
|
||||||
|
if (!authorization) return null;
|
||||||
|
|
||||||
|
const [type, token] = authorization.split(' ');
|
||||||
|
return type === 'Bearer' ? token : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,15 @@ class EnvironmentVariables {
|
|||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
DB_DATABASE: string = 'meme_mind';
|
DB_DATABASE: string = 'meme_mind';
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
WX_APPID: string = '';
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
WX_SECRET: string = '';
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
JWT_SECRET: string = 'default_jwt_secret_change_me';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateEnvironment(
|
export function validateEnvironment(
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ async function bootstrap() {
|
|||||||
.setTitle('MemeMind Server API')
|
.setTitle('MemeMind Server API')
|
||||||
.setDescription('微信小游戏 MemeMind 服务端 API 文档')
|
.setDescription('微信小游戏 MemeMind 服务端 API 文档')
|
||||||
.setVersion('1.0')
|
.setVersion('1.0')
|
||||||
|
.addBearerAuth()
|
||||||
.build();
|
.build();
|
||||||
const document = SwaggerModule.createDocument(app, config);
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
SwaggerModule.setup('api/docs', app, document);
|
SwaggerModule.setup('api/docs', app, document);
|
||||||
|
|||||||
110
src/modules/auth/auth.controller.ts
Normal file
110
src/modules/auth/auth.controller.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { WxLoginRequestDto, WxLoginResponseDto } from './dto/wx-login.dto';
|
||||||
|
import {
|
||||||
|
ConsumePointRequestDto,
|
||||||
|
EarnPointRequestDto,
|
||||||
|
GameDataResponseDto,
|
||||||
|
UserAssetsResponseDto,
|
||||||
|
} from './dto/user-assets.dto';
|
||||||
|
import { ApiResponseDto } from '../../common/dto/api-response.dto';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import type { JwtPayload } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
|
||||||
|
@ApiTags('用户认证与资产')
|
||||||
|
@Controller('v1')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
// ==================== 公开接口 ====================
|
||||||
|
|
||||||
|
@Post('auth/wx-login')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '微信登录',
|
||||||
|
description: '使用微信 wx.login 返回的 code 换取 JWT 令牌',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: '登录成功' })
|
||||||
|
@ApiResponse({ status: 401, description: '微信登录失败' })
|
||||||
|
async wxLogin(
|
||||||
|
@Body() dto: WxLoginRequestDto,
|
||||||
|
): Promise<ApiResponseDto<WxLoginResponseDto>> {
|
||||||
|
const data = await this.authService.wxLogin(dto.code);
|
||||||
|
return ApiResponseDto.success(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 需要鉴权的接口 ====================
|
||||||
|
|
||||||
|
@Get('user/assets')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '获取用户积分',
|
||||||
|
description: '获取当前登录用户的积分信息',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: '成功' })
|
||||||
|
@ApiResponse({ status: 401, description: '未授权' })
|
||||||
|
async getUserAssets(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
): Promise<ApiResponseDto<UserAssetsResponseDto>> {
|
||||||
|
const data = await this.authService.getUserAssets(user.sub);
|
||||||
|
return ApiResponseDto.success(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('user/assets/consume')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '消耗积分',
|
||||||
|
description: '消耗 1 积分(用于解锁提示)',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: '消耗成功' })
|
||||||
|
@ApiResponse({ status: 400, description: '积分不足' })
|
||||||
|
@ApiResponse({ status: 401, description: '未授权' })
|
||||||
|
async consumePoint(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Body() dto: ConsumePointRequestDto,
|
||||||
|
): Promise<ApiResponseDto<UserAssetsResponseDto>> {
|
||||||
|
const data = await this.authService.consumePoint(user.sub, dto);
|
||||||
|
return ApiResponseDto.success(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('user/assets/earn')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '获得积分',
|
||||||
|
description: '通关获得 1 积分(同一关卡不重复奖励)',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: '获得成功' })
|
||||||
|
@ApiResponse({ status: 401, description: '未授权' })
|
||||||
|
async earnPoint(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Body() dto: EarnPointRequestDto,
|
||||||
|
): Promise<ApiResponseDto<UserAssetsResponseDto>> {
|
||||||
|
const data = await this.authService.earnPoint(user.sub, dto);
|
||||||
|
return ApiResponseDto.success(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('user/game-data')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '获取游戏数据',
|
||||||
|
description: '获取用户积分和通关进度(Loading 页面使用)',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: '成功' })
|
||||||
|
@ApiResponse({ status: 401, description: '未授权' })
|
||||||
|
async getGameData(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
): Promise<ApiResponseDto<GameDataResponseDto>> {
|
||||||
|
const data = await this.authService.getGameData(user.sub);
|
||||||
|
return ApiResponseDto.success(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/modules/auth/auth.module.ts
Normal file
28
src/modules/auth/auth.module.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { User } from './entities/user.entity';
|
||||||
|
import { UserLevelProgress } from './entities/user-level-progress.entity';
|
||||||
|
import { UserRepository } from './repositories/user.repository';
|
||||||
|
import { UserLevelProgressRepository } from './repositories/user-level-progress.repository';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([User, UserLevelProgress]),
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
secret: configService.get<string>('JWT_SECRET'),
|
||||||
|
signOptions: { expiresIn: '7d' },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService, UserRepository, UserLevelProgressRepository],
|
||||||
|
exports: [JwtModule, AuthService],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
215
src/modules/auth/auth.service.ts
Normal file
215
src/modules/auth/auth.service.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
UnauthorizedException,
|
||||||
|
BadRequestException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { UserRepository } from './repositories/user.repository';
|
||||||
|
import { UserLevelProgressRepository } from './repositories/user-level-progress.repository';
|
||||||
|
import { WxLoginResponseDto, UserInfoDto } from './dto/wx-login.dto';
|
||||||
|
import {
|
||||||
|
UserAssetsResponseDto,
|
||||||
|
ConsumePointRequestDto,
|
||||||
|
EarnPointRequestDto,
|
||||||
|
GameDataResponseDto,
|
||||||
|
} from './dto/user-assets.dto';
|
||||||
|
import { JwtPayload } from '../../common/guards/jwt-auth.guard';
|
||||||
|
|
||||||
|
interface WxSessionResponse {
|
||||||
|
openid?: string;
|
||||||
|
session_key?: string;
|
||||||
|
errcode?: number;
|
||||||
|
errmsg?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
private readonly logger = new Logger(AuthService.name);
|
||||||
|
private readonly wxAppId: string;
|
||||||
|
private readonly wxSecret: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly userRepository: UserRepository,
|
||||||
|
private readonly userLevelProgressRepository: UserLevelProgressRepository,
|
||||||
|
) {
|
||||||
|
this.wxAppId = this.configService.get<string>('WX_APPID', '');
|
||||||
|
this.wxSecret = this.configService.get<string>('WX_SECRET', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信登录:code 换取 openid,创建或查找用户,签发 JWT
|
||||||
|
*/
|
||||||
|
async wxLogin(code: string): Promise<WxLoginResponseDto> {
|
||||||
|
// 1. 调用微信接口换取 openid
|
||||||
|
const wxSession = await this.getWxSession(code);
|
||||||
|
|
||||||
|
if (!wxSession.openid) {
|
||||||
|
this.logger.error(
|
||||||
|
`微信登录失败: errcode=${wxSession.errcode}, errmsg=${wxSession.errmsg}`,
|
||||||
|
);
|
||||||
|
throw new UnauthorizedException('微信登录失败,请重试');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 查找或创建用户
|
||||||
|
let user = await this.userRepository.findByOpenid(wxSession.openid);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
user = this.userRepository.create({
|
||||||
|
openid: wxSession.openid,
|
||||||
|
sessionKey: wxSession.session_key ?? null,
|
||||||
|
points: 10, // 新用户默认 10 积分
|
||||||
|
});
|
||||||
|
user = await this.userRepository.save(user);
|
||||||
|
this.logger.log(`新用户注册: ${user.id}`);
|
||||||
|
} else {
|
||||||
|
// 更新 session_key
|
||||||
|
if (wxSession.session_key) {
|
||||||
|
user.sessionKey = wxSession.session_key;
|
||||||
|
user = await this.userRepository.save(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 签发 JWT
|
||||||
|
const payload: JwtPayload = {
|
||||||
|
sub: user.id,
|
||||||
|
openid: user.openid,
|
||||||
|
};
|
||||||
|
const token = await this.jwtService.signAsync(payload);
|
||||||
|
|
||||||
|
// 4. 构造响应
|
||||||
|
const userInfo: UserInfoDto = {
|
||||||
|
id: user.id,
|
||||||
|
nickname: user.nickname,
|
||||||
|
points: user.points,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { token, user: userInfo };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户积分
|
||||||
|
*/
|
||||||
|
async getUserAssets(userId: string): Promise<UserAssetsResponseDto> {
|
||||||
|
const user = await this.findUserOrThrow(userId);
|
||||||
|
return { points: user.points };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消耗积分(解锁提示)
|
||||||
|
*/
|
||||||
|
async consumePoint(
|
||||||
|
userId: string,
|
||||||
|
dto: ConsumePointRequestDto,
|
||||||
|
): Promise<UserAssetsResponseDto> {
|
||||||
|
const user = await this.findUserOrThrow(userId);
|
||||||
|
|
||||||
|
if (user.points <= 0) {
|
||||||
|
throw new BadRequestException('积分不足,无法消耗');
|
||||||
|
}
|
||||||
|
|
||||||
|
user.points -= 1;
|
||||||
|
await this.userRepository.save(user);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`用户 ${userId} 消耗 1 积分(${dto.reason}),剩余: ${user.points}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { points: user.points };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获得积分(通关奖励)
|
||||||
|
*/
|
||||||
|
async earnPoint(
|
||||||
|
userId: string,
|
||||||
|
dto: EarnPointRequestDto,
|
||||||
|
): Promise<UserAssetsResponseDto> {
|
||||||
|
const user = await this.findUserOrThrow(userId);
|
||||||
|
|
||||||
|
// 检查是否已经领取过该关卡的通关奖励(防重复)
|
||||||
|
const existing = await this.userLevelProgressRepository.findByUserAndLevel(
|
||||||
|
userId,
|
||||||
|
dto.levelId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
this.logger.warn(
|
||||||
|
`用户 ${userId} 已完成关卡 ${dto.levelId},不重复奖励`,
|
||||||
|
);
|
||||||
|
return { points: user.points };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录通关进度
|
||||||
|
const progress = this.userLevelProgressRepository.create({
|
||||||
|
userId,
|
||||||
|
levelId: dto.levelId,
|
||||||
|
});
|
||||||
|
await this.userLevelProgressRepository.save(progress);
|
||||||
|
|
||||||
|
// 增加积分
|
||||||
|
user.points += 1;
|
||||||
|
await this.userRepository.save(user);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`用户 ${userId} 通关 ${dto.levelId},获得 1 积分,当前: ${user.points}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { points: user.points };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户游戏数据(Loading 页面复合接口)
|
||||||
|
*/
|
||||||
|
async getGameData(userId: string): Promise<GameDataResponseDto> {
|
||||||
|
const [user, progressList] = await Promise.all([
|
||||||
|
this.findUserOrThrow(userId),
|
||||||
|
this.userLevelProgressRepository.findByUserId(userId),
|
||||||
|
]);
|
||||||
|
const completedLevelIds = progressList.map((p) => p.levelId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
points: user.points,
|
||||||
|
},
|
||||||
|
completedLevelIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用微信 jscode2session 接口
|
||||||
|
*/
|
||||||
|
private async getWxSession(code: string): Promise<WxSessionResponse> {
|
||||||
|
const url = 'https://api.weixin.qq.com/sns/jscode2session';
|
||||||
|
const params = {
|
||||||
|
appid: this.wxAppId,
|
||||||
|
secret: this.wxSecret,
|
||||||
|
js_code: code,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get<WxSessionResponse>(url, { params });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('调用微信 jscode2session 失败:', error);
|
||||||
|
throw new UnauthorizedException('微信服务调用失败,请重试');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找用户,不存在则抛异常
|
||||||
|
*/
|
||||||
|
private async findUserOrThrow(userId: string) {
|
||||||
|
const user = await this.userRepository.findById(userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('用户不存在');
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/modules/auth/dto/user-assets.dto.ts
Normal file
48
src/modules/auth/dto/user-assets.dto.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class UserAssetsResponseDto {
|
||||||
|
@ApiProperty({ description: '积分' })
|
||||||
|
points: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConsumePointRequestDto {
|
||||||
|
@ApiProperty({ description: '消耗原因', enum: ['hint_unlock'] })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsIn(['hint_unlock'])
|
||||||
|
reason: 'hint_unlock';
|
||||||
|
|
||||||
|
@ApiProperty({ description: '关卡 ID', required: false })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
levelId?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '提示索引(2 或 3)', required: false })
|
||||||
|
@IsOptional()
|
||||||
|
hintIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EarnPointRequestDto {
|
||||||
|
@ApiProperty({ description: '获取原因', enum: ['level_complete'] })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsIn(['level_complete'])
|
||||||
|
reason: 'level_complete';
|
||||||
|
|
||||||
|
@ApiProperty({ description: '关卡 ID' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
levelId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GameDataResponseDto {
|
||||||
|
@ApiProperty({ description: '用户信息' })
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
points: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
@ApiProperty({ description: '已完成的关卡 ID 列表' })
|
||||||
|
completedLevelIds: string[];
|
||||||
|
}
|
||||||
28
src/modules/auth/dto/wx-login.dto.ts
Normal file
28
src/modules/auth/dto/wx-login.dto.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class WxLoginRequestDto {
|
||||||
|
@ApiProperty({ description: '微信 wx.login 返回的 code' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserInfoDto {
|
||||||
|
@ApiProperty({ description: '用户 ID' })
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '用户昵称', nullable: true })
|
||||||
|
nickname: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '积分' })
|
||||||
|
points: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WxLoginResponseDto {
|
||||||
|
@ApiProperty({ description: 'JWT 访问令牌' })
|
||||||
|
token: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '用户信息' })
|
||||||
|
user: UserInfoDto;
|
||||||
|
}
|
||||||
30
src/modules/auth/entities/user-level-progress.entity.ts
Normal file
30
src/modules/auth/entities/user-level-progress.entity.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { User } from './user.entity';
|
||||||
|
|
||||||
|
@Entity('wx_user_level_progress')
|
||||||
|
@Index('idx_user_level', ['userId', 'levelId'], { unique: true })
|
||||||
|
export class UserLevelProgress {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 191, name: 'user_id' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 191, name: 'level_id' })
|
||||||
|
levelId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => User)
|
||||||
|
@JoinColumn({ name: 'user_id' })
|
||||||
|
user: User;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'completed_at' })
|
||||||
|
completedAt: Date;
|
||||||
|
}
|
||||||
37
src/modules/auth/entities/user.entity.ts
Normal file
37
src/modules/auth/entities/user.entity.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('wx_users')
|
||||||
|
export class User {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index('idx_user_openid', { unique: true })
|
||||||
|
@Column({ type: 'varchar', length: 128 })
|
||||||
|
openid: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, name: 'session_key', nullable: true })
|
||||||
|
sessionKey: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
|
nickname: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'text', name: 'avatar_url', nullable: true })
|
||||||
|
avatarUrl: string | null;
|
||||||
|
|
||||||
|
/** 积分(默认 10) */
|
||||||
|
@Column({ type: 'int', default: 10 })
|
||||||
|
points: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { UserLevelProgress } from '../entities/user-level-progress.entity';
|
||||||
|
|
||||||
|
export interface IUserLevelProgressRepository {
|
||||||
|
findByUserId(userId: string): Promise<UserLevelProgress[]>;
|
||||||
|
findByUserAndLevel(
|
||||||
|
userId: string,
|
||||||
|
levelId: string,
|
||||||
|
): Promise<UserLevelProgress | null>;
|
||||||
|
create(data: Partial<UserLevelProgress>): UserLevelProgress;
|
||||||
|
save(progress: UserLevelProgress): Promise<UserLevelProgress>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { UserLevelProgress } from '../entities/user-level-progress.entity';
|
||||||
|
import { IUserLevelProgressRepository } from './user-level-progress.repository.interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UserLevelProgressRepository
|
||||||
|
implements IUserLevelProgressRepository
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(UserLevelProgress)
|
||||||
|
private readonly repository: Repository<UserLevelProgress>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findByUserId(userId: string): Promise<UserLevelProgress[]> {
|
||||||
|
return this.repository.find({ where: { userId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUserAndLevel(
|
||||||
|
userId: string,
|
||||||
|
levelId: string,
|
||||||
|
): Promise<UserLevelProgress | null> {
|
||||||
|
return this.repository.findOne({ where: { userId, levelId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
create(data: Partial<UserLevelProgress>): UserLevelProgress {
|
||||||
|
return this.repository.create(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(progress: UserLevelProgress): Promise<UserLevelProgress> {
|
||||||
|
return this.repository.save(progress);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { User } from '../entities/user.entity';
|
||||||
|
|
||||||
|
export interface IUserRepository {
|
||||||
|
findById(id: string): Promise<User | null>;
|
||||||
|
findByOpenid(openid: string): Promise<User | null>;
|
||||||
|
create(data: Partial<User>): User;
|
||||||
|
save(user: User): Promise<User>;
|
||||||
|
}
|
||||||
29
src/modules/auth/repositories/user.repository.ts
Normal file
29
src/modules/auth/repositories/user.repository.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { User } from '../entities/user.entity';
|
||||||
|
import { IUserRepository } from './user.repository.interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UserRepository implements IUserRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(User)
|
||||||
|
private readonly repository: Repository<User>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<User | null> {
|
||||||
|
return this.repository.findOne({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByOpenid(openid: string): Promise<User | null> {
|
||||||
|
return this.repository.findOne({ where: { openid } });
|
||||||
|
}
|
||||||
|
|
||||||
|
create(data: Partial<User>): User {
|
||||||
|
return this.repository.create(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(user: User): Promise<User> {
|
||||||
|
return this.repository.save(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user