feat: 支持登录、个人信息存储

This commit is contained in:
richarjiang
2026-04-05 13:38:12 +08:00
parent 46368b8c89
commit 9ab78555cb
24 changed files with 3560 additions and 20 deletions

View File

@@ -8,3 +8,8 @@ DB_DATABASE=mememind
# Application Configuration # Application Configuration
NODE_ENV=production NODE_ENV=production
PORT=3000 PORT=3000
WX_APPID=wx0f9c909d20d19396
WX_SECRET=c5635680747cccf351f5f323c01178e6
JWT_SECRET=mp-xieyingen

View File

@@ -1,12 +0,0 @@
# 生产环境配置
# 复制此文件为 .env.production 并修改配置
NODE_ENV=production
PORT=3000
# 数据库配置
DB_HOST=localhost
DB_PORT=3306
DB_USERNAME=your_db_username
DB_PASSWORD=your_db_password
DB_DATABASE=meme_mind

706
ARCHITECTURE_DIAGRAMS.md Normal file
View File

@@ -0,0 +1,706 @@
# MemeMind-Server Architecture Diagrams & Flows
## 1. System Architecture Overview
```
┌───────────────────────────────────────────────────────────────┐
│ WeChat Mini-Game Client │
│ (Cocos Creator 3.8.8) │
│ │
│ • PageLoading.ts (startup) │
│ • LevelDataManager.ts (API calls) │
│ • PageLevel.ts (gameplay) │
│ • StorageManager.ts (localStorage) │
└────────────────────┬────────────────────────────────────────┘
│ HTTP Requests
│ GET /api/v1/wechat-game/levels
│ GET /api/v1/wechat-game/configs
┌────────────────────▼────────────────────────────────────────┐
│ MemeMind-Server (NestJS) │
│ http://ilookai.cn:3000/api │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ HTTP Layer │ │
│ │ • GlobalPrefix: /api │ │
│ │ • CORS: Enabled │ │
│ │ • ValidationPipe: Global validation │ │
│ │ • Swagger: /api/docs │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────▼──────────────────────────────┐ │
│ │ WechatGameController │ │
│ │ POST /v1/wechat-game/configs │ │
│ │ GET /v1/wechat-game/configs/:key │ │
│ │ GET /v1/wechat-game/levels │ │
│ │ GET /v1/wechat-game/levels/:id │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ │
│ ┌──────────────────────▼──────────────────────────────┐ │
│ │ WechatGameService │ │
│ │ • getAllConfigs() │ │
│ │ • getConfigByKey(key) │ │
│ │ • getAllLevels() │ │
│ │ • getLevelById(id) │ │
│ │ • toResponseDto() │ │
│ │ • toLevelResponseDto() │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ │
│ ┌──────────────────────▼──────────────────────────────┐ │
│ │ Repository Layer │ │
│ │ ├─ LevelRepository │ │
│ │ └─ GameConfigRepository │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ │
│ ┌──────────────────────▼──────────────────────────────┐ │
│ │ TypeORM / MySQL │ │
│ │ ├─ levels table │ │
│ │ └─ game_configs table │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
└───────────────────────────────────────────────────────────────┘
│ Response (JSON)
│ {success, data, message, timestamp}
┌────────────────────▼────────────────────────────────────────┐
│ WeChat Mini-Game Client │
│ • LevelDataManager stores in _apiData │
│ • PageLevel reads _apiData │
│ • Images preloaded via assetManager.loadRemote() │
└───────────────────────────────────────────────────────────────┘
```
---
## 2. Request-Response Flow
### Scenario 1: Get All Levels
```
Client Server
│ │
├─ GET /api/v1/wechat-game/levels
│─────────────────────────────>│
│ │
│ [Controller]
│ getAllLevels()
│ │
│ [Service]
│ levelRepository.findAllOrdered()
│ │
│ [Repository]
│ SELECT * FROM levels ORDER BY sort_order
│ │
│ [MySQL]
│ Returns Level[]
│ │
│ [Service]
│ Map to LevelResponseDto
│ Add level numbers
│ │
│ [Filter]
│ Wrap in ApiResponseDto.success()
│ │
│ <─────────────────────────────│
│ { │
│ "success": true, │
│ "data": { │
│ "levels": [...], │
│ "total": 50 │
│ }, │
│ "message": null, │
│ "timestamp": "2026-04-05..." │
│ } │
│ │
├─ Store in _apiData
├─ Preload images
└─ Ready for gameplay
```
### Scenario 2: Get Config by Key
```
Client Server
│ │
├─ GET /api/v1/wechat-game/configs/HINT_COST
│─────────────────────────────>│
│ │
│ [Controller]
│ getConfigByKey("HINT_COST")
│ │
│ [Service]
│ gameConfigRepository.findByKey()
│ │
│ [Repository]
│ SELECT * FROM game_configs
│ WHERE config_key = 'HINT_COST'
│ │
│ [MySQL]
│ Returns GameConfig or null
│ │
│ ┌─────────┴─────────┐
│ │ │
│ FOUND NOT FOUND
│ │ │
│ [Service] [Service]
│ Map to DTO throw NotFoundException
│ │ │
│ [Filter] [Filter]
│ success() catch exception
│ │ │
│ <─────────────────┤ │
│ { │ │
│ "success": true,│ │
│ "data": {...} │ │
│ } │ ┌────────────────┘
│ │ │
│ │ └─> {
│ │ "success": false,
│ │ "data": null,
│ │ "message": "Game config... not found",
│ │ "path": "/api/v1/..."
│ │ }
│ │
└──────────────────────────────────
```
---
## 3. Module Dependency Graph
```
┌──────────────────────────────┐
│ AppModule (root) │
├──────────────────────────────┤
│ │
│ imports: [ │
│ AppConfigModule, │
│ TypeOrmModule, │
│ WechatGameModule │
│ ] │
│ │
└──┬───────────────────────────┘
├─────────────────────────────────────┐
│ │
│ │
┌──▼─────────────────┐ ┌──────────────▼──────────────┐
│ AppConfigModule │ │ WechatGameModule │
├────────────────────┤ ├─────────────────────────────┤
│ @Global() │ │ imports: [ │
│ │ │ TypeOrmModule.forFeature( │
│ imports: [ │ │ [GameConfig, Level] │
│ ConfigModule │ │ ) │
│ ] │ │ ] │
│ │ │ │
│ exports: [ │ │ controllers: [ │
│ ConfigModule │ │ WechatGameController │
│ ] │ │ ] │
│ │ │ │
│ │ │ providers: [ │
│ │ │ WechatGameService, │
│ │ │ LevelRepository, │
│ │ │ GameConfigRepository │
│ │ │ ] │
│ │ │ │
│ │ │ exports: [ │
│ │ │ WechatGameService │
│ │ │ ] │
│ │ │ │
└────────────────────┘ └─────────────────────────────┘
┌──────────▼───────────────┐
│ TypeOrmModule.forFeature│
├───────────────────────────┤
│ Registers: │
│ • Level entity │
│ • GameConfig entity │
│ • Auto-creates repos │
└──────────────────────────┘
```
---
## 4. Data Model Relationships
```
┌────────────────────────────────┐
│ levels │
├────────────────────────────────┤
│ PK: id (VARCHAR 191) │
├────────────────────────────────┤
│ id (PK) │
│ image_url (VARCHAR) │
│ answer (VARCHAR) │
│ hint1 (VARCHAR) │
│ hint2 (VARCHAR) │
│ hint3 (VARCHAR) │
│ sort_order (INT) │
│ created_at (DATETIME)│
│ updated_at (DATETIME)│
├────────────────────────────────┤
│ Indexes: │
│ • PK: id │
│ • idx_sort_order: sort_order │
├────────────────────────────────┤
│ Used by: │
│ • LevelRepository │
│ • WechatGameService │
└────────────────────────────────┘
┌────────────────────────────────┐
│ game_configs │
├────────────────────────────────┤
│ PK: id (UUID) │
├────────────────────────────────┤
│ id (PK) │
│ config_key (VARCHAR) │
│ config_value (TEXT) │
│ description (VARCHAR) │
│ is_active (BOOLEAN) │
│ created_at (DATETIME)│
│ updated_at (DATETIME)│
├────────────────────────────────┤
│ Indexes: │
│ • PK: id │
│ • UNIQUE: config_key │
│ • idx_active: is_active │
├────────────────────────────────┤
│ Used by: │
│ • GameConfigRepository │
│ • WechatGameService │
└────────────────────────────────┘
```
---
## 5. Service Method Call Chain
### GET /api/v1/wechat-game/levels
```
Controller.getAllLevels()
├─> service.getAllLevels()
│ │
│ ├─> levelRepository.findAllOrdered()
│ │ │
│ │ └─> repository.find({ order: { sortOrder: 'ASC' } })
│ │ │
│ │ └─> [SELECT * FROM levels ORDER BY sort_order ASC]
│ │
│ ├─> LOOP levels array:
│ │ ├─> toLevelResponseDto(level, index + 1)
│ │ │ │
│ │ │ └─> {
│ │ │ level: 1 (or 2, 3, ...)
│ │ │ id: level.id
│ │ │ imageUrl: level.imageUrl
│ │ │ answer: level.answer
│ │ │ hint1: level.hint1
│ │ │ hint2: level.hint2
│ │ │ hint3: level.hint3
│ │ │ sortOrder: level.sortOrder
│ │ │ createdAt: level.createdAt
│ │ │ updatedAt: level.updatedAt
│ │ │ }
│ │
│ └─> return {
│ levels: [LevelResponseDto[], ...]
│ total: count
│ }
└─> ApiResponseDto.success(data)
└─> {
success: true
data: { levels, total }
message: null
timestamp: new Date()
}
```
---
## 6. Error Handling Flow
```
┌─────────────────┐
│ HTTP Request │
└────────┬────────┘
┌────▼─────────────┐
│ ValidationPipe │
│ (Global) │
└────┬─────────────┘
┌───┴────────────────────────┐
│ │
▼ Valid ▼ Invalid
Continue ValidationException
[ExceptionFilter]
catches @Catch()
ApiResponseDto.error()
{success: false,
message: "...",
path: "..."}
▼ Valid data
[Controller]
[Service]
[Repository]
[Database]
├─ Success ──┐
│ │
│ ▼
│ [Service] returns data
│ │
│ [Controller]
│ │
│ ApiResponseDto.success(data)
│ │
│ Return to client
└─ Exception ──┐
NotFoundException
BadRequestException
(or any HttpException)
[HttpExceptionFilter]
@Catch() catches exception
Extract status & message
ApiResponseDto.error(message)
response.status(code).json(errorDto)
Return to client with error
```
---
## 7. Request Validation Pipeline
```
HTTP Request
├─ Path params extracted
│ @Param('id') id: string
│ @Param('key') key: string
├─ Query params extracted
│ @Query() dto: QueryDto
├─ Body params extracted (for POST/PUT)
│ @Body() dto: CreateDto
├─ Global ValidationPipe processes:
│ │
│ ├─ whitelist: true
│ │ └─ Remove unknown properties
│ │
│ ├─ forbidNonWhitelisted: true
│ │ └─ Throw if unknown properties found
│ │
│ └─ transform: true
│ └─ Transform strings to appropriate types
│ (e.g., "123" → 123)
├─ class-validator decorators checked
│ @IsString()
│ @IsNumber()
│ @IsEmail()
│ etc.
├─ If validation fails
│ └─> BadRequestException
│ └─> ExceptionFilter catches
│ └─> 400 status + error message
└─ If validation passes
└─> Continue to controller
```
---
## 8. Data Transformation Chain
```
HTTP Request JSON
├─ Parse JSON
├─ Extract into DTO object
│ {
│ "@Type(() => Number)" hint: "5" ──> 5 (number)
│ "@Transform()" name: "JOHN" ──> "john" (lowercased)
│ }
├─ Validate against DTO decorators
│ @IsNotEmpty()
│ @IsNumber()
│ @Min(0)
│ @Max(100)
├─ Pass to Service
├─ Service transforms to Entity
│ DTO ──> Entity
│ {id, name} {id, name, timestamp}
├─ Database operations
│ Entity ──> SQL
│ TypeORM handles serialization
├─ Result from Database
│ Entity[] ──> Entity[]
├─ Service transforms Entity to ResponseDto
│ Entity ──> ResponseDto
│ Remove sensitive fields
│ Add computed fields
├─ Wrap in ApiResponseDto
└─ Send as JSON Response
{
success: true,
data: [...ResponseDtos...],
message: null,
timestamp: "..."
}
```
---
## 9. Database Connection Lifecycle
```
[Application Start]
[config.module.ts loads]
• validateEnvironment()
• Reads .env, .env.local, .env.production
[app.module.ts initializes]
• TypeOrmModule.forRootAsync()
• Uses ConfigService to get DB params
├─ DB_HOST: localhost
├─ DB_PORT: 3306
├─ DB_USERNAME: meme_user
├─ DB_PASSWORD: (from env)
├─ DB_DATABASE: meme_mind
[TypeORM connects to MySQL]
mysql2 driver establishes connection
├─ If NODE_ENV === 'development'
│ └─ synchronize: true
│ └─ Auto-create/update tables
├─ If NODE_ENV === 'production'
│ └─ synchronize: false
│ └─ Use migrations instead
[Repositories instantiated]
@InjectRepository(Level)
@InjectRepository(GameConfig)
[Ready to accept requests]
• findAllOrdered() ──> SELECT ...
• findByKey() ──> SELECT ...
[Application shutdown]
TypeORM closes connection
```
---
## 10. Environment to Runtime Configuration
```
.env.local / .env file
├─ NODE_ENV=development
├─ PORT=3000
├─ DB_HOST=localhost
├─ DB_PORT=3306
├─ DB_USERNAME=meme_user
├─ DB_PASSWORD=secret
├─ DB_DATABASE=meme_mind
env.validation.ts
• plainToInstance(EnvironmentVariables, config)
• validateSync()
• Throws if validation fails
config.module.ts
• ConfigModule.forRoot()
• isGlobal: true ──> Available everywhere
• validate: validateEnvironment
database.config.ts
registerAs('database', () => ({
type: 'mysql',
host: configService.get('DB_HOST'),
port: configService.get('DB_PORT'),
username: configService.get('DB_USERNAME'),
password: configService.get('DB_PASSWORD'),
database: configService.get('DB_DATABASE'),
...
}))
app.module.ts
TypeOrmModule.forRootAsync({
useFactory: (configService) => ({
...configService.get('database')
})
})
main.ts
port = process.env.PORT ?? 3000
app.listen(port)
Application Running
• Connected to MySQL
• Listening on port
• Ready for requests
```
---
## 11. API Response Mapping Example
### Request:
```
GET /api/v1/wechat-game/levels
```
### Database Results:
```sql
SELECT * FROM levels ORDER BY sort_order ASC LIMIT 2;
Results:
id image_url answer hint1 hint2 hint3 sort_order created_at updated_at
level-001 http://...img1.jpg meme image funny null 0 2026-04-01 2026-04-05
level-002 http://...img2.jpg code tech null null 1 2026-04-02 2026-04-05
```
### Service Transformation:
```javascript
levels.map((level, index) => toLevelResponseDto(level, index + 1))
Result:
[
{
level: 1, // Computed: index + 1
id: "level-001",
imageUrl: "http://...img1.jpg",
answer: "meme",
hint1: "image",
hint2: "funny",
hint3: null,
sortOrder: 0,
createdAt: "2026-04-01T...",
updatedAt: "2026-04-05T..."
},
{
level: 2,
id: "level-002",
imageUrl: "http://...img2.jpg",
answer: "code",
hint1: "tech",
hint2: null,
hint3: null,
sortOrder: 1,
createdAt: "2026-04-02T...",
updatedAt: "2026-04-05T..."
}
]
```
### Final HTTP Response:
```json
{
"success": true,
"data": {
"levels": [
{
"level": 1,
"id": "level-001",
"imageUrl": "http://...img1.jpg",
"answer": "meme",
"hint1": "image",
"hint2": "funny",
"hint3": null,
"sortOrder": 0,
"createdAt": "2026-04-01T00:00:00.000Z",
"updatedAt": "2026-04-05T12:00:00.000Z"
},
{
"level": 2,
"id": "level-002",
"imageUrl": "http://...img2.jpg",
"answer": "code",
"hint1": "tech",
"hint2": null,
"hint3": null,
"sortOrder": 1,
"createdAt": "2026-04-02T00:00:00.000Z",
"updatedAt": "2026-04-05T12:00:00.000Z"
}
],
"total": 2
},
"message": null,
"timestamp": "2026-04-05T12:34:56.789Z"
}
```
---
## Summary of Diagrams
1. **System Architecture**: High-level components (Client, Server, Database)
2. **Request-Response Flow**: Detailed flow for GET and error scenarios
3. **Module Dependency Graph**: How modules depend on each other
4. **Data Model Relationships**: Database table structures
5. **Service Method Call Chain**: Stack of calls from Controller to DB
6. **Error Handling Flow**: Exception catching and wrapping
7. **Request Validation Pipeline**: Validation process
8. **Data Transformation Chain**: DTO → Entity → DB → Entity → ResponseDto
9. **Database Connection Lifecycle**: Connection initialization
10. **Environment to Runtime**: How .env becomes runtime config
11. **API Response Mapping**: Real example of transformation
*Generated: 2026-04-05*

