feat: 支持登录、个人信息存储
This commit is contained in:
@@ -8,3 +8,8 @@ DB_DATABASE=mememind
|
||||
# Application Configuration
|
||||
NODE_ENV=production
|
||||
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/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/swagger": "^11.2.6",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"axios": "^1.14.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"mysql2": "^3.19.1",
|
||||
|
||||
147
pnpm-lock.yaml
generated
147
pnpm-lock.yaml
generated
@@ -17,6 +17,9 @@ importers:
|
||||
'@nestjs/core':
|
||||
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)
|
||||
'@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':
|
||||
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)
|
||||
@@ -26,6 +29,9 @@ importers:
|
||||
'@nestjs/typeorm':
|
||||
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)))
|
||||
axios:
|
||||
specifier: ^1.14.0
|
||||
version: 1.14.0
|
||||
class-transformer:
|
||||
specifier: ^0.5.1
|
||||
version: 0.5.1
|
||||
@@ -706,6 +712,11 @@ packages:
|
||||
'@nestjs/websockets':
|
||||
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':
|
||||
resolution: {integrity: sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==}
|
||||
peerDependencies:
|
||||
@@ -880,9 +891,15 @@ packages:
|
||||
'@types/json-schema@7.0.15':
|
||||
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':
|
||||
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
|
||||
|
||||
'@types/ms@2.1.0':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
|
||||
'@types/node@22.19.15':
|
||||
resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
|
||||
|
||||
@@ -1017,49 +1034,41 @@ packages:
|
||||
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
|
||||
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
|
||||
@@ -1260,6 +1269,9 @@ packages:
|
||||
resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
|
||||
axios@1.14.0:
|
||||
resolution: {integrity: sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==}
|
||||
|
||||
babel-jest@30.3.0:
|
||||
resolution: {integrity: sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==}
|
||||
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
@@ -1333,6 +1345,9 @@ packages:
|
||||
bser@2.1.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
@@ -1597,6 +1612,9 @@ packages:
|
||||
eastasianwidth@0.2.0:
|
||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||
|
||||
ee-first@1.1.1:
|
||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||
|
||||
@@ -1822,6 +1840,15 @@ packages:
|
||||
flatted@3.4.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2263,6 +2290,16 @@ packages:
|
||||
jsonfile@6.2.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
@@ -2296,12 +2333,33 @@ packages:
|
||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||
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:
|
||||
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
|
||||
|
||||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
lodash.once@4.1.1:
|
||||
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
||||
|
||||
lodash@4.17.23:
|
||||
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
||||
|
||||
@@ -2610,6 +2668,10 @@ packages:
|
||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||
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:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -3995,6 +4057,12 @@ snapshots:
|
||||
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/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)':
|
||||
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)
|
||||
@@ -4184,8 +4252,15 @@ snapshots:
|
||||
|
||||
'@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/ms@2.1.0': {}
|
||||
|
||||
'@types/node@22.19.15':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
@@ -4563,6 +4638,14 @@ snapshots:
|
||||
|
||||
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):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
@@ -4676,6 +4759,8 @@ snapshots:
|
||||
dependencies:
|
||||
node-int64: 0.4.0
|
||||
|
||||
buffer-equal-constant-time@1.0.1: {}
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
buffer@5.7.1:
|
||||
@@ -4894,6 +4979,10 @@ snapshots:
|
||||
|
||||
eastasianwidth@0.2.0: {}
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
ee-first@1.1.1: {}
|
||||
|
||||
electron-to-chromium@1.5.313: {}
|
||||
@@ -5156,6 +5245,8 @@ snapshots:
|
||||
|
||||
flatted@3.4.1: {}
|
||||
|
||||
follow-redirects@1.15.11: {}
|
||||
|
||||
for-each@0.3.5:
|
||||
dependencies:
|
||||
is-callable: 1.2.7
|
||||
@@ -5774,6 +5865,30 @@ snapshots:
|
||||
optionalDependencies:
|
||||
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:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
@@ -5801,10 +5916,24 @@ snapshots:
|
||||
dependencies:
|
||||
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.merge@4.6.2: {}
|
||||
|
||||
lodash.once@4.1.1: {}
|
||||
|
||||
lodash@4.17.23: {}
|
||||
|
||||
log-symbols@4.1.0:
|
||||
@@ -6069,6 +6198,8 @@ snapshots:
|
||||
forwarded: 0.2.0
|
||||
ipaddr.js: 1.9.1
|
||||
|
||||
proxy-from-env@2.1.0: {}
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
pure-rand@7.0.1: {}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AppConfigModule } from './config/config.module';
|
||||
import { WechatGameModule } from './modules/wechat-game/wechat-game.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -24,6 +25,7 @@ import { WechatGameModule } from './modules/wechat-game/wechat-game.module';
|
||||
}),
|
||||
}),
|
||||
WechatGameModule,
|
||||
AuthModule,
|
||||
],
|
||||
})
|
||||
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()
|
||||
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(
|
||||
|
||||
@@ -33,6 +33,7 @@ async function bootstrap() {
|
||||
.setTitle('MemeMind Server API')
|
||||
.setDescription('微信小游戏 MemeMind 服务端 API 文档')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
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