diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..9fa29bb --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,6 @@ +{ + "enabledPlugins": { + "code-review@claude-plugins-official": true, + "claude-md-management@claude-plugins-official": true + } +} diff --git a/ARCHITECTURE_DIAGRAMS.md b/ARCHITECTURE_DIAGRAMS.md deleted file mode 100644 index 9cacb50..0000000 --- a/ARCHITECTURE_DIAGRAMS.md +++ /dev/null @@ -1,706 +0,0 @@ -# MemeMind-Server Architecture Diagrams & Flows - -## 1. System Architecture Overview - -``` -┌───────────────────────────────────────────────────────────────┐ -│ WeChat Mini-Game Client │ -│ (Cocos Creator 3.8.8) │ -│ │ -│ • PageLoading.ts (startup) │ -│ • LevelDataManager.ts (API calls) │ -│ • PageLevel.ts (gameplay) │ -│ • StorageManager.ts (localStorage) │ -└────────────────────┬────────────────────────────────────────┘ - │ - │ HTTP Requests - │ GET /api/v1/wechat-game/levels - │ GET /api/v1/wechat-game/configs - │ -┌────────────────────▼────────────────────────────────────────┐ -│ MemeMind-Server (NestJS) │ -│ http://ilookai.cn:3000/api │ -│ │ -│ ┌──────────────────────────────────────────────────────┐ │ -│ │ HTTP Layer │ │ -│ │ • GlobalPrefix: /api │ │ -│ │ • CORS: Enabled │ │ -│ │ • ValidationPipe: Global validation │ │ -│ │ • Swagger: /api/docs │ │ -│ └──────────────────────────────────────────────────────┘ │ -│ │ │ -│ ┌──────────────────────▼──────────────────────────────┐ │ -│ │ WechatGameController │ │ -│ │ POST /v1/wechat-game/configs │ │ -│ │ GET /v1/wechat-game/configs/:key │ │ -│ │ GET /v1/wechat-game/levels │ │ -│ │ GET /v1/wechat-game/levels/:id │ │ -│ └──────────────────────┬──────────────────────────────┘ │ -│ │ │ -│ ┌──────────────────────▼──────────────────────────────┐ │ -│ │ WechatGameService │ │ -│ │ • getAllConfigs() │ │ -│ │ • getConfigByKey(key) │ │ -│ │ • getAllLevels() │ │ -│ │ • getLevelById(id) │ │ -│ │ • toResponseDto() │ │ -│ │ • toLevelResponseDto() │ │ -│ └──────────────────────┬──────────────────────────────┘ │ -│ │ │ -│ ┌──────────────────────▼──────────────────────────────┐ │ -│ │ Repository Layer │ │ -│ │ ├─ LevelRepository │ │ -│ │ └─ GameConfigRepository │ │ -│ └──────────────────────┬──────────────────────────────┘ │ -│ │ │ -│ ┌──────────────────────▼──────────────────────────────┐ │ -│ │ TypeORM / MySQL │ │ -│ │ ├─ levels table │ │ -│ │ └─ game_configs table │ │ -│ └──────────────────────────────────────────────────────┘ │ -│ │ -└───────────────────────────────────────────────────────────────┘ - │ - │ Response (JSON) - │ {success, data, message, timestamp} - │ -┌────────────────────▼────────────────────────────────────────┐ -│ WeChat Mini-Game Client │ -│ • LevelDataManager stores in _apiData │ -│ • PageLevel reads _apiData │ -│ • Images preloaded via assetManager.loadRemote() │ -└───────────────────────────────────────────────────────────────┘ -``` - ---- - -## 2. Request-Response Flow - -### Scenario 1: Get All Levels - -``` -Client Server - │ │ - ├─ GET /api/v1/wechat-game/levels - │─────────────────────────────>│ - │ │ - │ [Controller] - │ getAllLevels() - │ │ - │ [Service] - │ levelRepository.findAllOrdered() - │ │ - │ [Repository] - │ SELECT * FROM levels ORDER BY sort_order - │ │ - │ [MySQL] - │ Returns Level[] - │ │ - │ [Service] - │ Map to LevelResponseDto - │ Add level numbers - │ │ - │ [Filter] - │ Wrap in ApiResponseDto.success() - │ │ - │ <─────────────────────────────│ - │ { │ - │ "success": true, │ - │ "data": { │ - │ "levels": [...], │ - │ "total": 50 │ - │ }, │ - │ "message": null, │ - │ "timestamp": "2026-04-05..." │ - │ } │ - │ │ - ├─ Store in _apiData - ├─ Preload images - └─ Ready for gameplay -``` - -### Scenario 2: Get Config by Key - -``` -Client Server - │ │ - ├─ GET /api/v1/wechat-game/configs/HINT_COST - │─────────────────────────────>│ - │ │ - │ [Controller] - │ getConfigByKey("HINT_COST") - │ │ - │ [Service] - │ gameConfigRepository.findByKey() - │ │ - │ [Repository] - │ SELECT * FROM game_configs - │ WHERE config_key = 'HINT_COST' - │ │ - │ [MySQL] - │ Returns GameConfig or null - │ │ - │ ┌─────────┴─────────┐ - │ │ │ - │ FOUND NOT FOUND - │ │ │ - │ [Service] [Service] - │ Map to DTO throw NotFoundException - │ │ │ - │ [Filter] [Filter] - │ success() catch exception - │ │ │ - │ <─────────────────┤ │ - │ { │ │ - │ "success": true,│ │ - │ "data": {...} │ │ - │ } │ ┌────────────────┘ - │ │ │ - │ │ └─> { - │ │ "success": false, - │ │ "data": null, - │ │ "message": "Game config... not found", - │ │ "path": "/api/v1/..." - │ │ } - │ │ - └────────────────────────────────── -``` - ---- - -## 3. Module Dependency Graph - -``` -┌──────────────────────────────┐ -│ AppModule (root) │ -├──────────────────────────────┤ -│ │ -│ imports: [ │ -│ AppConfigModule, │ -│ TypeOrmModule, │ -│ WechatGameModule │ -│ ] │ -│ │ -└──┬───────────────────────────┘ - │ - ├─────────────────────────────────────┐ - │ │ - │ │ -┌──▼─────────────────┐ ┌──────────────▼──────────────┐ -│ AppConfigModule │ │ WechatGameModule │ -├────────────────────┤ ├─────────────────────────────┤ -│ @Global() │ │ imports: [ │ -│ │ │ TypeOrmModule.forFeature( │ -│ imports: [ │ │ [GameConfig, Level] │ -│ ConfigModule │ │ ) │ -│ ] │ │ ] │ -│ │ │ │ -│ exports: [ │ │ controllers: [ │ -│ ConfigModule │ │ WechatGameController │ -│ ] │ │ ] │ -│ │ │ │ -│ │ │ providers: [ │ -│ │ │ WechatGameService, │ -│ │ │ LevelRepository, │ -│ │ │ GameConfigRepository │ -│ │ │ ] │ -│ │ │ │ -│ │ │ exports: [ │ -│ │ │ WechatGameService │ -│ │ │ ] │ -│ │ │ │ -└────────────────────┘ └─────────────────────────────┘ - │ - │ - ┌──────────▼───────────────┐ - │ TypeOrmModule.forFeature│ - ├───────────────────────────┤ - │ Registers: │ - │ • Level entity │ - │ • GameConfig entity │ - │ • Auto-creates repos │ - └──────────────────────────┘ -``` - ---- - -## 4. Data Model Relationships - -``` -┌────────────────────────────────┐ -│ levels │ -├────────────────────────────────┤ -│ PK: id (VARCHAR 191) │ -├────────────────────────────────┤ -│ id (PK) │ -│ image_url (VARCHAR) │ -│ answer (VARCHAR) │ -│ hint1 (VARCHAR) │ -│ hint2 (VARCHAR) │ -│ hint3 (VARCHAR) │ -│ sort_order (INT) │ -│ created_at (DATETIME)│ -│ updated_at (DATETIME)│ -├────────────────────────────────┤ -│ Indexes: │ -│ • PK: id │ -│ • idx_sort_order: sort_order │ -├────────────────────────────────┤ -│ Used by: │ -│ • LevelRepository │ -│ • WechatGameService │ -└────────────────────────────────┘ - -┌────────────────────────────────┐ -│ game_configs │ -├────────────────────────────────┤ -│ PK: id (UUID) │ -├────────────────────────────────┤ -│ id (PK) │ -│ config_key (VARCHAR) │ -│ config_value (TEXT) │ -│ description (VARCHAR) │ -│ is_active (BOOLEAN) │ -│ created_at (DATETIME)│ -│ updated_at (DATETIME)│ -├────────────────────────────────┤ -│ Indexes: │ -│ • PK: id │ -│ • UNIQUE: config_key │ -│ • idx_active: is_active │ -├────────────────────────────────┤ -│ Used by: │ -│ • GameConfigRepository │ -│ • WechatGameService │ -└────────────────────────────────┘ -``` - ---- - -## 5. Service Method Call Chain - -### GET /api/v1/wechat-game/levels - -``` -Controller.getAllLevels() - │ - ├─> service.getAllLevels() - │ │ - │ ├─> levelRepository.findAllOrdered() - │ │ │ - │ │ └─> repository.find({ order: { sortOrder: 'ASC' } }) - │ │ │ - │ │ └─> [SELECT * FROM levels ORDER BY sort_order ASC] - │ │ - │ ├─> LOOP levels array: - │ │ ├─> toLevelResponseDto(level, index + 1) - │ │ │ │ - │ │ │ └─> { - │ │ │ level: 1 (or 2, 3, ...) - │ │ │ id: level.id - │ │ │ imageUrl: level.imageUrl - │ │ │ answer: level.answer - │ │ │ hint1: level.hint1 - │ │ │ hint2: level.hint2 - │ │ │ hint3: level.hint3 - │ │ │ sortOrder: level.sortOrder - │ │ │ createdAt: level.createdAt - │ │ │ updatedAt: level.updatedAt - │ │ │ } - │ │ - │ └─> return { - │ levels: [LevelResponseDto[], ...] - │ total: count - │ } - │ - └─> ApiResponseDto.success(data) - │ - └─> { - success: true - data: { levels, total } - message: null - timestamp: new Date() - } -``` - ---- - -## 6. Error Handling Flow - -``` -┌─────────────────┐ -│ HTTP Request │ -└────────┬────────┘ - │ - ┌────▼─────────────┐ - │ ValidationPipe │ - │ (Global) │ - └────┬─────────────┘ - │ - ┌───┴────────────────────────┐ - │ │ - ▼ Valid ▼ Invalid - Continue ValidationException - │ - [ExceptionFilter] - catches @Catch() - │ - ApiResponseDto.error() - │ - {success: false, - message: "...", - path: "..."} - │ - ▼ Valid data - [Controller] - [Service] - [Repository] - [Database] - │ - ├─ Success ──┐ - │ │ - │ ▼ - │ [Service] returns data - │ │ - │ [Controller] - │ │ - │ ApiResponseDto.success(data) - │ │ - │ Return to client - │ - └─ Exception ──┐ - │ - ▼ - NotFoundException - BadRequestException - (or any HttpException) - │ - ▼ - [HttpExceptionFilter] - @Catch() catches exception - │ - ▼ - Extract status & message - │ - ▼ - ApiResponseDto.error(message) - │ - ▼ - response.status(code).json(errorDto) - │ - ▼ - Return to client with error -``` - ---- - -## 7. Request Validation Pipeline - -``` -HTTP Request - │ - ├─ Path params extracted - │ @Param('id') id: string - │ @Param('key') key: string - │ - ├─ Query params extracted - │ @Query() dto: QueryDto - │ - ├─ Body params extracted (for POST/PUT) - │ @Body() dto: CreateDto - │ - ├─ Global ValidationPipe processes: - │ │ - │ ├─ whitelist: true - │ │ └─ Remove unknown properties - │ │ - │ ├─ forbidNonWhitelisted: true - │ │ └─ Throw if unknown properties found - │ │ - │ └─ transform: true - │ └─ Transform strings to appropriate types - │ (e.g., "123" → 123) - │ - ├─ class-validator decorators checked - │ @IsString() - │ @IsNumber() - │ @IsEmail() - │ etc. - │ - ├─ If validation fails - │ └─> BadRequestException - │ └─> ExceptionFilter catches - │ └─> 400 status + error message - │ - └─ If validation passes - └─> Continue to controller -``` - ---- - -## 8. Data Transformation Chain - -``` -HTTP Request JSON - │ - ├─ Parse JSON - ├─ Extract into DTO object - │ { - │ "@Type(() => Number)" hint: "5" ──> 5 (number) - │ "@Transform()" name: "JOHN" ──> "john" (lowercased) - │ } - │ - ├─ Validate against DTO decorators - │ @IsNotEmpty() - │ @IsNumber() - │ @Min(0) - │ @Max(100) - │ - ├─ Pass to Service - │ - ├─ Service transforms to Entity - │ DTO ──> Entity - │ {id, name} {id, name, timestamp} - │ - ├─ Database operations - │ Entity ──> SQL - │ TypeORM handles serialization - │ - ├─ Result from Database - │ Entity[] ──> Entity[] - │ - ├─ Service transforms Entity to ResponseDto - │ Entity ──> ResponseDto - │ Remove sensitive fields - │ Add computed fields - │ - ├─ Wrap in ApiResponseDto - │ - └─ Send as JSON Response - { - success: true, - data: [...ResponseDtos...], - message: null, - timestamp: "..." - } -``` - ---- - -## 9. Database Connection Lifecycle - -``` -[Application Start] - │ - ▼ -[config.module.ts loads] - • validateEnvironment() - • Reads .env, .env.local, .env.production - │ - ▼ -[app.module.ts initializes] - • TypeOrmModule.forRootAsync() - • Uses ConfigService to get DB params - │ - ├─ DB_HOST: localhost - ├─ DB_PORT: 3306 - ├─ DB_USERNAME: meme_user - ├─ DB_PASSWORD: (from env) - ├─ DB_DATABASE: meme_mind - │ - ▼ -[TypeORM connects to MySQL] - mysql2 driver establishes connection - │ - ├─ If NODE_ENV === 'development' - │ └─ synchronize: true - │ └─ Auto-create/update tables - │ - ├─ If NODE_ENV === 'production' - │ └─ synchronize: false - │ └─ Use migrations instead - │ - ▼ -[Repositories instantiated] - @InjectRepository(Level) - @InjectRepository(GameConfig) - │ - ▼ -[Ready to accept requests] - • findAllOrdered() ──> SELECT ... - • findByKey() ──> SELECT ... - │ - ▼ -[Application shutdown] - TypeORM closes connection -``` - ---- - -## 10. Environment to Runtime Configuration - -``` -.env.local / .env file - │ - ├─ NODE_ENV=development - ├─ PORT=3000 - ├─ DB_HOST=localhost - ├─ DB_PORT=3306 - ├─ DB_USERNAME=meme_user - ├─ DB_PASSWORD=secret - ├─ DB_DATABASE=meme_mind - │ - ▼ -env.validation.ts - • plainToInstance(EnvironmentVariables, config) - • validateSync() - • Throws if validation fails - │ - ▼ -config.module.ts - • ConfigModule.forRoot() - • isGlobal: true ──> Available everywhere - • validate: validateEnvironment - │ - ▼ -database.config.ts - registerAs('database', () => ({ - type: 'mysql', - host: configService.get('DB_HOST'), - port: configService.get('DB_PORT'), - username: configService.get('DB_USERNAME'), - password: configService.get('DB_PASSWORD'), - database: configService.get('DB_DATABASE'), - ... - })) - │ - ▼ -app.module.ts - TypeOrmModule.forRootAsync({ - useFactory: (configService) => ({ - ...configService.get('database') - }) - }) - │ - ▼ -main.ts - port = process.env.PORT ?? 3000 - app.listen(port) - │ - ▼ -Application Running - • Connected to MySQL - • Listening on port - • Ready for requests -``` - ---- - -## 11. API Response Mapping Example - -### Request: -``` -GET /api/v1/wechat-game/levels -``` - -### Database Results: -```sql -SELECT * FROM levels ORDER BY sort_order ASC LIMIT 2; - -Results: -┌─────────────┬──────────────────────┬────────┬───────┬───────┬───────┬────────────┬─────────────┬─────────────┐ -│ id │ image_url │ answer │ hint1 │ hint2 │ hint3 │ sort_order │ created_at │ updated_at │ -├─────────────┼──────────────────────┼────────┼───────┼───────┼───────┼────────────┼─────────────┼─────────────┤ -│ level-001 │ http://...img1.jpg │ meme │ image │ funny │ null │ 0 │ 2026-04-01 │ 2026-04-05 │ -│ level-002 │ http://...img2.jpg │ code │ tech │ null │ null │ 1 │ 2026-04-02 │ 2026-04-05 │ -└─────────────┴──────────────────────┴────────┴───────┴───────┴───────┴────────────┴─────────────┴─────────────┘ -``` - -### Service Transformation: -```javascript -levels.map((level, index) => toLevelResponseDto(level, index + 1)) - -Result: -[ - { - level: 1, // Computed: index + 1 - id: "level-001", - imageUrl: "http://...img1.jpg", - answer: "meme", - hint1: "image", - hint2: "funny", - hint3: null, - sortOrder: 0, - createdAt: "2026-04-01T...", - updatedAt: "2026-04-05T..." - }, - { - level: 2, - id: "level-002", - imageUrl: "http://...img2.jpg", - answer: "code", - hint1: "tech", - hint2: null, - hint3: null, - sortOrder: 1, - createdAt: "2026-04-02T...", - updatedAt: "2026-04-05T..." - } -] -``` - -### Final HTTP Response: -```json -{ - "success": true, - "data": { - "levels": [ - { - "level": 1, - "id": "level-001", - "imageUrl": "http://...img1.jpg", - "answer": "meme", - "hint1": "image", - "hint2": "funny", - "hint3": null, - "sortOrder": 0, - "createdAt": "2026-04-01T00:00:00.000Z", - "updatedAt": "2026-04-05T12:00:00.000Z" - }, - { - "level": 2, - "id": "level-002", - "imageUrl": "http://...img2.jpg", - "answer": "code", - "hint1": "tech", - "hint2": null, - "hint3": null, - "sortOrder": 1, - "createdAt": "2026-04-02T00:00:00.000Z", - "updatedAt": "2026-04-05T12:00:00.000Z" - } - ], - "total": 2 - }, - "message": null, - "timestamp": "2026-04-05T12:34:56.789Z" -} -``` - ---- - -## Summary of Diagrams - -1. **System Architecture**: High-level components (Client, Server, Database) -2. **Request-Response Flow**: Detailed flow for GET and error scenarios -3. **Module Dependency Graph**: How modules depend on each other -4. **Data Model Relationships**: Database table structures -5. **Service Method Call Chain**: Stack of calls from Controller to DB -6. **Error Handling Flow**: Exception catching and wrapping -7. **Request Validation Pipeline**: Validation process -8. **Data Transformation Chain**: DTO → Entity → DB → Entity → ResponseDto -9. **Database Connection Lifecycle**: Connection initialization -10. **Environment to Runtime**: How .env becomes runtime config -11. **API Response Mapping**: Real example of transformation - -*Generated: 2026-04-05* diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7146be4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,266 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**MemeMind-Server** is a NestJS backend for a WeChat mini-game called MemeMind. The server handles user authentication via WeChat login, manages game levels and progress, supports social sharing/challenges, and maintains user profiles. All API responses use a standardized format and are exposed under `/api` with Swagger docs at `/api/docs`. + +## Tech Stack + +- **Framework**: NestJS 11 with TypeScript 5.7 +- **Database**: MySQL (via TypeORM) +- **Authentication**: JWT (7-day expiration) +- **Package Manager**: pnpm (with pnpm-lock.yaml) +- **Testing**: Jest (unit tests alongside source, e2e tests in `test/`) +- **Code Quality**: ESLint + Prettier (single quotes, trailing commas) +- **Deployment**: PM2 with rsync to remote server + +## Project Structure + +``` +src/ +├── main.ts # App bootstrap (CORS, validation, Swagger) +├── app.module.ts # Root module importing all features +├── config/ +│ ├── config.module.ts # Global config provider (env validation) +│ ├── env.validation.ts # Environment variable schema validation +│ └── database.config.ts # TypeORM configuration +├── database/ +│ └── migrations/ # TypeORM migrations (not heavily used yet) +├── common/ +│ ├── dto/api-response.dto.ts # Unified response wrapper (success/error) +│ ├── filters/http-exception.filter.ts # Global error handling +│ ├── guards/jwt-auth.guard.ts # JWT verification & payload extraction +│ └── decorators/current-user.decorator.ts # Param decorator for @CurrentUser() +└── modules/ + ├── auth/ # WeChat login, user creation, JWT issuance + ├── user/ # User profile and stamina management + ├── level/ # Game level progression tracking + ├── share/ # Social challenge/share features + ├── game-config/ # Game configuration endpoints + └── wechat-game/ # Shared game entities and repositories + +docs/ +├── api/ # API documentation (Markdown, auto-synced with code) +├── api/README.md # Index of all API modules +└── superpowers/ # Legacy or undocumented features +``` + +## Key Commands + +### Development + +```bash +pnpm install # Install dependencies +pnpm run start:dev # Run with file watching (http://localhost:3000/api) +pnpm run start:debug # Debug mode with Node inspector +pnpm run build # Compile TypeScript to dist/ +pnpm run start:prod # Run compiled production build +``` + +### Code Quality + +```bash +pnpm run lint # ESLint with auto-fix +pnpm run format # Prettier format (src/ and test/) +``` + +### Testing + +```bash +pnpm run test # All unit tests (*.spec.ts in src/) +pnpm run test:watch # Watch mode +pnpm run test:cov # Coverage report (outputs to coverage/) +pnpm run test:debug # Debug unit tests +pnpm run test:e2e # E2E tests (test/*.e2e-spec.ts) +``` + +### Deployment + +```bash +pnpm run deploy # Build, rsync to server, restart PM2 cluster +``` + +## Architecture Patterns + +### Module Structure + +Each feature module follows NestJS conventions: + +```typescript +// module.ts: Declares imports (other modules, TypeORM entities, services) +@Module({ + imports: [TypeOrmModule.forFeature([Entity]), AuthModule], + controllers: [FeatureController], + providers: [FeatureService, CustomRepository], + exports: [FeatureService], // For cross-module injection +}) +export class FeatureModule {} + +// controller.ts: HTTP routing, DTO validation, Swagger decorators +@Controller('v1/feature') +export class FeatureController { + @Post() async create(@Body() dto: CreateDto): Promise> + @UseGuards(JwtAuthGuard) // Applied per-endpoint + @Get(':id') async getOne(@Param('id') id: string, @CurrentUser() user: JwtPayload) +} + +// service.ts: Business logic, repository calls, external API calls +@Injectable() +export class FeatureService { + constructor(private readonly repo: CustomRepository) {} + async create(data: CreateDto): Promise { ... } +} + +// repositories/: Custom data access logic (extend TypeORM repositories) +@Injectable() +export class CustomRepository extends Repository { + async customQuery(): Promise { ... } +} + +// dto/: Request/response schemas with class-validator decorators +export class CreateDto { + @IsString() @MinLength(1) name: string; +} +``` + +### Authentication & Authorization + +- **No global guard**: JWT validation is per-endpoint via `@UseGuards(JwtAuthGuard)` +- **User extraction**: Use `@CurrentUser()` to inject `JwtPayload { sub: userId, openid }` +- **WeChat integration**: `AuthService.wxLogin(code)` calls WeChat API, creates user on first login, issues JWT + +### Request/Response Contract + +All endpoints return `ApiResponseDto`: + +```typescript +{ + success: boolean, // true on success, false on error + data: T | null, // Response payload or null + message: string | null, // Error message (null on success) + timestamp: Date // ISO 8601 timestamp +} +``` + +Errors are caught by `HttpExceptionFilter`, which formats exceptions as failed responses. + +### Database + +- **ORM**: TypeORM with MySQL +- **Entities**: Auto-loaded from `**/*.entity.ts`, sync mode in dev +- **Migrations**: Located in `src/database/migrations/` (minimal usage) +- **Connection**: Configured via `ConfigService` (env vars: DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_DATABASE) + +## Configuration + +Environment variables are validated via `env.validation.ts` using class-validator: + +``` +NODE_ENV # development|production|test (default: development) +PORT # Server port (default: 3000) +DB_HOST # MySQL host (default: localhost) +DB_PORT # MySQL port (default: 3306) +DB_USERNAME # MySQL user (default: meme_user) +DB_PASSWORD # MySQL password (default: '') +DB_DATABASE # MySQL database (default: meme_mind) +WX_APPID # WeChat mini-game app ID +WX_SECRET # WeChat mini-game secret +JWT_SECRET # JWT signing secret +``` + +Config files loaded in order: `.env.local` → `.env` → `.env.production`. Use `.env.local` for local overrides (never committed). + +## Testing + +- **Unit tests**: Colocated with source as `*.spec.ts` (e.g., `auth.service.spec.ts`) +- **E2E tests**: In `test/` directory (e.g., `test/app.e2e-spec.ts`) +- **Framework**: Jest with ts-jest compiler +- **Coverage**: Run `pnpm run test:cov` to generate coverage report in `coverage/` +- **Individual tests**: `pnpm run test -- --testNamePattern="test name"` or `pnpm run test:debug` + +## API Documentation + +API docs are maintained in `docs/api/` and **must be updated whenever controller/DTO changes occur**. The `api-doc-maintainer` skill automates this when editing `src/modules/*/`. + +**File mapping**: +- `docs/api/auth-api.md` → `src/modules/auth/` +- `docs/api/game-api.md` → `src/modules/wechat-game/` & `src/modules/level/` +- `docs/api/share-challenge-api.md` → `src/modules/share/` + +**When to update docs**: +- New endpoint added +- Request/response DTO fields changed +- Error codes or business logic modified +- Authentication requirements changed + +Use the template in `AGENTS.md` (api-doc-maintainer skill) for consistent formatting. + +## Deployment + +**Local to production**: + +1. Ensure `.env.production` has correct credentials (WX_APPID, WX_SECRET, JWT_SECRET, DB credentials) +2. Run `pnpm run deploy` (triggers `deploy.sh`) +3. Script: builds locally → rsyncs `dist/`, `package.json`, `pnpm-lock.yaml` to `/var/www/MemeMind-Server/` on server → installs deps → restarts PM2 cluster (2 instances) + +**PM2 config** (`ecosystem.config.js`): Cluster mode (2 instances), auto-restart, 1GB memory limit, logs to `logs/*.log` + +**Important**: Before deploying, verify deploy.sh credentials (SERVER_IP, SERVER_USER, REMOTE_DIR, APP_NAME) match your target environment. + +## Code Style & Conventions + +- **TypeScript**: Strict mode enabled, no implicit `any`, explicit types on public APIs +- **Naming**: Classes PascalCase, methods/variables camelCase, directories kebab-case +- **DTO files**: Semantic names (e.g., `wx-login.dto.ts`, `share-response.dto.ts`) +- **Quotes**: Single quotes (enforced by Prettier) +- **Indentation**: 2 spaces +- **Commits**: Use Conventional Commits (e.g., `feat(auth):`, `fix(level):`, `docs(api):`) + +## Key Integration Points + +### Auth Module + +- **Controller**: `POST /api/v1/auth/wx-login` +- **Flow**: WeChat code → `AuthService.wxLogin()` → calls WeChat API (`jscode2session`) → creates/updates user → signs JWT +- **Exports**: `AuthService`, `UserRepository`, `UserLevelProgressRepository` (used by other modules) + +### User Module + +- **Depends on**: AuthModule (for JWT guard, UserRepository) +- **Usage**: Profile endpoints, stamina management + +### Level Module + +- **Depends on**: AuthModule, UserModule, WechatGameModule +- **Entities**: Reused from WechatGameModule (Level, UserLevelProgress) + +### Share Module + +- **Entities**: ShareConfig, ShareParticipant, ShareLevelProgress (independent tables) +- **Depends on**: WechatGameModule, AuthModule +- **Pattern**: Multi-table repository pattern for complex queries + +## Common Development Tasks + +- **Add a new API endpoint**: Create DTO in `modules/{feature}/dto/`, add method to controller, implement in service, call `pnpm run lint && pnpm run test` to verify +- **Add a new entity**: Create `*.entity.ts` in module folder, add to TypeOrmModule.forFeature in module, update relevant repository +- **Modify API response format**: Update DTO/entity, regenerate Swagger docs, update `docs/api/*.md` manually if not auto-synced +- **Debug a failing test**: Run `pnpm run test:debug -- --testNamePattern="specific test"` and use Node inspector +- **Run migrations**: TypeORM in-dev synchronize mode auto-creates tables; for production, use TypeORM CLI or manual SQL + +## Troubleshooting + +- **Port 3000 in use**: `lsof -i :3000` and `kill -9 `, or change PORT env var +- **Database connection fails**: Verify DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD in `.env.local` match your MySQL setup +- **JWT verification fails**: Check JWT_SECRET is consistent across app instances and `.env` files +- **Swagger not loading**: Ensure app.listen() completes; check browser console for CORS errors +- **Tests hanging**: Check for open database connections; run `pnpm run test:debug` to inspect +- **Prettier/ESLint conflicts**: Run `pnpm run format && pnpm run lint` in sequence (format first, then lint) + +## Additional Resources + +- **NestJS docs**: https://docs.nestjs.com +- **TypeORM docs**: https://typeorm.io +- **Project-specific guidance**: See `AGENTS.md` for multi-agent collaboration patterns diff --git a/CLIENT_SERVER_INTEGRATION.md b/CLIENT_SERVER_INTEGRATION.md deleted file mode 100644 index c5a415f..0000000 --- a/CLIENT_SERVER_INTEGRATION.md +++ /dev/null @@ -1,836 +0,0 @@ -# MemeMind Client-Server Integration Guide - -## Overview - -This document explains how the Cocos Creator client communicates with the MemeMind-Server backend and what extensions would be needed to support the full game flow including user authentication, progress tracking, and point/life management. - ---- - -## Part 1: Current Integration (Read-Only) - -### Current API Call: Get All Levels - -**Client File**: `/Users/richard/Documents/code/cocosProject/mp-xieyingeng/assets/scripts/managers/LevelDataManager.ts` - -**Current Implementation**: -```typescript -async initialize(): Promise { - 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 deleted file mode 100644 index 6ce22f5..0000000 --- a/SERVER_ANALYSIS.md +++ /dev/null @@ -1,1191 +0,0 @@ -# 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/docs/api-changelog-v1.1.0.md b/docs/api-changelog-v1.1.0.md new file mode 100644 index 0000000..c38f46d --- /dev/null +++ b/docs/api-changelog-v1.1.0.md @@ -0,0 +1,291 @@ +# MemeMind API 变更文档 — 双图关卡 & 体力上限调整 + +> **版本**:v1.1.0 +> **日期**:2026-04-19 +> **影响范围**:关卡列表、进入关卡、分享挑战、体力系统 +> **兼容性**:⚠️ Breaking Change — 客户端必须适配后方可上线 + +--- + +## 一、变更概览 + +| 变更项 | 旧值 | 新值 | +|--------|------|------| +| 关卡图片数量 | 1 张(`imageUrl`) | 2 张(`image1Url` + `image2Url`) | +| 图片文本说明 | 无 | 每张图片各有一个 `description` 字段 | +| 谐音梗说明 | 无 | 新增 `punchline` 字段 | +| 体力上限 | 5 | **50** | +| 新用户默认体力 | 5 | **50** | +| 体力恢复速率 | 每 10 分钟 1 点 | **不变** | + +--- + +## 二、体力系统变更 + +### StaminaInfo 结构(不变,数值范围扩大) + +```typescript +interface StaminaInfo { + current: number; // 0 ~ 50(原 0 ~ 5) + max: number; // 固定 50(原 5) + nextRecoverAt: string | null; +} +``` + +**客户端注意**: +- 体力 UI 需要适配 0–50 的显示范围 +- 恢复速率不变,满体力恢复时间从 50 分钟变为 500 分钟(约 8.3 小时) +- 进入关卡仍消耗 1 点体力,已通关关卡仍免费 + +--- + +## 三、接口字段变更 + +### 3.1 `GET /api/v1/levels` — 获取关卡列表 + +**删除字段**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| ~~`imageUrl`~~ | ~~string~~ | 已删除,替换为下方双图字段 | + +**新增字段**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `image1Url` | string | 图片1 URL | +| `image1Description` | string \| null | 图片1 文本说明 | +| `image2Url` | string | 图片2 URL | +| `image2Description` | string \| null | 图片2 文本说明 | +| `punchline` | string \| null | 谐音梗说明(**仅通关后返回**,未通关为 null) | + +**完整响应示例**: + +```json +{ + "success": true, + "data": { + "levels": [ + { + "id": "level_001", + "level": 1, + "image1Url": "https://cdn.example.com/levels/001_1.png", + "image1Description": "一只猫在看鱼", + "image2Url": "https://cdn.example.com/levels/001_2.png", + "image2Description": "一条鱼在飞", + "answer": "猫和鱼", + "punchline": "谐音梗:鱼跃龙门 → 鱼越猫门", + "hint1": "这是一个经典的...", + "hint2": "和某个动物有关", + "hint3": null, + "completed": true, + "timeSpent": 45 + }, + { + "id": "level_002", + "level": 2, + "image1Url": "https://cdn.example.com/levels/002_1.png", + "image1Description": "一个人在走路", + "image2Url": "https://cdn.example.com/levels/002_2.png", + "image2Description": "一辆车在跑", + "answer": null, + "punchline": null, + "hint1": null, + "hint2": null, + "hint3": null, + "completed": false, + "timeSpent": null + } + ], + "total": 2 + }, + "message": null, + "timestamp": "2026-04-19T12:00:00.000Z" +} +``` + +> **可见性规则**:`answer`、`punchline`、`hint1`、`hint2`、`hint3` 仅在 `completed: true` 时返回,未通关均为 `null`。`image1Url`、`image1Description`、`image2Url`、`image2Description` 始终返回。 + +--- + +### 3.2 `POST /api/v1/levels/{id}/enter` — 进入关卡 + +**删除字段**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| ~~`imageUrl`~~ | ~~string~~ | 已删除 | + +**新增字段**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `image1Url` | string | 图片1 URL | +| `image1Description` | string \| null | 图片1 文本说明 | +| `image2Url` | string | 图片2 URL | +| `image2Description` | string \| null | 图片2 文本说明 | +| `punchline` | string \| null | 谐音梗说明 | + +**完整响应示例**: + +```json +{ + "success": true, + "data": { + "id": "level_002", + "level": 2, + "image1Url": "https://cdn.example.com/levels/002_1.png", + "image1Description": "一个人在走路", + "image2Url": "https://cdn.example.com/levels/002_2.png", + "image2Description": "一辆车在跑", + "answer": "人车赛跑", + "punchline": "谐音梗:车水马龙 → 车水人龙", + "hint1": "第一个线索", + "hint2": "第二个线索", + "hint3": null, + "stamina": { + "current": 47, + "max": 50, + "nextRecoverAt": "2026-04-19T12:10:00.000Z" + } + }, + "message": null, + "timestamp": "2026-04-19T12:00:00.000Z" +} +``` + +> **注意**:进入关卡时 `answer` 和 `punchline` 始终返回(无论是否通关),因为用户已消耗体力进入。 + +--- + +### 3.3 `POST /api/v1/share/{shareCode}/join` — 加入分享挑战 + +分享挑战中的关卡数据同步变更。 + +**删除字段**(`levels[]` 中每个关卡): + +| 字段 | 类型 | 说明 | +|------|------|------| +| ~~`imageUrl`~~ | ~~string~~ | 已删除 | + +**新增字段**(`levels[]` 中每个关卡): + +| 字段 | 类型 | 说明 | +|------|------|------| +| `image1Url` | string | 图片1 URL | +| `image1Description` | string \| null | 图片1 文本说明 | +| `image2Url` | string | 图片2 URL | +| `image2Description` | string \| null | 图片2 文本说明 | +| `punchline` | string \| null | 谐音梗说明 | + +--- + +### 3.4 未变更的接口 + +以下接口**无任何变更**,客户端无需修改: + +| 接口 | 说明 | +|------|------| +| `POST /api/v1/auth/wx-login` | 登录(新用户 stamina 初始为 50,但 login 返回结构不变) | +| `GET /api/v1/user/profile` | 用户资料(stamina.max 变为 50,结构不变) | +| `GET /api/v1/user/game-data` | 游戏数据(stamina.max 变为 50,结构不变) | +| `POST /api/v1/levels/{id}/complete` | 通关上报(结构完全不变) | +| `GET /api/v1/game-configs` | 游戏配置(不变) | +| `GET /api/v1/game-configs/{key}` | 单个配置(不变) | + +--- + +## 四、客户端适配清单 + +### 必须修改 + +- [ ] 所有使用 `imageUrl` 的地方改为 `image1Url` + `image2Url` +- [ ] 关卡详情页展示两张图片,每张图片下方展示 `image1Description` / `image2Description` +- [ ] 通关后展示 `punchline`(谐音梗说明) +- [ ] 体力 UI 适配 0–50 范围(进度条、数字显示等) +- [ ] 更新 TypeScript 接口定义(见下方) + +### 建议修改 + +- [ ] 体力恢复倒计时逻辑无需修改(恢复速率不变) +- [ ] `punchline` 为 `null` 时不展示(未配置谐音梗的关卡) +- [ ] `image2Url` 为空字符串时做兜底处理(历史关卡可能尚未配置第二张图) + +--- + +## 五、客户端 TypeScript 接口定义 + +直接复制替换旧接口: + +```typescript +/** 关卡列表项 */ +interface LevelListItem { + id: string; + level: number; + image1Url: string; + image1Description: string | null; + image2Url: string; + image2Description: string | null; + answer: string | null; + punchline: string | null; + hint1: string | null; + hint2: string | null; + hint3: string | null; + completed: boolean; + timeSpent: number | null; +} + +/** 进入关卡响应 */ +interface EnterLevelResponse { + id: string; + level: number; + image1Url: string; + image1Description: string | null; + image2Url: string; + image2Description: string | null; + answer: string; + punchline: string | null; + hint1: string | null; + hint2: string | null; + hint3: string | null; + stamina: StaminaInfo; +} + +/** 体力信息(结构不变,数值范围 0-50) */ +interface StaminaInfo { + current: number; + max: number; // 50 + nextRecoverAt: string | null; +} + +/** 分享关卡 */ +interface ShareLevel { + id: string; + level: number; + image1Url: string; + image1Description: string | null; + image2Url: string; + image2Description: string | null; + answer: string; + punchline: string | null; + hint1: string | null; + hint2: string | null; + hint3: string | null; + sortOrder: number; +} +``` + +--- + +## 六、字段映射速查表 + +方便全局搜索替换: + +| 旧字段 | 新字段 | 备注 | +|--------|--------|------| +| `imageUrl` | `image1Url` | 原图片字段,直接重命名 | +| — | `image1Description` | 新增,图片1 说明文字 | +| — | `image2Url` | 新增,第二张图片 | +| — | `image2Description` | 新增,图片2 说明文字 | +| — | `punchline` | 新增,谐音梗说明 | +| `stamina.max = 5` | `stamina.max = 50` | 数值变更 | diff --git a/docs/api/game-api.md b/docs/api/game-api.md index 7d141d7..be78770 100644 --- a/docs/api/game-api.md +++ b/docs/api/game-api.md @@ -91,8 +91,8 @@ Authorization: Bearer | 属性 | 值 | |------|-----| -| 默认体力 | 5(新用户注册时) | -| 上限 | 5 | +| 默认体力 | 50(新用户注册时) | +| 上限 | 50 | | 恢复速度 | 每 **10 分钟** 恢复 1 点 | | 消耗 | 进入**未通关**关卡时消耗 1 点 | | 已通关关卡 | 再次进入不消耗体力 | @@ -104,7 +104,7 @@ Authorization: Bearer ```typescript interface StaminaInfo { current: number; // 当前体力值(已计算恢复) - max: number; // 体力上限,固定为 5 + max: number; // 体力上限,固定为 50 nextRecoverAt: string | null; // 下一点体力恢复的时间(ISO 8601),满体力时为 null } ``` @@ -113,8 +113,8 @@ interface StaminaInfo { ```json { - "current": 3, - "max": 5, + "current": 45, + "max": 50, "nextRecoverAt": "2026-04-10T12:10:00.000Z" } ``` @@ -168,7 +168,7 @@ interface StaminaInfo { "user": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "nickname": null, - "stamina": 5 + "stamina": 50 } }, "message": null, @@ -211,8 +211,8 @@ interface StaminaInfo { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "nickname": null, "stamina": { - "current": 3, - "max": 5, + "current": 45, + "max": 50, "nextRecoverAt": "2026-04-10T12:10:00.000Z" } }, @@ -258,8 +258,8 @@ interface StaminaInfo { "user": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "stamina": { - "current": 5, - "max": 5, + "current": 50, + "max": 50, "nextRecoverAt": null } }, @@ -278,7 +278,7 @@ interface StaminaInfo { ### 4. 获取关卡列表 -获取所有关卡列表。**已通关的关卡**返回答案和线索,**未通关的关卡**不返回敏感数据。 +获取所有关卡列表。**已通关的关卡**返回答案、谐音梗说明和线索,**未通关的关卡**不返回敏感数据。 **接口地址**:`GET /api/v1/levels` @@ -297,8 +297,12 @@ interface StaminaInfo { interface LevelListItem { id: string; // 关卡 ID level: number; // 关卡编号(从 1 开始) - imageUrl: string; // 关卡图片 URL + image1Url: string; // 图片1 URL + image1Description: string | null; // 图片1 文本说明 + image2Url: string; // 图片2 URL + image2Description: string | null; // 图片2 文本说明 answer: string | null; // 答案(仅已通关时返回,否则 null) + punchline: string | null; // 谐音梗说明(仅已通关时返回,否则 null) hint1: string | null; // 线索1(仅已通关时返回,否则 null) hint2: string | null; // 线索2(仅已通关时返回,否则 null) hint3: string | null; // 线索3(仅已通关时返回,否则 null) @@ -317,8 +321,12 @@ interface LevelListItem { { "id": "level_001", "level": 1, - "imageUrl": "https://cdn.example.com/levels/001.png", + "image1Url": "https://cdn.example.com/levels/001_1.png", + "image1Description": "一只猫在看鱼", + "image2Url": "https://cdn.example.com/levels/001_2.png", + "image2Description": "一条鱼在飞", "answer": "梗答案", + "punchline": "谐音梗:鱼和猫的故事", "hint1": "这是一个经典的...", "hint2": "和某个明星有关", "hint3": null, @@ -328,8 +336,12 @@ interface LevelListItem { { "id": "level_002", "level": 2, - "imageUrl": "https://cdn.example.com/levels/002.png", + "image1Url": "https://cdn.example.com/levels/002_1.png", + "image1Description": "一个人在走路", + "image2Url": "https://cdn.example.com/levels/002_2.png", + "image2Description": "一辆车在跑", "answer": null, + "punchline": null, "hint1": null, "hint2": null, "hint3": null, @@ -346,8 +358,9 @@ interface LevelListItem { **客户端使用说明**: - 关卡选择页面使用此接口获取关卡列表 +- 每个关卡有两张图片(`image1Url`、`image2Url`)和对应的文本说明 - 根据 `completed` 字段展示不同的 UI 状态(已通关/未通关) -- 未通关关卡的 `answer`、`hint1`、`hint2`、`hint3` 均为 `null`,**客户端不应缓存这些字段** +- 未通关关卡的 `answer`、`punchline`、`hint1`、`hint2`、`hint3` 均为 `null`,**客户端不应缓存这些字段** --- @@ -373,8 +386,12 @@ interface LevelListItem { { id: string; level: number; - imageUrl: string; + image1Url: string; + image1Description: string | null; + image2Url: string; + image2Description: string | null; answer: string; + punchline: string | null; hint1: string | null; hint2: string | null; hint3: string | null; @@ -390,14 +407,18 @@ interface LevelListItem { "data": { "id": "level_002", "level": 2, - "imageUrl": "https://cdn.example.com/levels/002.png", + "image1Url": "https://cdn.example.com/levels/002_1.png", + "image1Description": "一个人在走路", + "image2Url": "https://cdn.example.com/levels/002_2.png", + "image2Description": "一辆车在跑", "answer": "这是答案", + "punchline": "谐音梗:走和跑的故事", "hint1": "第一个线索", "hint2": "第二个线索", "hint3": null, "stamina": { - "current": 2, - "max": 5, + "current": 47, + "max": 50, "nextRecoverAt": "2026-04-10T12:10:00.000Z" } }, @@ -658,7 +679,9 @@ interface GameState { currentLevel: { // 当前正在玩的关卡 id: string; answer: string; + punchline: string | null; hints: (string | null)[]; + images: { url: string; description: string | null }[]; startTime: number; // 开始时间戳,用于计算 timeSpent } | null; } @@ -823,8 +846,12 @@ async function loadGameData(): Promise { interface LevelListItem { id: string; level: number; - imageUrl: string; + image1Url: string; + image1Description: string | null; + image2Url: string; + image2Description: string | null; answer: string | null; + punchline: string | null; hint1: string | null; hint2: string | null; hint3: string | null; @@ -847,8 +874,12 @@ async function getLevels(): Promise { interface EnterLevelResponse { id: string; level: number; - imageUrl: string; + image1Url: string; + image1Description: string | null; + image2Url: string; + image2Description: string | null; answer: string; + punchline: string | null; hint1: string | null; hint2: string | null; hint3: string | null; @@ -969,4 +1000,6 @@ export class GameEntry extends Component { 4. **已通关关卡免费进入**:已通关关卡再次进入不消耗体力 5. **通关上报仅限成功**:只在用户答对后调用 `complete` 接口,答错不需要上报 6. **hint 字段**:`hint1/hint2/hint3` 可能为 `null`,表示该线索未配置 -7. **网络异常处理**:建议所有接口调用加 loading 状态,并处理 401(重新登录)和网络错误 +7. **punchline 字段**:谐音梗说明,仅已通关时返回,未通关时为 `null` +8. **双图结构**:每个关卡有两张图片(`image1Url`、`image2Url`),分别有对应的文本说明 +9. **网络异常处理**:建议所有接口调用加 loading 状态,并处理 401(重新登录)和网络错误 diff --git a/src/common/constants/game.constants.ts b/src/common/constants/game.constants.ts new file mode 100644 index 0000000..df41e2f --- /dev/null +++ b/src/common/constants/game.constants.ts @@ -0,0 +1,5 @@ +/** Maximum stamina a user can have */ +export const MAX_STAMINA = 50; + +/** Stamina recovery interval: 1 point every 10 minutes */ +export const RECOVER_INTERVAL_MS = 10 * 60 * 1000; diff --git a/src/common/filters/http-exception.filter.spec.ts b/src/common/filters/http-exception.filter.spec.ts index 0e9fd33..4fcc43e 100644 --- a/src/common/filters/http-exception.filter.spec.ts +++ b/src/common/filters/http-exception.filter.spec.ts @@ -13,9 +13,7 @@ describe('HttpExceptionFilter', () => { const mockJson = jest.fn(); const mockStatus = jest.fn().mockReturnValue({ json: mockJson }); const mockGetResponse = jest.fn().mockReturnValue({ status: mockStatus }); - const mockGetRequest = jest - .fn() - .mockReturnValue({ url: '/api/v1/test' }); + const mockGetRequest = jest.fn().mockReturnValue({ url: '/api/v1/test' }); const mockHost: ArgumentsHost = { switchToHttp: () => ({ diff --git a/src/common/guards/jwt-auth.guard.spec.ts b/src/common/guards/jwt-auth.guard.spec.ts index 8718b45..56a082b 100644 --- a/src/common/guards/jwt-auth.guard.spec.ts +++ b/src/common/guards/jwt-auth.guard.spec.ts @@ -45,7 +45,7 @@ describe('JwtAuthGuard', () => { mockJwtService.verifyAsync.mockResolvedValue(payload); const context = createMockContext('Bearer valid-token'); - const request = context.switchToHttp().getRequest() as Record; + const request = context.switchToHttp().getRequest(); await guard.canActivate(context); expect(request.user).toEqual(payload); diff --git a/src/database/migrations/003_level_dual_image_stamina.sql b/src/database/migrations/003_level_dual_image_stamina.sql new file mode 100644 index 0000000..2eec302 --- /dev/null +++ b/src/database/migrations/003_level_dual_image_stamina.sql @@ -0,0 +1,32 @@ +-- Migration: 003_level_dual_image_stamina +-- Description: Level dual-image support + stamina max 50 (old max was 5) + +-- 1. Rename image_url → image1_url and expand to VARCHAR(500) +ALTER TABLE levels CHANGE COLUMN image_url image1_url VARCHAR(500) NOT NULL; + +-- 2. Add image1_description after image1_url +ALTER TABLE levels ADD COLUMN image1_description VARCHAR(500) NULL AFTER image1_url; + +-- 3. Add image2_url with default empty string +ALTER TABLE levels ADD COLUMN image2_url VARCHAR(500) NOT NULL DEFAULT '' AFTER image1_description; + +-- 4. Add image2_description after image2_url +ALTER TABLE levels ADD COLUMN image2_description VARCHAR(500) NULL AFTER image2_url; + +-- 5. Add punchline (谐音梗说明) after answer +ALTER TABLE levels ADD COLUMN punchline VARCHAR(500) NULL AFTER answer; + +-- 6. Update stamina default from 5 to 50 +ALTER TABLE wx_users ALTER COLUMN stamina SET DEFAULT 50; + +-- 7. Bump users at or below old max (5) to new max (50) +UPDATE wx_users SET stamina = 50 WHERE stamina <= 5; + +-- ROLLBACK (manual): +-- ALTER TABLE levels CHANGE COLUMN image1_url image_url VARCHAR(191) NOT NULL; +-- ALTER TABLE levels DROP COLUMN image1_description; +-- ALTER TABLE levels DROP COLUMN image2_url; +-- ALTER TABLE levels DROP COLUMN image2_description; +-- ALTER TABLE levels DROP COLUMN punchline; +-- ALTER TABLE wx_users ALTER COLUMN stamina SET DEFAULT 5; +-- UPDATE wx_users SET stamina = 5 WHERE stamina = 50; diff --git a/src/modules/auth/auth.controller.spec.ts b/src/modules/auth/auth.controller.spec.ts index 88b802f..de22035 100644 --- a/src/modules/auth/auth.controller.spec.ts +++ b/src/modules/auth/auth.controller.spec.ts @@ -3,6 +3,7 @@ import { JwtService } from '@nestjs/jwt'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { ApiResponseDto } from '../../common/dto/api-response.dto'; +import { MAX_STAMINA } from '../../common/constants/game.constants'; describe('AuthController', () => { let controller: AuthController; @@ -31,7 +32,7 @@ describe('AuthController', () => { it('should return success response with token and user info', async () => { const loginResponse = { token: 'jwt-token', - user: { id: 'user-uuid-1', nickname: 'Test', stamina: 5 }, + user: { id: 'user-uuid-1', nickname: 'Test', stamina: MAX_STAMINA }, }; mockAuthService.wxLogin.mockResolvedValue(loginResponse); diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index ba44d3f..c63eb3f 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -1,9 +1,5 @@ import { Body, Controller, Post } from '@nestjs/common'; -import { - ApiOperation, - ApiResponse, - ApiTags, -} from '@nestjs/swagger'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { AuthService } from './auth.service'; import { WxLoginRequestDto, WxLoginResponseDto } from './dto/wx-login.dto'; import { ApiResponseDto } from '../../common/dto/api-response.dto'; diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index daf9ce1..2e2b20e 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -23,6 +23,11 @@ import { UserLevelProgressRepository } from './repositories/user-level-progress. ], controllers: [AuthController], providers: [AuthService, UserRepository, UserLevelProgressRepository], - exports: [JwtModule, AuthService, UserRepository, UserLevelProgressRepository], + exports: [ + JwtModule, + AuthService, + UserRepository, + UserLevelProgressRepository, + ], }) export class AuthModule {} diff --git a/src/modules/auth/auth.service.spec.ts b/src/modules/auth/auth.service.spec.ts index 4ca934b..07ad21a 100644 --- a/src/modules/auth/auth.service.spec.ts +++ b/src/modules/auth/auth.service.spec.ts @@ -6,6 +6,7 @@ import axios from 'axios'; import { AuthService } from './auth.service'; import { UserRepository } from './repositories/user.repository'; import { User } from './entities/user.entity'; +import { MAX_STAMINA } from '../../common/constants/game.constants'; jest.mock('axios'); const mockedAxios = axios as jest.Mocked; @@ -19,7 +20,7 @@ describe('AuthService', () => { sessionKey: 'session-key-abc', nickname: 'TestUser', avatarUrl: null, - stamina: 5, + stamina: MAX_STAMINA, staminaUpdatedAt: null, createdAt: new Date('2026-01-01'), updatedAt: new Date('2026-01-01'), @@ -65,7 +66,7 @@ describe('AuthService', () => { describe('wxLogin', () => { it('should create a new user and return JWT token on first login', async () => { - const newUser = { ...mockUser, stamina: 5 }; + const newUser = { ...mockUser }; mockedAxios.get.mockResolvedValue({ data: { openid: 'wx-openid-123', session_key: 'session-key-abc' }, }); @@ -78,11 +79,11 @@ describe('AuthService', () => { expect(result.token).toBe('jwt-token-xyz'); expect(result.user.id).toBe('user-uuid-1'); - expect(result.user.stamina).toBe(5); + expect(result.user.stamina).toBe(MAX_STAMINA); expect(mockUserRepository.create).toHaveBeenCalledWith({ openid: 'wx-openid-123', sessionKey: 'session-key-abc', - stamina: 5, + stamina: MAX_STAMINA, }); expect(mockJwtService.signAsync).toHaveBeenCalledWith({ sub: 'user-uuid-1', diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 813c6bc..a6e3262 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -1,14 +1,11 @@ -import { - Injectable, - Logger, - UnauthorizedException, -} from '@nestjs/common'; +import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import axios from 'axios'; import { UserRepository } from './repositories/user.repository'; import { WxLoginResponseDto, UserInfoDto } from './dto/wx-login.dto'; import { JwtPayload } from '../../common/guards/jwt-auth.guard'; +import { MAX_STAMINA } from '../../common/constants/game.constants'; interface WxSessionResponse { openid?: string; @@ -53,7 +50,7 @@ export class AuthService { user = this.userRepository.create({ openid: wxSession.openid, sessionKey: wxSession.session_key ?? null, - stamina: 5, // 新用户默认 5 体力值 + stamina: MAX_STAMINA, }); user = await this.userRepository.save(user); this.logger.log(`新用户注册: ${user.id}`); diff --git a/src/modules/auth/entities/user.entity.ts b/src/modules/auth/entities/user.entity.ts index 3c4f8ea..79ef3ee 100644 --- a/src/modules/auth/entities/user.entity.ts +++ b/src/modules/auth/entities/user.entity.ts @@ -6,6 +6,7 @@ import { UpdateDateColumn, Index, } from 'typeorm'; +import { MAX_STAMINA } from '../../../common/constants/game.constants'; @Entity('wx_users') export class User { @@ -25,8 +26,8 @@ export class User { @Column({ type: 'text', name: 'avatar_url', nullable: true }) avatarUrl!: string | null; - /** 体力值(默认 5,上限 5) */ - @Column({ type: 'int', default: 5 }) + /** 体力值(默认 MAX_STAMINA,上限 MAX_STAMINA) */ + @Column({ type: 'int', default: MAX_STAMINA }) stamina!: number; /** 体力值最后更新时间(用于计算恢复) */ diff --git a/src/modules/game-config/game-config.service.ts b/src/modules/game-config/game-config.service.ts index 1419e29..0939f6d 100644 --- a/src/modules/game-config/game-config.service.ts +++ b/src/modules/game-config/game-config.service.ts @@ -8,9 +8,7 @@ import { GameConfig } from '../wechat-game/entities/game-config.entity'; @Injectable() export class GameConfigService { - constructor( - private readonly gameConfigRepository: GameConfigRepository, - ) {} + constructor(private readonly gameConfigRepository: GameConfigRepository) {} async getAllConfigs(): Promise { const configs = await this.gameConfigRepository.findActiveConfigs(); diff --git a/src/modules/level/dto/enter-level.dto.ts b/src/modules/level/dto/enter-level.dto.ts index 6b7f49c..e6d4a91 100644 --- a/src/modules/level/dto/enter-level.dto.ts +++ b/src/modules/level/dto/enter-level.dto.ts @@ -8,12 +8,24 @@ export class EnterLevelResponseDto { @ApiProperty({ description: '关卡编号' }) level!: number; - @ApiProperty({ description: '图片 URL' }) - imageUrl!: string; + @ApiProperty({ description: '图片1 URL' }) + image1Url!: string; + + @ApiProperty({ description: '图片1 文本说明', nullable: true }) + image1Description!: string | null; + + @ApiProperty({ description: '图片2 URL' }) + image2Url!: string; + + @ApiProperty({ description: '图片2 文本说明', nullable: true }) + image2Description!: string | null; @ApiProperty({ description: '答案' }) answer!: string; + @ApiProperty({ description: '谐音梗说明', nullable: true }) + punchline!: string | null; + @ApiProperty({ description: '线索1', nullable: true }) hint1!: string | null; diff --git a/src/modules/level/dto/level-list.dto.ts b/src/modules/level/dto/level-list.dto.ts index 46a51b6..39ae94c 100644 --- a/src/modules/level/dto/level-list.dto.ts +++ b/src/modules/level/dto/level-list.dto.ts @@ -7,12 +7,24 @@ export class LevelListItemDto { @ApiProperty({ description: '关卡编号' }) level!: number; - @ApiProperty({ description: '图片 URL' }) - imageUrl!: string; + @ApiProperty({ description: '图片1 URL' }) + image1Url!: string; + + @ApiProperty({ description: '图片1 文本说明', nullable: true }) + image1Description!: string | null; + + @ApiProperty({ description: '图片2 URL' }) + image2Url!: string; + + @ApiProperty({ description: '图片2 文本说明', nullable: true }) + image2Description!: string | null; @ApiProperty({ description: '答案(仅已通关时返回)', nullable: true }) answer!: string | null; + @ApiProperty({ description: '谐音梗说明(仅已通关时返回)', nullable: true }) + punchline!: string | null; + @ApiProperty({ description: '线索1(仅已通关时返回)', nullable: true }) hint1!: string | null; @@ -25,7 +37,10 @@ export class LevelListItemDto { @ApiProperty({ description: '是否已通关' }) completed!: boolean; - @ApiProperty({ description: '通关时长(秒),未通关时为 null', nullable: true }) + @ApiProperty({ + description: '通关时长(秒),未通关时为 null', + nullable: true, + }) timeSpent!: number | null; } diff --git a/src/modules/level/level.controller.ts b/src/modules/level/level.controller.ts index 8a7de7c..882a2e8 100644 --- a/src/modules/level/level.controller.ts +++ b/src/modules/level/level.controller.ts @@ -1,11 +1,4 @@ -import { - Body, - Controller, - Get, - Param, - Post, - UseGuards, -} from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, @@ -49,7 +42,8 @@ export class LevelController { @Post(':id/enter') @ApiOperation({ summary: '进入关卡', - description: '消耗 1 体力进入关卡,返回完整关卡详情(线索+答案)。已通关关卡不消耗体力。', + description: + '消耗 1 体力进入关卡,返回完整关卡详情(线索+答案)。已通关关卡不消耗体力。', }) @ApiResponse({ status: 200, description: '成功' }) @ApiResponse({ status: 400, description: '体力不足' }) diff --git a/src/modules/level/level.service.ts b/src/modules/level/level.service.ts index 379b528..bdffa5d 100644 --- a/src/modules/level/level.service.ts +++ b/src/modules/level/level.service.ts @@ -1,8 +1,4 @@ -import { - Injectable, - NotFoundException, - Logger, -} from '@nestjs/common'; +import { Injectable, NotFoundException, Logger } from '@nestjs/common'; import { LevelRepository } from '../wechat-game/repositories/level.repository'; import { UserLevelProgressRepository } from '../auth/repositories/user-level-progress.repository'; import { UserService } from '../user/user.service'; @@ -12,6 +8,10 @@ import { CompleteLevelRequestDto, CompleteLevelResponseDto, } from './dto/complete-level.dto'; +import { + pickLevelImageFields, + pickLevelImageFieldsMasked, +} from '../wechat-game/level-fields.helper'; @Injectable() export class LevelService { @@ -32,9 +32,7 @@ export class LevelService { this.userLevelProgressRepository.findByUserId(userId), ]); - const progressMap = new Map( - progressList.map((p) => [p.levelId, p]), - ); + const progressMap = new Map(progressList.map((p) => [p.levelId, p])); const items: LevelListItemDto[] = levels.map((level, index) => { const progress = progressMap.get(level.id); @@ -43,11 +41,8 @@ export class LevelService { return { id: level.id, level: index + 1, - imageUrl: level.imageUrl, + ...pickLevelImageFieldsMasked(level, completed), answer: completed ? level.answer : null, - hint1: completed ? level.hint1 : null, - hint2: completed ? level.hint2 : null, - hint3: completed ? level.hint3 : null, completed, timeSpent: completed ? progress.timeSpent : null, }; @@ -63,7 +58,6 @@ export class LevelService { userId: string, levelId: string, ): Promise { - // 1. 并行查找关卡和通关记录 const [level, existing] = await Promise.all([ this.levelRepository.findById(levelId), this.userLevelProgressRepository.findByUserAndLevel(userId, levelId), @@ -76,11 +70,10 @@ export class LevelService { let staminaInfo; if (existing) { - // 已通关,不消耗体力,直接返回 + // Already completed — no stamina cost const user = await this.userService.findUserOrThrow(userId); staminaInfo = this.userService.computeStamina(user); } else { - // 未通关,消耗体力(返回值已包含 stamina 信息,无需重复计算) const result = await this.userService.consumeStamina(userId); staminaInfo = result.stamina; this.logger.log(`用户 ${userId} 进入关卡 ${levelId},消耗 1 体力`); @@ -89,11 +82,8 @@ export class LevelService { return { id: level.id, level: level.sortOrder, - imageUrl: level.imageUrl, + ...pickLevelImageFields(level), answer: level.answer, - hint1: level.hint1, - hint2: level.hint2, - hint3: level.hint3, stamina: staminaInfo, }; } @@ -106,7 +96,6 @@ export class LevelService { levelId: string, dto: CompleteLevelRequestDto, ): Promise { - // 并行验证关卡存在和检查通关记录 const [level, existing] = await Promise.all([ this.levelRepository.findById(levelId), this.userLevelProgressRepository.findByUserAndLevel(userId, levelId), @@ -125,7 +114,6 @@ export class LevelService { }; } - // 记录通关进度 const progress = this.userLevelProgressRepository.create({ userId, levelId, @@ -133,9 +121,7 @@ export class LevelService { }); await this.userLevelProgressRepository.save(progress); - this.logger.log( - `用户 ${userId} 通关 ${levelId},用时 ${dto.timeSpent} 秒`, - ); + this.logger.log(`用户 ${userId} 通关 ${levelId},用时 ${dto.timeSpent} 秒`); return { firstClear: true, diff --git a/src/modules/share/dto/report-level-progress.dto.ts b/src/modules/share/dto/report-level-progress.dto.ts index 6aa863f..d9cd0e3 100644 --- a/src/modules/share/dto/report-level-progress.dto.ts +++ b/src/modules/share/dto/report-level-progress.dto.ts @@ -1,5 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsBoolean, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator'; +import { + IsBoolean, + IsNotEmpty, + IsNumber, + IsString, + Min, +} from 'class-validator'; export class ReportLevelProgressDto { @ApiProperty({ description: '分享码' }) diff --git a/src/modules/share/dto/share-response.dto.ts b/src/modules/share/dto/share-response.dto.ts index 61d64bb..76bdc92 100644 --- a/src/modules/share/dto/share-response.dto.ts +++ b/src/modules/share/dto/share-response.dto.ts @@ -18,12 +18,24 @@ export class ShareLevelDto { @ApiProperty() level!: number; - @ApiProperty() - imageUrl!: string; + @ApiProperty({ description: '图片1 URL' }) + image1Url!: string; + + @ApiProperty({ description: '图片1 文本说明', nullable: true }) + image1Description!: string | null; + + @ApiProperty({ description: '图片2 URL' }) + image2Url!: string; + + @ApiProperty({ description: '图片2 文本说明', nullable: true }) + image2Description!: string | null; @ApiProperty() answer!: string; + @ApiProperty({ description: '谐音梗说明', nullable: true }) + punchline!: string | null; + @ApiProperty({ nullable: true }) hint1!: string | null; diff --git a/src/modules/share/entities/share-level-progress.entity.ts b/src/modules/share/entities/share-level-progress.entity.ts index c481441..9bab889 100644 --- a/src/modules/share/entities/share-level-progress.entity.ts +++ b/src/modules/share/entities/share-level-progress.entity.ts @@ -38,6 +38,11 @@ export class ShareLevelProgress { @Column({ type: 'int', default: 0, name: 'time_spent' }) timeSpent!: number; - @Column({ type: 'timestamp', name: 'completed_at', nullable: true, default: null }) + @Column({ + type: 'timestamp', + name: 'completed_at', + nullable: true, + default: null, + }) completedAt!: Date | null; } diff --git a/src/modules/share/share.service.spec.ts b/src/modules/share/share.service.spec.ts index 59b09a7..ebc3139 100644 --- a/src/modules/share/share.service.spec.ts +++ b/src/modules/share/share.service.spec.ts @@ -21,8 +21,12 @@ describe('ShareService', () => { const mockLevels: Level[] = Array.from({ length: 6 }, (_, i) => ({ id: `level-${i + 1}`, - imageUrl: `https://example.com/meme${i + 1}.jpg`, + image1Url: `https://example.com/meme${i + 1}_1.jpg`, + image1Description: null, + image2Url: `https://example.com/meme${i + 1}_2.jpg`, + image2Description: null, answer: `答案${i + 1}`, + punchline: null, hint1: `提示${i + 1}`, hint2: null, hint3: null, diff --git a/src/modules/share/share.service.ts b/src/modules/share/share.service.ts index e59894e..8618f7e 100644 --- a/src/modules/share/share.service.ts +++ b/src/modules/share/share.service.ts @@ -8,6 +8,7 @@ import { ShareConfigRepository } from './repositories/share-config.repository'; import { ShareParticipantRepository } from './repositories/share-participant.repository'; import { ShareLevelProgressRepository } from './repositories/share-level-progress.repository'; import { LevelRepository } from '../wechat-game/repositories/level.repository'; +import { pickLevelImageFields } from '../wechat-game/level-fields.helper'; import { CreateShareDto } from './dto/create-share.dto'; import { ReportLevelProgressDto } from './dto/report-level-progress.dto'; import { @@ -83,7 +84,7 @@ export class ShareService { await this.shareParticipantRepository.addParticipant(config.id, userId); } - // 单次查询获取所有关卡,再按 levelIds 顺序排列 + // Single query, then reorder to match levelIds sequence const allLevels = await this.levelRepository.findByIds(config.levelIds); const levelMap = new Map(allLevels.map((l) => [l.id, l])); @@ -95,11 +96,8 @@ export class ShareService { return { id: level.id, level: index + 1, - imageUrl: level.imageUrl, + ...pickLevelImageFields(level), answer: level.answer, - hint1: level.hint1, - hint2: level.hint2, - hint3: level.hint3, sortOrder: level.sortOrder, }; }); diff --git a/src/modules/user/dto/user-profile.dto.ts b/src/modules/user/dto/user-profile.dto.ts index df4f2a7..3257b06 100644 --- a/src/modules/user/dto/user-profile.dto.ts +++ b/src/modules/user/dto/user-profile.dto.ts @@ -7,7 +7,10 @@ export class StaminaInfoDto { @ApiProperty({ description: '体力值上限' }) max!: number; - @ApiProperty({ description: '下次恢复时间(ISO 字符串),满体力时为 null', nullable: true }) + @ApiProperty({ + description: '下次恢复时间(ISO 字符串),满体力时为 null', + nullable: true, + }) nextRecoverAt!: string | null; } diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index 2433285..4a545fb 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -6,7 +6,10 @@ import { ApiTags, } from '@nestjs/swagger'; import { UserService } from './user.service'; -import { UserProfileResponseDto, GameDataResponseDto } from './dto/user-profile.dto'; +import { + UserProfileResponseDto, + GameDataResponseDto, +} from './dto/user-profile.dto'; import { ApiResponseDto } from '../../common/dto/api-response.dto'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import type { JwtPayload } from '../../common/guards/jwt-auth.guard'; diff --git a/src/modules/user/user.service.ts b/src/modules/user/user.service.ts index 0e96c52..c70699f 100644 --- a/src/modules/user/user.service.ts +++ b/src/modules/user/user.service.ts @@ -11,9 +11,12 @@ import { UserProfileResponseDto, GameDataResponseDto, } from './dto/user-profile.dto'; +import { + MAX_STAMINA, + RECOVER_INTERVAL_MS, +} from '../../common/constants/game.constants'; -export const MAX_STAMINA = 5; -export const RECOVER_INTERVAL_MS = 10 * 60 * 1000; // 10 分钟 +export { MAX_STAMINA, RECOVER_INTERVAL_MS }; @Injectable() export class UserService { @@ -53,12 +56,15 @@ export class UserService { return { current: currentStamina, max: MAX_STAMINA, nextRecoverAt }; } + private static readonly MAX_STAMINA_RETRIES = 3; + /** * 消耗 1 点体力,返回消耗后的体力信息。 * 使用原子更新防止并发竞态条件(双击进入关卡场景)。 */ async consumeStamina( userId: string, + retries = 0, ): Promise<{ user: User; stamina: StaminaInfoDto }> { const user = await this.findUserOrThrow(userId); const staminaInfo = this.computeStamina(user); @@ -70,7 +76,6 @@ export class UserService { const newStamina = staminaInfo.current - 1; const now = new Date(); - // 原子更新:使用 WHERE 条件确保并发安全 const result = await this.userRepository.updateStaminaAtomic( userId, user.stamina, @@ -79,14 +84,30 @@ export class UserService { ); if (result.affected === 0) { - // 并发冲突,重试一次 - return this.consumeStamina(userId); + if (retries >= UserService.MAX_STAMINA_RETRIES) { + throw new BadRequestException('操作冲突,请重试'); + } + return this.consumeStamina(userId, retries + 1); } - const updatedUser = { ...user, stamina: newStamina, staminaUpdatedAt: now }; - const updatedStamina = this.computeStamina(updatedUser as User); + const updatedStamina: StaminaInfoDto = + newStamina >= MAX_STAMINA + ? { current: MAX_STAMINA, max: MAX_STAMINA, nextRecoverAt: null } + : { + current: newStamina, + max: MAX_STAMINA, + nextRecoverAt: new Date( + now.getTime() + RECOVER_INTERVAL_MS, + ).toISOString(), + }; - return { user: updatedUser as User, stamina: updatedStamina }; + return { + user: Object.assign(Object.create(Object.getPrototypeOf(user)), user, { + stamina: newStamina, + staminaUpdatedAt: now, + }), + stamina: updatedStamina, + }; } async getUserProfile(userId: string): Promise { diff --git a/src/modules/wechat-game/dto/level-response.dto.ts b/src/modules/wechat-game/dto/level-response.dto.ts index 468885b..69f76ad 100644 --- a/src/modules/wechat-game/dto/level-response.dto.ts +++ b/src/modules/wechat-game/dto/level-response.dto.ts @@ -7,12 +7,24 @@ export class LevelResponseDto { @ApiProperty({ description: '关卡ID' }) id!: string; - @ApiProperty({ description: '图片URL' }) - imageUrl!: string; + @ApiProperty({ description: '图片1 URL' }) + image1Url!: string; + + @ApiProperty({ description: '图片1 文本说明', nullable: true }) + image1Description!: string | null; + + @ApiProperty({ description: '图片2 URL' }) + image2Url!: string; + + @ApiProperty({ description: '图片2 文本说明', nullable: true }) + image2Description!: string | null; @ApiProperty({ description: '答案' }) answer!: string; + @ApiProperty({ description: '谐音梗说明', nullable: true }) + punchline!: string | null; + @ApiProperty({ description: '提示1', nullable: true }) hint1!: string | null; diff --git a/src/modules/wechat-game/entities/level.entity.ts b/src/modules/wechat-game/entities/level.entity.ts index 5b089b9..4b4a5f1 100644 --- a/src/modules/wechat-game/entities/level.entity.ts +++ b/src/modules/wechat-game/entities/level.entity.ts @@ -11,12 +11,34 @@ export class Level { @PrimaryColumn({ type: 'varchar', length: 191 }) id!: string; - @Column({ type: 'varchar', length: 191, name: 'image_url' }) - imageUrl!: string; + @Column({ type: 'varchar', length: 500, name: 'image1_url' }) + image1Url!: string; + + @Column({ + type: 'varchar', + length: 500, + name: 'image1_description', + nullable: true, + }) + image1Description!: string | null; + + @Column({ type: 'varchar', length: 500, name: 'image2_url', default: '' }) + image2Url!: string; + + @Column({ + type: 'varchar', + length: 500, + name: 'image2_description', + nullable: true, + }) + image2Description!: string | null; @Column({ type: 'varchar', length: 191 }) answer!: string; + @Column({ type: 'varchar', length: 500, nullable: true }) + punchline!: string | null; + @Column({ type: 'varchar', length: 191, nullable: true }) hint1!: string | null; diff --git a/src/modules/wechat-game/level-fields.helper.ts b/src/modules/wechat-game/level-fields.helper.ts new file mode 100644 index 0000000..1ea0ede --- /dev/null +++ b/src/modules/wechat-game/level-fields.helper.ts @@ -0,0 +1,49 @@ +import { Level } from './entities/level.entity'; + +/** Common image + content fields shared across all level-related DTOs */ +export interface LevelImageFields { + image1Url: string; + image1Description: string | null; + image2Url: string; + image2Description: string | null; + punchline: string | null; + hint1: string | null; + hint2: string | null; + hint3: string | null; +} + +/** + * Pick the common image/content fields from a Level entity. + * Use spread to merge into any level DTO. + */ +export function pickLevelImageFields(level: Level): LevelImageFields { + return { + image1Url: level.image1Url, + image1Description: level.image1Description, + image2Url: level.image2Url, + image2Description: level.image2Description, + punchline: level.punchline, + hint1: level.hint1, + hint2: level.hint2, + hint3: level.hint3, + }; +} + +/** + * Pick image fields with answer/hints masked for non-completed levels. + */ +export function pickLevelImageFieldsMasked( + level: Level, + completed: boolean, +): LevelImageFields { + return { + image1Url: level.image1Url, + image1Description: level.image1Description, + image2Url: level.image2Url, + image2Description: level.image2Description, + punchline: completed ? level.punchline : null, + hint1: completed ? level.hint1 : null, + hint2: completed ? level.hint2 : null, + hint3: completed ? level.hint3 : null, + }; +}