View File

@@ -0,0 +1,836 @@
# MemeMind Client-Server Integration Guide
## Overview
This document explains how the Cocos Creator client communicates with the MemeMind-Server backend and what extensions would be needed to support the full game flow including user authentication, progress tracking, and point/life management.
---
## Part 1: Current Integration (Read-Only)
### Current API Call: Get All Levels
**Client File**: `/Users/richard/Documents/code/cocosProject/mp-xieyingeng/assets/scripts/managers/LevelDataManager.ts`
**Current Implementation**:
```typescript
async initialize(): Promise<void> {
try {
// Initialize() is called by PageLoading during startup
const response = await HttpUtil.get<ApiResponse>(
'https://ilookai.cn/api/v1/wechat-game/levels'
);
if (response.success && response.data?.levels) {
this._apiData = response.data.levels;
this._levelDataCache.clear();
// Preload next level images asynchronously
this.preloadNextLevel(0);
}
} catch (error) {
console.error('Failed to load level data', error);
}
}
```
**Server Endpoint**:
```
GET /api/v1/wechat-game/levels
Status: 200
Response: ApiResponseDto<LevelListResponseDto>
```
**Response Format**:
```typescript
{
success: true,
data: {
levels: [
{
level: 1, // Level number (1-indexed)
id: "level-001", // Unique ID
imageUrl: "https://...", // Level image URL
answer: "meme", // Correct answer
hint1: "image", // First hint (free)
hint2: "funny", // Second hint (costs 1 life)
hint3: null, // Third hint (costs 1 life)
sortOrder: 0, // Display order
createdAt: "2026-04-01T...",
updatedAt: "2026-04-05T..."
},
...
],
total: 50
},
message: null,
timestamp: "2026-04-05T10:30:00Z"
}
```
### Data Flow
```
Client Startup
PageLoading.ts: _startPreload()
├─ LevelDataManager.initialize()
│ │
│ └─> HttpUtil.get('/api/v1/wechat-game/levels')
│ │
│ ▼
│ MemeMind-Server
│ │
│ ├─> WechatGameController.getAllLevels()
│ ├─> WechatGameService.getAllLevels()
│ ├─> LevelRepository.findAllOrdered()
│ └─> MySQL: SELECT * FROM levels ORDER BY sort_order
│ Response returned
│ │
│ ▼
│ LevelDataManager._apiData = levels
│ Preload images
├─ Progress: 80% -> 100%
PageHome displayed
User clicks "Start Game"
PageLevel loaded
├─> Reads from _apiData
├─> Displays level image, hints, input
└─> Ready for gameplay
```
---
## Part 2: Missing Features for Full Integration
### 1. User Authentication
**What's needed**:
- WeChat OpenID extraction via `wx.login()`
- Backend user registration/login endpoint
- JWT token generation and validation
- User context in requests
**Implementation Plan**:
**Server Addition**:
```typescript
// src/modules/users/users.module.ts
@Module({
controllers: [UsersController],
providers: [UsersService, UsersRepository],
imports: [TypeOrmModule.forFeature([User])],
})
export class UsersModule {}
// src/modules/users/entities/user.entity.ts
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
wxOpenId: string; // WeChat OpenID
@Column()
nickname: string;
@Column({ default: 10 })
currentLives: number;
@Column({ default: 0 })
currentLevelIndex: number;
@Column({ default: 0 })
totalPoints: number;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
// Endpoint: POST /api/v1/users/login
@Post('login')
async login(@Body() dto: LoginDto): Promise<ApiResponseDto<LoginResponseDto>> {
// dto.code from wx.login()
// Exchange code for wxOpenId via WeChat API
// Create or fetch user
// Generate JWT token
// Return user + token
}
```
**Client Addition**:
```typescript
// In PageLoading.ts or main.ts
async function initializeUser() {
try {
const code = await wx.login();
const response = await HttpUtil.post('/api/v1/users/login', {
code: code.code
});
if (response.success) {
const { user, token } = response.data;
StorageManager.setToken(token); // New: Store JWT
StorageManager.setUserId(user.id); // New: Store user ID
return user;
}
} catch (error) {
console.error('Login failed', error);
}
}
```
---
### 2. Level Submission & Answer Validation
**What's needed**:
- POST endpoint for level submissions
- Answer validation (case-insensitive, trim whitespace)
- Update user progress
- Award points/lives
- Handle wrong answers
**Implementation Plan**:
**Server Addition**:
```typescript
// src/modules/levels/level-submission.module.ts
@Module({
controllers: [LevelSubmissionController],
providers: [LevelSubmissionService],
imports: [
TypeOrmModule.forFeature([UserProgress, Level]),
UsersModule,
WechatGameModule,
],
})
export class LevelSubmissionModule {}
// Endpoint: POST /api/v1/levels/:levelId/submit
@Post(':levelId/submit')
@UseGuards(JwtAuthGuard) // Require authentication
async submitAnswer(
@Param('levelId') levelId: string,
@Body() dto: SubmitAnswerDto,
@Req() request: any,
): Promise<ApiResponseDto<SubmitAnswerResponseDto>> {
// request.user.id from JWT
// Validate answer (case-insensitive, trim)
// If correct:
// - Award 1 life
// - Update currentLevelIndex
// - Record submission
// If wrong:
// - Record wrong attempt
// - Maybe deduct lives?
// Return result
}
// src/modules/levels/dto/submit-answer.dto.ts
export class SubmitAnswerDto {
@IsString()
@IsNotEmpty()
answer: string;
@IsNumber()
timeTaken: number; // Seconds to solve
@IsNumber()
hintsUsed: number; // How many hints revealed
}
export class SubmitAnswerResponseDto {
success: boolean; // Correct answer?
message: string; // "Correct!" or "Wrong!"
newLives: number; // Updated lives
newLevel: number; // Next level index
pointsEarned: number; // Points for this solve
totalPoints: number; // Total accumulated points
}
```
**Client Change**:
```typescript
// In PageLevel.ts: showSuccess() method
private async showSuccess(): void {
this.stopCountdown();
this.playSuccessSound();
// NEW: Submit to server
try {
const token = StorageManager.getToken();
const response = await HttpUtil.post(
`/api/v1/levels/${this._currentLevel.id}/submit`,
{
answer: this._userAnswer,
timeTaken: this._elapsedTime,
hintsUsed: this._hintsUsed
},
{
headers: {
Authorization: `Bearer ${token}`
}
}
);
if (response.success) {
// Update local storage with new progress
StorageManager.setLives(response.data.newLives);
StorageManager.onLevelCompleted(response.data.newLevel - 1);
// Show points earned
this.showPointsNotification(response.data.pointsEarned);
}
} catch (error) {
console.error('Submission failed', error);
}
this._showPassModal();
}
```
---
### 3. User Progress Tracking
**What's needed**:
- Endpoint to get user progress
- Endpoint to update progress
- Sync between client localStorage and server
- Handle offline mode
**Implementation Plan**:
**Server Addition**:
```typescript
// Endpoint: GET /api/v1/users/me/progress
@Get('me/progress')
@UseGuards(JwtAuthGuard)
async getProgress(
@Req() request: any,
): Promise<ApiResponseDto<UserProgressDto>> {
// request.user.id from JWT
// Return user progress
}
export class UserProgressDto {
id: string;
userId: string;
currentLevelIndex: number;
maxLevelUnlocked: number;
totalPoints: number;
currentLives: number;
completedLevels: {
levelId: string;
completedAt: Date;
timeTaken: number;
hintsUsed: number;
}[];
}
// Endpoint: POST /api/v1/users/me/progress/sync
@Post('me/progress/sync')
@UseGuards(JwtAuthGuard)
async syncProgress(
@Req() request: any,
@Body() dto: SyncProgressDto,
): Promise<ApiResponseDto<SyncProgressResponseDto>> {
// Merge client progress with server
// Handle conflicts (prefer latest)
// Return merged progress
}
```
**Client Update**:
```typescript
// In StorageManager.ts: Add sync methods
static async syncWithServer(): Promise<void> {
try {
const token = this.getToken();
const localProgress = this.getCurrentLevelIndex();
const localLives = this.getLives();
const response = await HttpUtil.post(
'/api/v1/users/me/progress/sync',
{
currentLevelIndex: localProgress,
currentLives: localLives,
},
{
headers: { Authorization: `Bearer ${token}` }
}
);
if (response.success) {
// Update local with server's merged data
this.setLives(response.data.currentLives);
// Update progress for each completed level
response.data.completedLevels.forEach((level) => {
this.onLevelCompleted(level.levelIndex);
});
}
} catch (error) {
console.error('Sync failed', error);
// Continue with local data
}
}
// Call on app startup and periodically
```
---
### 4. Hint Usage & Cost Management
**What's needed**:
- Track which hints have been used
- Deduct lives when using premium hints
- Prevent excessive hint usage
**Implementation Plan**:
**Server Addition**:
```typescript
// Track hint usage per level per user
@Entity('level_hints')
export class LevelHint {
@PrimaryGeneratedColumn('uuid')
id: string;
@ManyToOne(() => User)
user: User;
@Column()
levelId: string;
@Column()
hint1Revealed: boolean; // Always free
@Column()
hint2Revealed: boolean; // Costs 1 life
@Column()
hint3Revealed: boolean; // Costs 1 life
@CreateDateColumn()
createdAt: Date;
}
// Endpoint: POST /api/v1/levels/:levelId/reveal-hint
@Post(':levelId/reveal-hint')
@UseGuards(JwtAuthGuard)
async revealHint(
@Param('levelId') levelId: string,
@Body() dto: RevealHintDto, // { hintNumber: 2 }
@Req() request: any,
): Promise<ApiResponseDto<RevealHintResponseDto>> {
// Validate hint number
// Check if already revealed
// If hint 2 or 3: deduct 1 life
// Update hint tracking
// Return hint text
}
```
**Client Change**:
```typescript
// In PageLevel.ts: onUnlockClue() method
private async onUnlockClue(clueIndex: number): void {
const token = StorageManager.getToken();
// Hint index 1 is always free (hint1)
// Hints 2 and 3 cost 1 life each
if (clueIndex > 0 && !this._freeCluePassed) {
const currentLives = StorageManager.getLives();
if (currentLives <= 0) {
this._showToast('没有生命了!(No lives left!)');
return;
}
}
try {
const response = await HttpUtil.post(
`/api/v1/levels/${this._currentLevel.id}/reveal-hint`,
{ hintNumber: clueIndex + 1 },
{ headers: { Authorization: `Bearer ${token}` } }
);
if (response.success) {
const hint = response.data.hintText;
this._showHint(hint);
// Deduct life if premium hint
if (clueIndex > 0) {
StorageManager.consumeLife();
StorageManager.setLives(response.data.remainingLives);
this._updateLivesDisplay();
}
}
} catch (error) {
console.error('Failed to unlock hint', error);
}
}
```
---
### 5. Leaderboard & Statistics
**What's needed**:
- Track total points per user
- Track completion time
- Leaderboard endpoint
- User statistics endpoint
**Implementation Plan**:
**Server Addition**:
```typescript
// Endpoint: GET /api/v1/leaderboard?limit=100
@Get('leaderboard')
async getLeaderboard(
@Query('limit') limit: number = 100,
): Promise<ApiResponseDto<LeaderboardDto>> {
// Select top users by totalPoints
// Include rank, nickname, points, completedLevels
}
// Endpoint: GET /api/v1/users/me/statistics
@Get('me/statistics')
@UseGuards(JwtAuthGuard)
async getStatistics(
@Req() request: any,
): Promise<ApiResponseDto<StatisticsDto>> {
// Return user statistics
// Total levels completed
// Average time per level
// Total hints used
// Current streak, etc.
}
export class StatisticsDto {
totalLevelsCompleted: number;
currentLevelIndex: number;
totalPoints: number;
currentLives: number;
averageTimePerLevel: number; // Seconds
totalTimeSpent: number; // Seconds
totalHintsUsed: number;
perfectSolves: number; // Solved in first try
longestStreak: number;
}
```
---
## Part 3: API Authentication Pattern
### JWT Guard Implementation
**Server**:
```typescript
// src/modules/auth/guards/jwt.guard.ts
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly configService: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException('No token provided');
}
try {
const secret = this.configService.get('JWT_SECRET');
const decoded = verify(token, secret);
request.user = decoded; // Attach to request
return true;
} catch (error) {
throw new UnauthorizedException('Invalid token');
}
}
private extractTokenFromHeader(request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
// Usage on endpoints
@UseGuards(JwtAuthGuard)
@Get('me/progress')
async getProgress(@Req() request: any) {
const userId = request.user.id; // From JWT payload
// ...
}
```
**Client**:
```typescript
// In HttpUtil.ts: Add auth header
static async post<T>(
url: string,
data: any,
options?: RequestOptions
): Promise<ApiResponse<T>> {
const token = StorageManager.getToken();
const headers = {
...options?.headers,
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
// Make request with headers
}
```
---
## Part 4: Data Sync Strategy
### Scenario 1: Online Mode
```
User completes level
[Client] Submits answer
├─ HTTP POST /api/v1/levels/:id/submit
[Server] Validates and updates
├─ Check answer
├─ Award life/points
├─ Update progress
├─ Store submission
[Response] Returned to client
├─ Update localStorage
├─ Show success modal
├─ Move to next level
Progress synced
```
### Scenario 2: Offline Mode
```
User completes level (no connection)
├─ Submit fails (no network)
[Client] Stores locally
├─ StorageManager.recordOfflineSubmission()
├─ Update lives/progress locally
├─ Show success modal (assume correct)
When connection returns
├─ StorageManager.syncPendingSubmissions()
[Server] Receives batch of submissions
├─ Validate all answers
├─ Apply corrections if needed
├─ Return merged state
[Client] Reconciles state
├─ If conflicts: server wins
├─ Update localStorage
├─ Show notification of changes
Progress synced
```
---
## Part 5: Implementation Roadmap
### Phase 1: Basic Auth (Week 1)
- [ ] User entity & table
- [ ] Login endpoint with wx.login() code exchange
- [ ] JWT token generation
- [ ] JwtAuthGuard for protected routes
- [ ] Client login integration
- [ ] Token storage in StorageManager
### Phase 2: Progress Tracking (Week 2)
- [ ] UserProgress entity & table
- [ ] GET /api/v1/users/me/progress
- [ ] POST /api/v1/levels/:id/submit
- [ ] Update client PageLevel.ts to submit answers
- [ ] Sync endpoint for merging progress
- [ ] Offline submission queue
### Phase 3: Hint System Integration (Week 3)
- [ ] LevelHint entity & tracking
- [ ] POST /api/v1/levels/:id/reveal-hint
- [ ] Life deduction logic
- [ ] Client hint unlock cost
### Phase 4: Leaderboard & Stats (Week 4)
- [ ] Statistics calculation
- [ ] Leaderboard endpoint
- [ ] Client leaderboard page
- [ ] Personal statistics page
### Phase 5: Polish & Optimization (Week 5)
- [ ] Caching layer (@nestjs/cache-manager)
- [ ] Rate limiting (@nestjs/throttler)
- [ ] Request logging middleware
- [ ] Performance monitoring
- [ ] Database indexing optimization
---
## Part 6: Environment Configuration
### Server .env.production
```bash
NODE_ENV=production
PORT=3000
# Database
DB_HOST=production-db-host
DB_PORT=3306
DB_USERNAME=prod_user
DB_PASSWORD=secure_password
DB_DATABASE=meme_mind_prod
# Authentication
JWT_SECRET=very-secure-secret-key-32-chars-min
JWT_EXPIRATION=7d
# WeChat
WECHAT_APPID=your_wechat_appid
WECHAT_SECRET=your_wechat_secret
# API
API_BASE_URL=https://ilookai.cn/api
CORS_ORIGIN=https://yourdomain.com
```
### Client Storage Keys
```typescript
// New keys needed:
- 'auth_token' JWT token
- 'user_id' Current user ID
- 'offline_submissions' Queue of submissions to send
- 'last_sync' Timestamp of last sync
```
---
## Part 7: Error Handling
### Common API Errors
```typescript
// 401 Unauthorized
{
success: false,
data: null,
message: 'Invalid token',
path: '/api/v1/users/me/progress'
}
// 404 Not Found
{
success: false,
data: null,
message: 'Level with id "xyz" not found',
path: '/api/v1/levels/xyz'
}
// 400 Bad Request (validation)
{
success: false,
data: null,
message: 'Validation failed: answer must be a string',
path: '/api/v1/levels/123/submit'
}
// 500 Server Error
{
success: false,
data: null,
message: 'Internal server error',
path: '/api/v1/levels'
}
```
### Client Handling
```typescript
async function handleApiError(error: unknown) {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const message = error.response?.data?.message;
if (status === 401) {
// Token expired - redirect to login
StorageManager.clearToken();
navigateTo('PageHome');
} else if (status === 404) {
showToast(`Not found: ${message}`);
} else if (status === 400) {
showToast(`Invalid input: ${message}`);
} else {
showToast(`Error: ${message || 'Unknown error'}`);
}
}
}
```
---
## Summary
The current integration only handles reading level data. To support the full game loop with:
- ✅ User authentication (JWT)
- ✅ Answer submission & validation
- ✅ Progress tracking & sync
- ✅ Hint system with cost management
- ✅ Leaderboard & statistics
You need to implement the endpoints, models, and client logic described in this guide. The roadmap suggests a 5-week implementation with phases for each feature.
---
*Generated: 2026-04-05 | For MemeMind Project*

