diff --git a/.env.production b/.env.production index de31710..67128fe 100644 --- a/.env.production +++ b/.env.production @@ -8,3 +8,8 @@ DB_DATABASE=mememind # Application Configuration NODE_ENV=production PORT=3000 + + +WX_APPID=wx0f9c909d20d19396 +WX_SECRET=c5635680747cccf351f5f323c01178e6 +JWT_SECRET=mp-xieyingen \ No newline at end of file diff --git a/.env.production.example b/.env.production.example deleted file mode 100644 index 0bd58b6..0000000 --- a/.env.production.example +++ /dev/null @@ -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 diff --git a/ARCHITECTURE_DIAGRAMS.md b/ARCHITECTURE_DIAGRAMS.md new file mode 100644 index 0000000..9cacb50 --- /dev/null +++ b/ARCHITECTURE_DIAGRAMS.md @@ -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* diff --git a/CLIENT_SERVER_INTEGRATION.md b/CLIENT_SERVER_INTEGRATION.md new file mode 100644 index 0000000..c5a415f --- /dev/null +++ b/CLIENT_SERVER_INTEGRATION.md @@ -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 { + try { + // Initialize() is called by PageLoading during startup + const response = await HttpUtil.get( + '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 +``` + +**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> { + // 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> { + // 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> { + // 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> { + // 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 { + 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> { + // 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> { + // 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> { + // 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( + url: string, + data: any, + options?: RequestOptions +): Promise> { + 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* diff --git a/SERVER_ANALYSIS.md b/SERVER_ANALYSIS.md new file mode 100644 index 0000000..6ce22f5 --- /dev/null +++ b/SERVER_ANALYSIS.md @@ -0,0 +1,1191 @@ +# MemeMind-Server Backend Analysis + +**Date**: 2026-04-05 +**Project**: MemeMind-Server (NestJS + TypeORM) +**Location**: `/Users/richard/Documents/code/xieyingeng/MemeMind-Server` + +## Executive Summary + +MemeMind-Server is a NestJS backend for a WeChat mini-game with a clean layered architecture. It provides two main modules: **WechatGame** (game level and config management) and **AppConfig** (environment setup). The server uses TypeORM with MySQL as the database and follows enterprise patterns including repositories, DTOs, and global error handling. + +--- + +## 1. Project Structure + +``` +MemeMind-Server/ +├── src/ +│ ├── main.ts # App entry point +│ ├── app.module.ts # Root module (imports config & wechat-game) +│ ├── common/ +│ │ ├── dto/ +│ │ │ └── api-response.dto.ts # Generic API response wrapper +│ │ └── filters/ +│ │ └── http-exception.filter.ts # Global exception handler +│ ├── config/ +│ │ ├── config.module.ts # Environment config setup +│ │ ├── database.config.ts # TypeORM & MySQL config +│ │ └── env.validation.ts # Environment variable validation +│ └── modules/ +│ └── wechat-game/ +│ ├── wechat-game.module.ts # Module definition +│ ├── wechat-game.controller.ts # 4 REST endpoints +│ ├── wechat-game.service.ts # Business logic +│ ├── entities/ +│ │ ├── level.entity.ts # Level data model +│ │ └── game-config.entity.ts # Configuration model +│ ├── dto/ +│ │ ├── level-response.dto.ts # Level API response +│ │ └── game-config-response.dto.ts # Config API response +│ └── repositories/ +│ ├── level.repository.ts # Level DB access +│ ├── level.repository.interface.ts +│ ├── game-config.repository.ts # Config DB access +│ └── game-config.repository.interface.ts +├── test/ +│ ├── app.e2e-spec.ts +│ └── jest-e2e.json +├── package.json +├── tsconfig.json +├── tsconfig.build.json +└── nest-cli.json +``` + +--- + +## 2. Package Dependencies + +### Core Framework +- **@nestjs/core** ^11.0.1 - NestJS core +- **@nestjs/common** ^11.0.1 - Common utilities +- **@nestjs/platform-express** ^11.0.1 - Express integration +- **@nestjs/config** ^4.0.3 - Environment management +- **@nestjs/typeorm** ^11.0.0 - TypeORM integration +- **@nestjs/swagger** ^11.2.6 - API documentation + +### Database +- **typeorm** ^0.3.28 - ORM framework +- **mysql2** ^3.19.1 - MySQL driver + +### Data Validation & Transformation +- **class-validator** ^0.15.1 - DTO validation +- **class-transformer** ^0.5.1 - DTO transformation +- **reflect-metadata** ^0.2.2 - Reflection for decorators + +### Utilities +- **rxjs** ^7.8.1 - Reactive programming + +### Development Tools +- **@nestjs/cli** ^11.0.0 +- **@nestjs/testing** ^11.0.1 +- **typescript** ^5.7.3 +- **ts-jest** ^29.2.5 +- **jest** ^30.0.0 +- **prettier** ^3.4.2 +- **eslint** ^9.18.0 + +--- + +## 3. Application Setup (main.ts) + +**File**: `src/main.ts` (46 lines) + +```typescript +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + // Global prefix for all routes: /api + app.setGlobalPrefix('api'); + + // CORS enabled (supports WeChat mini-game cross-origin requests) + app.enableCors({ + origin: true, + credentials: true, + }); + + // Global validation pipe + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, // Strip unknown properties + forbidNonWhitelisted: true, // Throw on unknown properties + transform: true, // Auto-transform DTOs + }), + ); + + // Global exception filter + app.useGlobalFilters(new HttpExceptionFilter()); + + // Swagger documentation at /api/docs + const config = new DocumentBuilder() + .setTitle('MemeMind Server API') + .setDescription('微信小游戏 MemeMind 服务端 API 文档') + .setVersion('1.0') + .build(); + + const port = process.env.PORT ?? 3000; + await app.listen(port); +} +``` + +### Key Setup Details +- **Global prefix**: All routes start with `/api` +- **CORS**: Enabled for WeChat mini-game frontend +- **Validation**: Auto-validates all DTO inputs, strips unknown fields +- **Exception handling**: Global filter wraps all errors in `ApiResponseDto` +- **Swagger**: Auto-generated API docs at `/api/docs` +- **Default port**: 3000 (configurable via `PORT` env var) + +--- + +## 4. Root Module Configuration (app.module.ts) + +**File**: `src/app.module.ts` (30 lines) + +```typescript +@Module({ + imports: [ + AppConfigModule, // Environment setup + TypeOrmModule.forRootAsync({ // Dynamic DB config + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + type: 'mysql', + host: configService.get('database.host'), + port: configService.get('database.port'), + username: configService.get('database.username'), + password: configService.get('database.password'), + database: configService.get('database.database'), + entities: [__dirname + '/**/*.entity{.ts,.js}'], + synchronize: NODE_ENV !== 'production', // Auto-create tables in dev + logging: NODE_ENV !== 'production', // Log queries in dev + autoLoadEntities: true, // Auto-discover entities + }), + }), + WechatGameModule, // Game module + ], +}) +export class AppModule {} +``` + +### Key Configuration +- **Async TypeORM config**: Reads database settings from environment +- **Auto-synchronization**: In development, automatically creates/updates tables +- **Auto-load entities**: Discovers all `.entity.ts` files +- **Logging**: Database queries logged in development mode + +--- + +## 5. Environment Configuration + +### Files +- **config.module.ts**: Global config setup +- **database.config.ts**: Database connection parameters +- **env.validation.ts**: Type-safe environment variables + +### Configuration Structure (env.validation.ts) + +```typescript +class EnvironmentVariables { + @IsEnum(Environment) + NODE_ENV: Environment = 'development'; // 'development' | 'production' | 'test' + + @IsNumber() + PORT: number = 3000; + + @IsString() + DB_HOST: string = 'localhost'; + + @IsNumber() + DB_PORT: number = 3306; + + @IsString() + DB_USERNAME: string = 'meme_user'; + + @IsString() + DB_PASSWORD: string = ''; + + @IsString() + DB_DATABASE: string = 'meme_mind'; +} +``` + +### Environment Files +The app looks for environment files in this order: +1. `.env.local` (local override) +2. `.env.production` (production override) +3. `.env` (default) + +### Database Configuration (database.config.ts) +- **Default MySQL connection**: localhost:3306 +- **Fallback credentials**: meme_user / (no password) +- **Default database**: meme_mind +- **Auto-synchronize**: ✅ Enabled in development +- **Query logging**: ✅ Enabled in development + +--- + +## 6. Global Response Format + +### ApiResponseDto (src/common/dto/api-response.dto.ts) + +All API responses follow this generic wrapper: + +```typescript +interface ApiResponseDto { + success: boolean; // Request successful? + data: T | null; // Response data (null on error) + message: string | null; // Error message (null on success) + timestamp: Date; // Response timestamp + + // Static factory methods + static success(data: T): ApiResponseDto + static error(message: string): ApiResponseDto +} +``` + +### Example Success Response +```json +{ + "success": true, + "data": { + "levels": [...], + "total": 50 + }, + "message": null, + "timestamp": "2026-04-05T10:30:00.000Z" +} +``` + +### Example Error Response +```json +{ + "success": false, + "data": null, + "message": "Level with id 'xyz' not found", + "timestamp": "2026-04-05T10:30:00.000Z", + "path": "/api/v1/wechat-game/levels/xyz" +} +``` + +--- + +## 7. Global Exception Handler + +### HttpExceptionFilter (src/common/filters/http-exception.filter.ts) + +```typescript +@Catch() +export class HttpExceptionFilter implements ExceptionFilter { + catch(exception: unknown, host: ArgumentsHost) { + // Extracts HTTP status code from exception + // Wraps error in ApiResponseDto format + // Includes request path for debugging + // Logs unexpected errors + } +} +``` + +**Features**: +- ✅ Catches all exceptions (HTTP and runtime errors) +- ✅ Wraps all errors in standard `ApiResponseDto` format +- ✅ Includes request URL in error response +- ✅ Logs errors to console +- ✅ Proper HTTP status codes (404, 400, 500, etc.) + +--- + +## 8. WechatGame Module Architecture + +### Module Structure (wechat-game.module.ts) + +```typescript +@Module({ + imports: [TypeOrmModule.forFeature([GameConfig, Level])], + controllers: [WechatGameController], + providers: [WechatGameService, GameConfigRepository, LevelRepository], + exports: [WechatGameService], +}) +export class WechatGameModule {} +``` + +### Layer Architecture + +``` +┌─────────────────────────────────────┐ +│ HTTP Requests (REST Controller) │ +│ Base: /api/v1/wechat-game │ +└──────────────┬──────────────────────┘ + │ +┌──────────────▼──────────────────────┐ +│ Business Logic (Service) │ +│ • Query logic │ +│ • DTO mapping │ +│ • Error handling │ +└──────────────┬──────────────────────┘ + │ +┌──────────────▼──────────────────────┐ +│ Data Access (Repositories) │ +│ • Level Repository │ +│ • GameConfig Repository │ +└──────────────┬──────────────────────┘ + │ +┌──────────────▼──────────────────────┐ +│ TypeORM (Database Layer) │ +│ • MySQL queries │ +│ • Transaction management │ +└─────────────────────────────────────┘ +``` + +--- + +## 9. REST API Endpoints + +### Base URL: `/api/v1/wechat-game` + +**Endpoint 1: Get All Game Configs** +``` +GET /api/v1/wechat-game/configs +Response: ApiResponseDto +Status: 200 +``` + +**Endpoint 2: Get Config by Key** +``` +GET /api/v1/wechat-game/configs/:key +Response: ApiResponseDto +Status: 200 | 404 (not found) +``` + +**Endpoint 3: Get All Levels** +``` +GET /api/v1/wechat-game/levels +Response: ApiResponseDto +Status: 200 +Description: Returns levels sorted by sort_order +``` + +**Endpoint 4: Get Level by ID** +``` +GET /api/v1/wechat-game/levels/:id +Response: ApiResponseDto +Status: 200 | 404 (not found) +``` + +--- + +## 10. Data Models + +### Entity 1: Level (src/modules/wechat-game/entities/level.entity.ts) + +```typescript +@Entity('levels') +export class Level { + @PrimaryColumn({ type: 'varchar', length: 191 }) + id: string; // UUID or custom ID + + @Column({ type: 'varchar', length: 191, name: 'image_url' }) + imageUrl: string; // Game image URL + + @Column({ type: 'varchar', length: 191 }) + answer: string; // Correct answer (single string) + + @Column({ type: 'varchar', length: 191, nullable: true }) + hint1: string | null; // First hint (free) + + @Column({ type: 'varchar', length: 191, nullable: true }) + hint2: string | null; // Second hint (costs 1 life) + + @Column({ type: 'varchar', length: 191, nullable: true }) + hint3: string | null; // Third hint (costs 1 life) + + @Column({ type: 'int', name: 'sort_order', default: 0 }) + sortOrder: number; // Order of display + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} +``` + +**Database Table**: `levels` + +### Entity 2: GameConfig (src/modules/wechat-game/entities/game-config.entity.ts) + +```typescript +@Entity('game_configs') +export class GameConfig { + @PrimaryGeneratedColumn('uuid') + id: string; // Auto-generated UUID + + @Column({ type: 'varchar', length: 255, name: 'config_key' }) + configKey: string; // Unique config identifier + + @Column({ type: 'text', name: 'config_value' }) + configValue: string; // Config value (stored as text) + + @Column({ + type: 'varchar', + length: 100, + nullable: true, + }) + description: string | null; // Optional description + + @Column({ type: 'boolean', default: true, name: 'is_active' }) + isActive: boolean; // Is this config active? + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} +``` + +**Database Table**: `game_configs` + +--- + +## 11. Service Layer (WechatGameService) + +**File**: `src/modules/wechat-game/wechat-game.service.ts` (93 lines) + +### Methods + +#### 1. getAllConfigs() +```typescript +async getAllConfigs(): Promise { + const configs = await this.gameConfigRepository.findActiveConfigs(); + return { + configs: configs.map((config) => this.toResponseDto(config)), + total: configs.length, + }; +} +``` +- Returns only `isActive: true` configurations +- Maps entities to DTOs +- Returns list with total count + +#### 2. getConfigByKey(key: string) +```typescript +async getConfigByKey(key: string): Promise { + const config = await this.gameConfigRepository.findByKey(key); + + if (!config) { + throw new NotFoundException(`Game config with key "${key}" not found`); + } + + return this.toResponseDto(config); +} +``` +- Throws 404 if not found +- Maps entity to DTO + +#### 3. getAllLevels() +```typescript +async getAllLevels(): Promise { + const levels = await this.levelRepository.findAllOrdered(); + + return { + levels: levels.map((level, index) => + this.toLevelResponseDto(level, index + 1), // 1-indexed level number + ), + total: levels.length, + }; +} +``` +- Fetches levels in sort_order +- Adds computed `level` number (1-indexed) +- Returns list with total count + +#### 4. getLevelById(id: string) +```typescript +async getLevelById(id: string): Promise { + const levels = await this.levelRepository.findAllOrdered(); + const levelIndex = levels.findIndex((l) => l.id === id); + + if (levelIndex === -1) { + throw new NotFoundException(`Level with id "${id}" not found`); + } + + return this.toLevelResponseDto(levels[levelIndex], levelIndex + 1); +} +``` +- Fetches all levels and finds by ID (not optimal - see improvements) +- Throws 404 if not found +- Adds computed `level` number + +#### 5. toResponseDto(config) +```typescript +private toResponseDto(config: GameConfig): GameConfigResponseDto { + return { + id: config.id, + configKey: config.configKey, + configValue: config.configValue, + description: config.description, + isActive: config.isActive, + createdAt: config.createdAt, + updatedAt: config.updatedAt, + }; +} +``` + +#### 6. toLevelResponseDto(level, levelNumber) +```typescript +private toLevelResponseDto(level: Level, levelNumber: number): LevelResponseDto { + return { + level: levelNumber, // Computed field + 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, + }; +} +``` + +--- + +## 12. Repository Pattern + +### Repository Interfaces + +**ILevelRepository**: +```typescript +interface ILevelRepository { + findAll(): Promise; + findById(id: string): Promise; + findAllOrdered(): Promise; // Ordered by sortOrder ASC +} +``` + +**IGameConfigRepository**: +```typescript +interface IGameConfigRepository { + findAll(): Promise; + findById(id: string): Promise; + findByKey(key: string): Promise; + findActiveConfigs(): Promise; // Where isActive = true +} +``` + +### Implementations + +**LevelRepository** (src/modules/wechat-game/repositories/level.repository.ts): +```typescript +@Injectable() +export class LevelRepository implements ILevelRepository { + constructor( + @InjectRepository(Level) + private readonly repository: Repository, + ) {} + + async findAll(): Promise { + return this.repository.find(); + } + + async findById(id: string): Promise { + return this.repository.findOne({ where: { id } }); + } + + async findAllOrdered(): Promise { + return this.repository.find({ + order: { sortOrder: 'ASC' }, + }); + } +} +``` + +**GameConfigRepository** (src/modules/wechat-game/repositories/game-config.repository.ts): +```typescript +@Injectable() +export class GameConfigRepository implements IGameConfigRepository { + constructor( + @InjectRepository(GameConfig) + private readonly repository: Repository, + ) {} + + async findAll(): Promise { + return this.repository.find(); + } + + async findById(id: string): Promise { + return this.repository.findOne({ where: { id } }); + } + + async findByKey(key: string): Promise { + return this.repository.findOne({ where: { configKey: key } }); + } + + async findActiveConfigs(): Promise { + return this.repository.find({ where: { isActive: true } }); + } +} +``` + +--- + +## 13. Data Transfer Objects (DTOs) + +### LevelResponseDto (src/modules/wechat-game/dto/level-response.dto.ts) + +```typescript +export class LevelResponseDto { + @ApiProperty({ description: '关卡编号' }) + level: number; // Computed level number (1-indexed) + + @ApiProperty({ description: '关卡ID' }) + id: string; + + @ApiProperty({ description: '图片URL' }) + imageUrl: string; + + @ApiProperty({ description: '答案' }) + answer: string; + + @ApiProperty({ description: '提示1', nullable: true }) + hint1: string | null; + + @ApiProperty({ description: '提示2', nullable: true }) + hint2: string | null; + + @ApiProperty({ description: '提示3', nullable: true }) + hint3: string | null; + + @ApiProperty({ description: '排序顺序' }) + sortOrder: number; + + @ApiProperty({ description: '创建时间' }) + createdAt: Date; + + @ApiProperty({ description: '更新时间' }) + updatedAt: Date; +} + +export class LevelListResponseDto { + @ApiProperty({ type: [LevelResponseDto], description: '关卡列表' }) + levels: LevelResponseDto[]; + + @ApiProperty({ description: '关卡总数' }) + total: number; +} +``` + +### GameConfigResponseDto (src/modules/wechat-game/dto/game-config-response.dto.ts) + +```typescript +export class GameConfigResponseDto { + @ApiProperty({ description: '配置ID' }) + id: string; + + @ApiProperty({ description: '配置键名' }) + configKey: string; + + @ApiProperty({ description: '配置值' }) + configValue: string; + + @ApiProperty({ description: '配置描述', nullable: true }) + description: string | null; + + @ApiProperty({ description: '是否激活' }) + isActive: boolean; + + @ApiProperty({ description: '创建时间' }) + createdAt: Date; + + @ApiProperty({ description: '更新时间' }) + updatedAt: Date; +} + +export class GameConfigListResponseDto { + @ApiProperty({ type: [GameConfigResponseDto], description: '配置列表' }) + configs: GameConfigResponseDto[]; + + @ApiProperty({ description: '配置总数' }) + total: number; +} +``` + +--- + +## 14. Controller (WechatGameController) + +**File**: `src/modules/wechat-game/wechat-game.controller.ts` (69 lines) + +```typescript +@ApiTags('微信小游戏') +@Controller('v1/wechat-game') +export class WechatGameController { + constructor(private readonly wechatGameService: WechatGameService) {} + + @Get('configs') + @ApiOperation({ + summary: '获取所有游戏配置', + description: '获取所有激活的游戏配置列表', + }) + async getAllConfigs(): Promise> { + const data = await this.wechatGameService.getAllConfigs(); + return ApiResponseDto.success(data); + } + + @Get('configs/:key') + @ApiOperation({ + summary: '根据key获取配置', + }) + async getConfigByKey( + @Param('key') key: string, + ): Promise> { + const data = await this.wechatGameService.getConfigByKey(key); + return ApiResponseDto.success(data); + } + + @Get('levels') + @ApiOperation({ + summary: '获取所有关卡', + description: '获取所有关卡列表,按sort_order排序', + }) + async getAllLevels(): Promise> { + const data = await this.wechatGameService.getAllLevels(); + return ApiResponseDto.success(data); + } + + @Get('levels/:id') + @ApiOperation({ + summary: '根据ID获取关卡', + }) + async getLevelById( + @Param('id') id: string, + ): Promise> { + const data = await this.wechatGameService.getLevelById(id); + return ApiResponseDto.success(data); + } +} +``` + +### Features +- ✅ All endpoints wrapped in generic `ApiResponseDto` +- ✅ Full Swagger documentation (`@ApiOperation`, `@ApiResponse`) +- ✅ Proper HTTP methods and status codes +- ✅ Route parameters validated by NestJS + +--- + +## 15. Integration with Client + +### Client API Call Pattern + +From the Cocos Creator client (`LevelDataManager.ts`), the server is called: + +```typescript +// Client calls +const response = await HttpUtil.get('https://ilookai.cn/api/v1/wechat-game/levels'); + +// Server Response +{ + "success": true, + "data": { + "levels": [ + { + "level": 1, + "id": "level-001", + "imageUrl": "https://example.com/image.jpg", + "answer": "answer text", + "hint1": "hint 1", + "hint2": "hint 2", + "hint3": "hint 3", + "sortOrder": 0, + "createdAt": "2026-04-05T00:00:00Z", + "updatedAt": "2026-04-05T00:00:00Z" + }, + ... + ], + "total": 50 + }, + "message": null, + "timestamp": "2026-04-05T10:30:00.000Z" +} +``` + +### Client Integration Points + +1. **Level Loading**: Client calls `GET /api/v1/wechat-game/levels` +2. **Single Level Fetch**: Client calls `GET /api/v1/wechat-game/levels/:id` (though it doesn't currently) +3. **Config Management**: Client can call `GET /api/v1/wechat-game/configs` for game settings + +--- + +## 16. Architecture Patterns + +### 1. Dependency Injection +- NestJS constructor injection for all dependencies +- Repository interfaces decoupled from implementations +- Testable design + +### 2. Repository Pattern +- Repositories abstract database access +- Implements `IRepository` interfaces +- Easy to mock for testing + +### 3. Service Layer Pattern +- Services contain business logic +- Controllers delegate to services +- Services delegate to repositories + +### 4. DTO Pattern +- DTOs separate API contracts from internal entities +- Validation and transformation in DTOs +- Swagger documentation on DTO fields + +### 5. Global Exception Handling +- Single exception filter wraps all errors +- Consistent error response format +- Centralized logging + +### 6. Configuration Management +- Environment-based configuration +- Typed environment variables +- Validation on startup + +--- + +## 17. TypeORM Configuration Details + +### Auto-Discovery +- Entities auto-loaded from: `**/*.entity{.ts,.js}` +- Repositories auto-created by `@InjectRepository()` + +### Development Features +- ✅ Database synchronization (auto-create tables) +- ✅ Query logging to console +- ✅ SQL visible in development + +### Production Features +- ✅ No auto-sync (safer) +- ✅ No query logging (better performance) +- ✅ Connection pooling via mysql2 + +### Column Mapping +- Snake_case in database: `image_url`, `sort_order`, `created_at` +- Camel case in entities: `imageUrl`, `sortOrder`, `createdAt` +- Auto-converted via TypeORM decorators + +--- + +## 18. Security Patterns + +### Global Validation Pipe +```typescript +new ValidationPipe({ + whitelist: true, // ✅ Remove unknown properties + forbidNonWhitelisted: true, // ✅ Throw if unknown properties + transform: true, // ✅ Auto-transform types +}) +``` + +### Considerations for Production +- ⚠️ No authentication middleware currently +- ⚠️ No rate limiting +- ⚠️ No request logging middleware +- ⚠️ CORS allows all origins (fine for public API) +- ⚠️ No input sanitization beyond whitelist + +### Recommendations +1. Add JWT authentication for admin endpoints +2. Add rate limiting (e.g., `@nestjs/throttler`) +3. Add request logging (e.g., Morgan middleware) +4. Add input validation for text fields (sanitize HTML) +5. Add API key authentication for WeChat client + +--- + +## 19. Performance Considerations + +### Current Implementation +``` +GET /api/v1/wechat-game/levels/:id + └─> getAllLevels() - Fetches ALL levels + └─> findAllOrdered() - Database query for all levels + └─> Memory search for :id - O(n) in application +``` + +**Problem**: `getLevelById()` fetches all levels, then searches in memory + +### Optimization +```typescript +// BETTER - Direct database query +async getLevelById(id: string): Promise { + const level = await this.levelRepository.findById(id); + if (!level) throw new NotFoundException(); + + // Need to get level index for number calculation + const index = await this.levelRepository.getLevelIndex(id); + return this.toLevelResponseDto(level, index + 1); +} +``` + +### Database Indexing +- Consider adding index on `levels.sort_order` for sorting +- Consider adding index on `game_configs.config_key` for lookups +- Consider adding composite index on `game_configs(config_key, is_active)` + +--- + +## 20. API Response Flow Diagram + +``` +┌─────────────────────────┐ +│ HTTP Request │ +│ GET /api/v1/... │ +└────────────┬────────────┘ + │ +┌────────────▼────────────┐ +│ Global Validation Pipe │ ← Validates path params +└────────────┬────────────┘ + │ +┌────────────▼────────────────────────┐ +│ Controller Method │ +│ @Get('levels/:id') │ +└────────────┬────────────────────────┘ + │ +┌────────────▼────────────────────────┐ +│ Service Method │ +│ getLevelById(id) │ +│ - Repository query │ +│ - DTO mapping │ +│ - Null check → NotFoundException │ +└────────────┬────────────────────────┘ + │ + Success ───────────────┐ + or Error │ + │ │ +┌────────────▼────────────────────────┐ +│ Global Exception Filter (if error) │ +│ Wraps in ApiResponseDto │ +└────────────┬────────────────────────┘ + │ +┌────────────▼────────────────────────┐ +│ ApiResponseDto.success() or error()│ +│ Wraps in standard response format │ +└────────────┬────────────────────────┘ + │ +┌────────────▼────────────────────────┐ +│ HTTP Response (200, 400, 404, 500) │ +│ JSON: {success, data, message} │ +└─────────────────────────────────────┘ +``` + +--- + +## 21. Key Findings + +### Strengths ✅ +1. **Clean architecture**: Proper separation of concerns (Controller → Service → Repository) +2. **Type safety**: Full TypeScript with DTOs and entity types +3. **Error handling**: Centralized exception filter with consistent format +4. **Documentation**: Swagger auto-documentation on all endpoints +5. **Configuration**: Type-safe environment variables with validation +6. **Database**: TypeORM with MySQL, auto-sync in dev +7. **Validation**: Global validation pipe with whitelist protection +8. **Modularity**: Feature-based module structure + +### Potential Issues ⚠️ +1. **Performance**: `getLevelById()` fetches all levels instead of direct query +2. **Authentication**: No auth mechanism for sensitive endpoints +3. **Rate limiting**: No rate limiting implemented +4. **Caching**: No caching layer (clients could cache locally) +5. **Pagination**: No pagination on large datasets +6. **Middleware**: Limited middleware (no logging, no request IDs) +7. **Testing**: No E2E tests implemented yet + +### Missing Features 🔧 +1. Authentication & Authorization (guards, JWT tokens) +2. POST/PUT/DELETE endpoints (read-only currently) +3. User management module +4. Score/points tracking +5. Level submission endpoints +6. Statistics/leaderboard endpoints + +--- + +## 22. Database Schema + +### levels table +```sql +CREATE TABLE levels ( + id VARCHAR(191) PRIMARY KEY, + image_url VARCHAR(191) NOT NULL, + answer VARCHAR(191) NOT NULL, + hint1 VARCHAR(191), + hint2 VARCHAR(191), + hint3 VARCHAR(191), + sort_order INT DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_sort_order (sort_order) +); +``` + +### game_configs table +```sql +CREATE TABLE game_configs ( + id CHAR(36) PRIMARY KEY, -- UUID + config_key VARCHAR(255) NOT NULL UNIQUE, + config_value TEXT NOT NULL, + description VARCHAR(100), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_config_key (config_key), + KEY idx_active (is_active) +); +``` + +--- + +## 23. Extension Points + +### Adding User Authentication + +```typescript +// 1. Create users.entity.ts +@Entity('users') +export class User { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + wxOpenId: string; // WeChat OpenID + + @Column() + nickname: string; + + @Column() + lives: number; // Current lives + + @Column() + currentLevel: number; // Progress +} + +// 2. Create user.module.ts with UserController, UserService, UserRepository +// 3. Add JWT guard to protected endpoints +// 4. Add user context to requests +``` + +### Adding Level Submission Endpoint + +```typescript +// POST /api/v1/wechat-game/levels/:id/submit +@Post('levels/:id/submit') +async submitAnswer( + @Param('id') id: string, + @Body() dto: SubmitAnswerDto, +): Promise> { + // Validate answer + // Update user progress + // Award points/lives + // Return result +} +``` + +### Adding Caching + +```typescript +// Import cache module +import { CacheModule } from '@nestjs/cache-manager'; + +// In WechatGameModule +@Module({ + imports: [ + CacheModule.register({ + ttl: 3600, // 1 hour + }), + TypeOrmModule.forFeature([GameConfig, Level]), + ], +}) + +// In service +constructor( + private cacheManager: Cache, + private levelRepository: LevelRepository, +) {} + +async getAllLevels() { + const cached = await this.cacheManager.get('all_levels'); + if (cached) return cached; + + const levels = await this.levelRepository.findAllOrdered(); + await this.cacheManager.set('all_levels', levels, 3600); + return levels; +} +``` + +--- + +## 24. Quick Reference + +### Starting the Server +```bash +npm install # Install dependencies +npm run start:dev # Development with watch mode +npm run start:prod # Production mode +npm run build # Build TypeScript +npm test # Run tests +npm run test:e2e # End-to-end tests +``` + +### Project Commands +```bash +npm run format # Format code with Prettier +npm run lint # Lint and fix with ESLint +npm run test:watch # Watch tests +npm run test:cov # Test coverage +``` + +### Environment Setup +```bash +# .env file +NODE_ENV=development +PORT=3000 +DB_HOST=localhost +DB_PORT=3306 +DB_USERNAME=meme_user +DB_PASSWORD=password +DB_DATABASE=meme_mind +``` + +### Swagger UI +- **Location**: `http://localhost:3000/api/docs` +- **API Endpoint**: `http://localhost:3000/api/docs-json` +- Auto-updated from decorators + +### Database Connection +``` +Host: localhost +Port: 3306 +Username: meme_user +Database: meme_mind +``` + +--- + +## Summary + +MemeMind-Server is a well-structured NestJS backend with: +- ✅ Clean layered architecture (Controller → Service → Repository) +- ✅ Type-safe with full TypeScript +- ✅ Comprehensive error handling and validation +- ✅ Auto-generated Swagger documentation +- ✅ MySQL persistence with TypeORM +- ✅ Environment-based configuration +- ✅ Ready for feature expansion (auth, scoring, leaderboards) + +The server currently provides **read-only access** to game levels and configurations, with endpoints designed to support the WeChat mini-game client's level loading and configuration management needs. + +--- + +*Generated: 2026-04-05 | Last Updated: 2026-04-05* diff --git a/migrations/20260405_add_auth_tables.sql b/migrations/20260405_add_auth_tables.sql new file mode 100644 index 0000000..41ce50a --- /dev/null +++ b/migrations/20260405_add_auth_tables.sql @@ -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; diff --git a/package.json b/package.json index 01d54fa..600a8ad 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87d070f..343f66a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/app.module.ts b/src/app.module.ts index f3c7891..cbc6129 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 {} diff --git a/src/common/decorators/current-user.decorator.ts b/src/common/decorators/current-user.decorator.ts new file mode 100644 index 0000000..dffda34 --- /dev/null +++ b/src/common/decorators/current-user.decorator.ts @@ -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; + }, +); diff --git a/src/common/guards/jwt-auth.guard.ts b/src/common/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..b1e6a27 --- /dev/null +++ b/src/common/guards/jwt-auth.guard.ts @@ -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 { + const request = context.switchToHttp().getRequest(); + const token = this.extractToken(request); + + if (!token) { + throw new UnauthorizedException('未提供访问令牌'); + } + + try { + const payload = await this.jwtService.verifyAsync(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; + } +} diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index 6485c9d..ec506e6 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -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( diff --git a/src/main.ts b/src/main.ts index 53fc62d..f03cdee 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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); diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..1a9bd08 --- /dev/null +++ b/src/modules/auth/auth.controller.ts @@ -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> { + 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> { + 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> { + 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> { + 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> { + const data = await this.authService.getGameData(user.sub); + return ApiResponseDto.success(data); + } +} diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..088b5e7 --- /dev/null +++ b/src/modules/auth/auth.module.ts @@ -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('JWT_SECRET'), + signOptions: { expiresIn: '7d' }, + }), + }), + ], + controllers: [AuthController], + providers: [AuthService, UserRepository, UserLevelProgressRepository], + exports: [JwtModule, AuthService], +}) +export class AuthModule {} diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..80bcb37 --- /dev/null +++ b/src/modules/auth/auth.service.ts @@ -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('WX_APPID', ''); + this.wxSecret = this.configService.get('WX_SECRET', ''); + } + + /** + * 微信登录:code 换取 openid,创建或查找用户,签发 JWT + */ + async wxLogin(code: string): Promise { + // 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 { + const user = await this.findUserOrThrow(userId); + return { points: user.points }; + } + + /** + * 消耗积分(解锁提示) + */ + async consumePoint( + userId: string, + dto: ConsumePointRequestDto, + ): Promise { + 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 { + 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 { + 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 { + 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(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; + } +} diff --git a/src/modules/auth/dto/user-assets.dto.ts b/src/modules/auth/dto/user-assets.dto.ts new file mode 100644 index 0000000..07e591d --- /dev/null +++ b/src/modules/auth/dto/user-assets.dto.ts @@ -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[]; +} diff --git a/src/modules/auth/dto/wx-login.dto.ts b/src/modules/auth/dto/wx-login.dto.ts new file mode 100644 index 0000000..4d954d1 --- /dev/null +++ b/src/modules/auth/dto/wx-login.dto.ts @@ -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; +} diff --git a/src/modules/auth/entities/user-level-progress.entity.ts b/src/modules/auth/entities/user-level-progress.entity.ts new file mode 100644 index 0000000..852910b --- /dev/null +++ b/src/modules/auth/entities/user-level-progress.entity.ts @@ -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; +} diff --git a/src/modules/auth/entities/user.entity.ts b/src/modules/auth/entities/user.entity.ts new file mode 100644 index 0000000..3afbbfd --- /dev/null +++ b/src/modules/auth/entities/user.entity.ts @@ -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; +} diff --git a/src/modules/auth/repositories/user-level-progress.repository.interface.ts b/src/modules/auth/repositories/user-level-progress.repository.interface.ts new file mode 100644 index 0000000..d50e162 --- /dev/null +++ b/src/modules/auth/repositories/user-level-progress.repository.interface.ts @@ -0,0 +1,11 @@ +import { UserLevelProgress } from '../entities/user-level-progress.entity'; + +export interface IUserLevelProgressRepository { + findByUserId(userId: string): Promise; + findByUserAndLevel( + userId: string, + levelId: string, + ): Promise; + create(data: Partial): UserLevelProgress; + save(progress: UserLevelProgress): Promise; +} diff --git a/src/modules/auth/repositories/user-level-progress.repository.ts b/src/modules/auth/repositories/user-level-progress.repository.ts new file mode 100644 index 0000000..42c2a07 --- /dev/null +++ b/src/modules/auth/repositories/user-level-progress.repository.ts @@ -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, + ) {} + + async findByUserId(userId: string): Promise { + return this.repository.find({ where: { userId } }); + } + + async findByUserAndLevel( + userId: string, + levelId: string, + ): Promise { + return this.repository.findOne({ where: { userId, levelId } }); + } + + create(data: Partial): UserLevelProgress { + return this.repository.create(data); + } + + async save(progress: UserLevelProgress): Promise { + return this.repository.save(progress); + } +} diff --git a/src/modules/auth/repositories/user.repository.interface.ts b/src/modules/auth/repositories/user.repository.interface.ts new file mode 100644 index 0000000..49203b2 --- /dev/null +++ b/src/modules/auth/repositories/user.repository.interface.ts @@ -0,0 +1,8 @@ +import { User } from '../entities/user.entity'; + +export interface IUserRepository { + findById(id: string): Promise; + findByOpenid(openid: string): Promise; + create(data: Partial): User; + save(user: User): Promise; +} diff --git a/src/modules/auth/repositories/user.repository.ts b/src/modules/auth/repositories/user.repository.ts new file mode 100644 index 0000000..cd33326 --- /dev/null +++ b/src/modules/auth/repositories/user.repository.ts @@ -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, + ) {} + + async findById(id: string): Promise { + return this.repository.findOne({ where: { id } }); + } + + async findByOpenid(openid: string): Promise { + return this.repository.findOne({ where: { openid } }); + } + + create(data: Partial): User { + return this.repository.create(data); + } + + async save(user: User): Promise { + return this.repository.save(user); + } +}