1191
SERVER_ANALYSIS.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
-- MemeMind Server: Auth 模块数据库迁移
-- 新增 wx_users 表和 wx_user_level_progress 表
-- 执行时间: 2026-04-05
-- ==========================================
-- 1. 创建 wx_users 表
-- ==========================================
CREATE TABLE IF NOT EXISTS `wx_users` (
`id` varchar(36) NOT NULL,
`openid` varchar(128) NOT NULL,
`session_key` varchar(255) DEFAULT NULL,
`nickname` varchar(100) DEFAULT NULL,
`avatar_url` text DEFAULT NULL,
`points` int NOT NULL DEFAULT 10,
`created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
`updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (`id`),
UNIQUE KEY `idx_user_openid` (`openid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ==========================================
-- 2. 创建 wx_user_level_progress 表
-- ==========================================
CREATE TABLE IF NOT EXISTS `wx_user_level_progress` (
`id` varchar(36) NOT NULL,
`user_id` varchar(191) NOT NULL,
`level_id` varchar(191) NOT NULL,
`completed_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (`id`),
UNIQUE KEY `idx_user_level` (`user_id`, `level_id`),
KEY `FK_wx_ulp_user` (`user_id`),
CONSTRAINT `FK_wx_ulp_user` FOREIGN KEY (`user_id`) REFERENCES `wx_users` (`id`) ON DELETE CASCADE ON UPDATE NO ACTION
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -24,9 +24,11 @@
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3", "@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.2.6", "@nestjs/swagger": "^11.2.6",
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"axios": "^1.14.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"mysql2": "^3.19.1", "mysql2": "^3.19.1",

147
pnpm-lock.yaml generated
View File

@@ -17,6 +17,9 @@ importers:
'@nestjs/core': '@nestjs/core':
specifier: ^11.0.1 specifier: ^11.0.1
version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/jwt':
specifier: ^11.0.2
version: 11.0.2(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))
'@nestjs/platform-express': '@nestjs/platform-express':
specifier: ^11.0.1 specifier: ^11.0.1
version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
@@ -26,6 +29,9 @@ importers:
'@nestjs/typeorm': '@nestjs/typeorm':
specifier: ^11.0.0 specifier: ^11.0.0
version: 11.0.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.28(mysql2@3.19.1(@types/node@22.19.15))(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.9.3))) version: 11.0.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.28(mysql2@3.19.1(@types/node@22.19.15))(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.9.3)))
axios:
specifier: ^1.14.0
version: 1.14.0
class-transformer: class-transformer:
specifier: ^0.5.1 specifier: ^0.5.1
version: 0.5.1 version: 0.5.1
@@ -706,6 +712,11 @@ packages:
'@nestjs/websockets': '@nestjs/websockets':
optional: true optional: true
'@nestjs/jwt@11.0.2':
resolution: {integrity: sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==}
peerDependencies:
'@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
'@nestjs/mapped-types@2.1.0': '@nestjs/mapped-types@2.1.0':
resolution: {integrity: sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==} resolution: {integrity: sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==}
peerDependencies: peerDependencies:
@@ -880,9 +891,15 @@ packages:
'@types/json-schema@7.0.15': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/jsonwebtoken@9.0.10':
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
'@types/methods@1.1.4': '@types/methods@1.1.4':
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node@22.19.15': '@types/node@22.19.15':
resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
@@ -1017,49 +1034,41 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1': '@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1': '@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1': '@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1': '@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1': '@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1': '@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -1260,6 +1269,9 @@ packages:
resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}
engines: {node: '>= 6.0.0'} engines: {node: '>= 6.0.0'}
axios@1.14.0:
resolution: {integrity: sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==}
babel-jest@30.3.0: babel-jest@30.3.0:
resolution: {integrity: sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==} resolution: {integrity: sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -1333,6 +1345,9 @@ packages:
bser@2.1.1: bser@2.1.1:
resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==}
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
buffer-from@1.1.2: buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@@ -1597,6 +1612,9 @@ packages:
eastasianwidth@0.2.0: eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
ee-first@1.1.1: ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@@ -1822,6 +1840,15 @@ packages:
flatted@3.4.1: flatted@3.4.1:
resolution: {integrity: sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==} resolution: {integrity: sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==}
follow-redirects@1.15.11:
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
for-each@0.3.5: for-each@0.3.5:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2263,6 +2290,16 @@ packages:
jsonfile@6.2.0: jsonfile@6.2.0:
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
jsonwebtoken@9.0.3:
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
engines: {node: '>=12', npm: '>=6'}
jwa@2.0.1:
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
keyv@4.5.4: keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -2296,12 +2333,33 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} engines: {node: '>=10'}
lodash.includes@4.3.0:
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
lodash.isboolean@3.0.3:
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
lodash.isinteger@4.0.4:
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
lodash.isnumber@3.0.3:
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
lodash.isstring@4.0.1:
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
lodash.memoize@4.1.2: lodash.memoize@4.1.2:
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
lodash.merge@4.6.2: lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
lodash@4.17.23: lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
@@ -2610,6 +2668,10 @@ packages:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
punycode@2.3.1: punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -3995,6 +4057,12 @@ snapshots:
optionalDependencies: optionalDependencies:
'@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) '@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
'@nestjs/jwt@11.0.2(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))':
dependencies:
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@types/jsonwebtoken': 9.0.10
jsonwebtoken: 9.0.3
'@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)': '@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)':
dependencies: dependencies:
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -4184,8 +4252,15 @@ snapshots:
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@types/jsonwebtoken@9.0.10':
dependencies:
'@types/ms': 2.1.0
'@types/node': 22.19.15
'@types/methods@1.1.4': {} '@types/methods@1.1.4': {}
'@types/ms@2.1.0': {}
'@types/node@22.19.15': '@types/node@22.19.15':
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
@@ -4563,6 +4638,14 @@ snapshots:
aws-ssl-profiles@1.1.2: {} aws-ssl-profiles@1.1.2: {}
axios@1.14.0:
dependencies:
follow-redirects: 1.15.11
form-data: 4.0.5
proxy-from-env: 2.1.0
transitivePeerDependencies:
- debug
babel-jest@30.3.0(@babel/core@7.29.0): babel-jest@30.3.0(@babel/core@7.29.0):
dependencies: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
@@ -4676,6 +4759,8 @@ snapshots:
dependencies: dependencies:
node-int64: 0.4.0 node-int64: 0.4.0
buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {} buffer-from@1.1.2: {}
buffer@5.7.1: buffer@5.7.1:
@@ -4894,6 +4979,10 @@ snapshots:
eastasianwidth@0.2.0: {} eastasianwidth@0.2.0: {}
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
ee-first@1.1.1: {} ee-first@1.1.1: {}
electron-to-chromium@1.5.313: {} electron-to-chromium@1.5.313: {}
@@ -5156,6 +5245,8 @@ snapshots:
flatted@3.4.1: {} flatted@3.4.1: {}
follow-redirects@1.15.11: {}
for-each@0.3.5: for-each@0.3.5:
dependencies: dependencies:
is-callable: 1.2.7 is-callable: 1.2.7
@@ -5774,6 +5865,30 @@ snapshots:
optionalDependencies: optionalDependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
jsonwebtoken@9.0.3:
dependencies:
jws: 4.0.1
lodash.includes: 4.3.0
lodash.isboolean: 3.0.3
lodash.isinteger: 4.0.4
lodash.isnumber: 3.0.3
lodash.isplainobject: 4.0.6
lodash.isstring: 4.0.1
lodash.once: 4.1.1
ms: 2.1.3
semver: 7.7.4
jwa@2.0.1:
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
jws@4.0.1:
dependencies:
jwa: 2.0.1
safe-buffer: 5.2.1
keyv@4.5.4: keyv@4.5.4:
dependencies: dependencies:
json-buffer: 3.0.1 json-buffer: 3.0.1
@@ -5801,10 +5916,24 @@ snapshots:
dependencies: dependencies:
p-locate: 5.0.0 p-locate: 5.0.0
lodash.includes@4.3.0: {}
lodash.isboolean@3.0.3: {}
lodash.isinteger@4.0.4: {}
lodash.isnumber@3.0.3: {}
lodash.isplainobject@4.0.6: {}
lodash.isstring@4.0.1: {}
lodash.memoize@4.1.2: {} lodash.memoize@4.1.2: {}
lodash.merge@4.6.2: {} lodash.merge@4.6.2: {}
lodash.once@4.1.1: {}
lodash@4.17.23: {} lodash@4.17.23: {}
log-symbols@4.1.0: log-symbols@4.1.0:
@@ -6069,6 +6198,8 @@ snapshots:
forwarded: 0.2.0 forwarded: 0.2.0
ipaddr.js: 1.9.1 ipaddr.js: 1.9.1
proxy-from-env@2.1.0: {}
punycode@2.3.1: {} punycode@2.3.1: {}
pure-rand@7.0.1: {} pure-rand@7.0.1: {}

View File

@@ -3,6 +3,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AppConfigModule } from './config/config.module'; import { AppConfigModule } from './config/config.module';
import { WechatGameModule } from './modules/wechat-game/wechat-game.module'; import { WechatGameModule } from './modules/wechat-game/wechat-game.module';
import { AuthModule } from './modules/auth/auth.module';
@Module({ @Module({
imports: [ imports: [
@@ -24,6 +25,7 @@ import { WechatGameModule } from './modules/wechat-game/wechat-game.module';
}), }),
}), }),
WechatGameModule, WechatGameModule,
AuthModule,
], ],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -0,0 +1,13 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { JwtPayload } from '../guards/jwt-auth.guard';
/**
* 从请求中提取当前登录用户信息的装饰器
* 使用方式: @CurrentUser() user: JwtPayload
*/
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): JwtPayload => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);

View File

@@ -0,0 +1,45 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
export interface JwtPayload {
sub: string; // user id
openid: string;
}
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const token = this.extractToken(request);
if (!token) {
throw new UnauthorizedException('未提供访问令牌');
}
try {
const payload = await this.jwtService.verifyAsync<JwtPayload>(token);
// 将用户信息挂载到 request 上
(request as any).user = payload;
} catch {
throw new UnauthorizedException('访问令牌无效或已过期');
}
return true;
}
private extractToken(request: Request): string | null {
const authorization = request.headers.authorization;
if (!authorization) return null;
const [type, token] = authorization.split(' ');
return type === 'Bearer' ? token : null;
}
}

View File

@@ -33,6 +33,15 @@ class EnvironmentVariables {
@IsString() @IsString()
DB_DATABASE: string = 'meme_mind'; DB_DATABASE: string = 'meme_mind';
@IsString()
WX_APPID: string = '';
@IsString()
WX_SECRET: string = '';
@IsString()
JWT_SECRET: string = 'default_jwt_secret_change_me';
} }
export function validateEnvironment( export function validateEnvironment(

View File

@@ -33,6 +33,7 @@ async function bootstrap() {
.setTitle('MemeMind Server API') .setTitle('MemeMind Server API')
.setDescription('微信小游戏 MemeMind 服务端 API 文档') .setDescription('微信小游戏 MemeMind 服务端 API 文档')
.setVersion('1.0') .setVersion('1.0')
.addBearerAuth()
.build(); .build();
const document = SwaggerModule.createDocument(app, config); const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document); SwaggerModule.setup('api/docs', app, document);

View File

@@ -0,0 +1,110 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { WxLoginRequestDto, WxLoginResponseDto } from './dto/wx-login.dto';
import {
ConsumePointRequestDto,
EarnPointRequestDto,
GameDataResponseDto,
UserAssetsResponseDto,
} from './dto/user-assets.dto';
import { ApiResponseDto } from '../../common/dto/api-response.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import type { JwtPayload } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@ApiTags('用户认证与资产')
@Controller('v1')
export class AuthController {
constructor(private readonly authService: AuthService) {}
// ==================== 公开接口 ====================
@Post('auth/wx-login')
@ApiOperation({
summary: '微信登录',
description: '使用微信 wx.login 返回的 code 换取 JWT 令牌',
})
@ApiResponse({ status: 200, description: '登录成功' })
@ApiResponse({ status: 401, description: '微信登录失败' })
async wxLogin(
@Body() dto: WxLoginRequestDto,
): Promise<ApiResponseDto<WxLoginResponseDto>> {
const data = await this.authService.wxLogin(dto.code);
return ApiResponseDto.success(data);
}
// ==================== 需要鉴权的接口 ====================
@Get('user/assets')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: '获取用户积分',
description: '获取当前登录用户的积分信息',
})
@ApiResponse({ status: 200, description: '成功' })
@ApiResponse({ status: 401, description: '未授权' })
async getUserAssets(
@CurrentUser() user: JwtPayload,
): Promise<ApiResponseDto<UserAssetsResponseDto>> {
const data = await this.authService.getUserAssets(user.sub);
return ApiResponseDto.success(data);
}
@Post('user/assets/consume')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: '消耗积分',
description: '消耗 1 积分(用于解锁提示)',
})
@ApiResponse({ status: 200, description: '消耗成功' })
@ApiResponse({ status: 400, description: '积分不足' })
@ApiResponse({ status: 401, description: '未授权' })
async consumePoint(
@CurrentUser() user: JwtPayload,
@Body() dto: ConsumePointRequestDto,
): Promise<ApiResponseDto<UserAssetsResponseDto>> {
const data = await this.authService.consumePoint(user.sub, dto);
return ApiResponseDto.success(data);
}
@Post('user/assets/earn')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: '获得积分',
description: '通关获得 1 积分(同一关卡不重复奖励)',
})
@ApiResponse({ status: 200, description: '获得成功' })
@ApiResponse({ status: 401, description: '未授权' })
async earnPoint(
@CurrentUser() user: JwtPayload,
@Body() dto: EarnPointRequestDto,
): Promise<ApiResponseDto<UserAssetsResponseDto>> {
const data = await this.authService.earnPoint(user.sub, dto);
return ApiResponseDto.success(data);
}
@Get('user/game-data')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: '获取游戏数据',
description: '获取用户积分和通关进度Loading 页面使用)',
})
@ApiResponse({ status: 200, description: '成功' })
@ApiResponse({ status: 401, description: '未授权' })
async getGameData(
@CurrentUser() user: JwtPayload,
): Promise<ApiResponseDto<GameDataResponseDto>> {
const data = await this.authService.getGameData(user.sub);
return ApiResponseDto.success(data);
}
}

View File

@@ -0,0 +1,28 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { User } from './entities/user.entity';
import { UserLevelProgress } from './entities/user-level-progress.entity';
import { UserRepository } from './repositories/user.repository';
import { UserLevelProgressRepository } from './repositories/user-level-progress.repository';
@Module({
imports: [
TypeOrmModule.forFeature([User, UserLevelProgress]),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
signOptions: { expiresIn: '7d' },
}),
}),
],
controllers: [AuthController],
providers: [AuthService, UserRepository, UserLevelProgressRepository],
exports: [JwtModule, AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,215 @@
import {
Injectable,
Logger,
UnauthorizedException,
BadRequestException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import axios from 'axios';
import { UserRepository } from './repositories/user.repository';
import { UserLevelProgressRepository } from './repositories/user-level-progress.repository';
import { WxLoginResponseDto, UserInfoDto } from './dto/wx-login.dto';
import {
UserAssetsResponseDto,
ConsumePointRequestDto,
EarnPointRequestDto,
GameDataResponseDto,
} from './dto/user-assets.dto';
import { JwtPayload } from '../../common/guards/jwt-auth.guard';
interface WxSessionResponse {
openid?: string;
session_key?: string;
errcode?: number;
errmsg?: string;
}
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
private readonly wxAppId: string;
private readonly wxSecret: string;
constructor(
private readonly configService: ConfigService,
private readonly jwtService: JwtService,
private readonly userRepository: UserRepository,
private readonly userLevelProgressRepository: UserLevelProgressRepository,
) {
this.wxAppId = this.configService.get<string>('WX_APPID', '');
this.wxSecret = this.configService.get<string>('WX_SECRET', '');
}
/**
* 微信登录code 换取 openid创建或查找用户签发 JWT
*/
async wxLogin(code: string): Promise<WxLoginResponseDto> {
// 1. 调用微信接口换取 openid
const wxSession = await this.getWxSession(code);
if (!wxSession.openid) {
this.logger.error(
`微信登录失败: errcode=${wxSession.errcode}, errmsg=${wxSession.errmsg}`,
);
throw new UnauthorizedException('微信登录失败,请重试');
}
// 2. 查找或创建用户
let user = await this.userRepository.findByOpenid(wxSession.openid);
if (!user) {
user = this.userRepository.create({
openid: wxSession.openid,
sessionKey: wxSession.session_key ?? null,
points: 10, // 新用户默认 10 积分
});
user = await this.userRepository.save(user);
this.logger.log(`新用户注册: ${user.id}`);
} else {
// 更新 session_key
if (wxSession.session_key) {
user.sessionKey = wxSession.session_key;
user = await this.userRepository.save(user);
}
}
// 3. 签发 JWT
const payload: JwtPayload = {
sub: user.id,
openid: user.openid,
};
const token = await this.jwtService.signAsync(payload);
// 4. 构造响应
const userInfo: UserInfoDto = {
id: user.id,
nickname: user.nickname,
points: user.points,
};
return { token, user: userInfo };
}
/**
* 获取用户积分
*/
async getUserAssets(userId: string): Promise<UserAssetsResponseDto> {
const user = await this.findUserOrThrow(userId);
return { points: user.points };
}
/**
* 消耗积分(解锁提示)
*/
async consumePoint(
userId: string,
dto: ConsumePointRequestDto,
): Promise<UserAssetsResponseDto> {
const user = await this.findUserOrThrow(userId);
if (user.points <= 0) {
throw new BadRequestException('积分不足,无法消耗');
}
user.points -= 1;
await this.userRepository.save(user);
this.logger.log(
`用户 ${userId} 消耗 1 积分(${dto.reason}),剩余: ${user.points}`,
);
return { points: user.points };
}
/**
* 获得积分(通关奖励)
*/
async earnPoint(
userId: string,
dto: EarnPointRequestDto,
): Promise<UserAssetsResponseDto> {
const user = await this.findUserOrThrow(userId);
// 检查是否已经领取过该关卡的通关奖励(防重复)
const existing = await this.userLevelProgressRepository.findByUserAndLevel(
userId,
dto.levelId,
);
if (existing) {
this.logger.warn(
`用户 ${userId} 已完成关卡 ${dto.levelId},不重复奖励`,
);
return { points: user.points };
}
// 记录通关进度
const progress = this.userLevelProgressRepository.create({
userId,
levelId: dto.levelId,
});
await this.userLevelProgressRepository.save(progress);
// 增加积分
user.points += 1;
await this.userRepository.save(user);
this.logger.log(
`用户 ${userId} 通关 ${dto.levelId},获得 1 积分,当前: ${user.points}`,
);
return { points: user.points };
}
/**
* 获取用户游戏数据Loading 页面复合接口)
*/
async getGameData(userId: string): Promise<GameDataResponseDto> {
const [user, progressList] = await Promise.all([
this.findUserOrThrow(userId),
this.userLevelProgressRepository.findByUserId(userId),
]);
const completedLevelIds = progressList.map((p) => p.levelId);
return {
user: {
id: user.id,
points: user.points,
},
completedLevelIds,
};
}
/**
* 调用微信 jscode2session 接口
*/
private async getWxSession(code: string): Promise<WxSessionResponse> {
const url = 'https://api.weixin.qq.com/sns/jscode2session';
const params = {
appid: this.wxAppId,
secret: this.wxSecret,
js_code: code,
grant_type: 'authorization_code',
};
try {
const response = await axios.get<WxSessionResponse>(url, { params });
return response.data;
} catch (error) {
this.logger.error('调用微信 jscode2session 失败:', error);
throw new UnauthorizedException('微信服务调用失败,请重试');
}
}
/**
* 查找用户,不存在则抛异常
*/
private async findUserOrThrow(userId: string) {
const user = await this.userRepository.findById(userId);
if (!user) {
throw new UnauthorizedException('用户不存在');
}
return user;
}
}

View File

@@ -0,0 +1,48 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class UserAssetsResponseDto {
@ApiProperty({ description: '积分' })
points: number;
}
export class ConsumePointRequestDto {
@ApiProperty({ description: '消耗原因', enum: ['hint_unlock'] })
@IsString()
@IsNotEmpty()
@IsIn(['hint_unlock'])
reason: 'hint_unlock';
@ApiProperty({ description: '关卡 ID', required: false })
@IsString()
@IsOptional()
levelId?: string;
@ApiProperty({ description: '提示索引2 或 3', required: false })
@IsOptional()
hintIndex?: number;
}
export class EarnPointRequestDto {
@ApiProperty({ description: '获取原因', enum: ['level_complete'] })
@IsString()
@IsNotEmpty()
@IsIn(['level_complete'])
reason: 'level_complete';
@ApiProperty({ description: '关卡 ID' })
@IsString()
@IsNotEmpty()
levelId: string;
}
export class GameDataResponseDto {
@ApiProperty({ description: '用户信息' })
user: {
id: string;
points: number;
};
@ApiProperty({ description: '已完成的关卡 ID 列表' })
completedLevelIds: string[];
}

View File

@@ -0,0 +1,28 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class WxLoginRequestDto {
@ApiProperty({ description: '微信 wx.login 返回的 code' })
@IsString()
@IsNotEmpty()
code: string;
}
export class UserInfoDto {
@ApiProperty({ description: '用户 ID' })
id: string;
@ApiProperty({ description: '用户昵称', nullable: true })
nickname: string | null;
@ApiProperty({ description: '积分' })
points: number;
}
export class WxLoginResponseDto {
@ApiProperty({ description: 'JWT 访问令牌' })
token: string;
@ApiProperty({ description: '用户信息' })
user: UserInfoDto;
}

View File

@@ -0,0 +1,30 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from './user.entity';
@Entity('wx_user_level_progress')
@Index('idx_user_level', ['userId', 'levelId'], { unique: true })
export class UserLevelProgress {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 191, name: 'user_id' })
userId: string;
@Column({ type: 'varchar', length: 191, name: 'level_id' })
levelId: string;
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user: User;
@CreateDateColumn({ name: 'completed_at' })
completedAt: Date;
}

View File

@@ -0,0 +1,37 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
@Entity('wx_users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index('idx_user_openid', { unique: true })
@Column({ type: 'varchar', length: 128 })
openid: string;
@Column({ type: 'varchar', length: 255, name: 'session_key', nullable: true })
sessionKey: string | null;
@Column({ type: 'varchar', length: 100, nullable: true })
nickname: string | null;
@Column({ type: 'text', name: 'avatar_url', nullable: true })
avatarUrl: string | null;
/** 积分(默认 10 */
@Column({ type: 'int', default: 10 })
points: number;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@@ -0,0 +1,11 @@
import { UserLevelProgress } from '../entities/user-level-progress.entity';
export interface IUserLevelProgressRepository {
findByUserId(userId: string): Promise<UserLevelProgress[]>;
findByUserAndLevel(
userId: string,
levelId: string,
): Promise<UserLevelProgress | null>;
create(data: Partial<UserLevelProgress>): UserLevelProgress;
save(progress: UserLevelProgress): Promise<UserLevelProgress>;
}

View File

@@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserLevelProgress } from '../entities/user-level-progress.entity';
import { IUserLevelProgressRepository } from './user-level-progress.repository.interface';
@Injectable()
export class UserLevelProgressRepository
implements IUserLevelProgressRepository
{
constructor(
@InjectRepository(UserLevelProgress)
private readonly repository: Repository<UserLevelProgress>,
) {}
async findByUserId(userId: string): Promise<UserLevelProgress[]> {
return this.repository.find({ where: { userId } });
}
async findByUserAndLevel(
userId: string,
levelId: string,
): Promise<UserLevelProgress | null> {
return this.repository.findOne({ where: { userId, levelId } });
}
create(data: Partial<UserLevelProgress>): UserLevelProgress {
return this.repository.create(data);
}
async save(progress: UserLevelProgress): Promise<UserLevelProgress> {
return this.repository.save(progress);
}
}

View File

@@ -0,0 +1,8 @@
import { User } from '../entities/user.entity';
export interface IUserRepository {
findById(id: string): Promise<User | null>;
findByOpenid(openid: string): Promise<User | null>;
create(data: Partial<User>): User;
save(user: User): Promise<User>;
}

View File

@@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../entities/user.entity';
import { IUserRepository } from './user.repository.interface';
@Injectable()
export class UserRepository implements IUserRepository {
constructor(
@InjectRepository(User)
private readonly repository: Repository<User>,
) {}
async findById(id: string): Promise<User | null> {
return this.repository.findOne({ where: { id } });
}
async findByOpenid(openid: string): Promise<User | null> {
return this.repository.findOne({ where: { openid } });
}
create(data: Partial<User>): User {
return this.repository.create(data);
}
async save(user: User): Promise<User> {
return this.repository.save(user);
}
}