feat: 支持新的关卡数据结构

This commit is contained in:
richarjiang
2026-04-19 13:27:10 +08:00
parent 1d6cd0cdc0
commit e6079e4345
33 changed files with 882 additions and 2843 deletions

6
.claude/settings.json Normal file
View File

@@ -0,0 +1,6 @@
{
"enabledPlugins": {
"code-review@claude-plugins-official": true,
"claude-md-management@claude-plugins-official": true
}
}

View File

@@ -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*

266
CLAUDE.md Normal file
View File

@@ -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<ApiResponseDto<ResponseDto>>
@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<ResponseDto> { ... }
}
// repositories/: Custom data access logic (extend TypeORM repositories)
@Injectable()
export class CustomRepository extends Repository<Entity> {
async customQuery(): Promise<Entity[]> { ... }
}
// 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<T>`:
```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 <PID>`, 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

View File

@@ -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<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*

File diff suppressed because it is too large Load Diff

View File

@@ -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 需要适配 050 的显示范围
- 恢复速率不变,满体力恢复时间从 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 适配 050 范围(进度条、数字显示等)
- [ ] 更新 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` | 数值变更 |

View File

@@ -91,8 +91,8 @@ Authorization: Bearer <token>
| 属性 | 值 | | 属性 | 值 |
|------|-----| |------|-----|
| 默认体力 | 5新用户注册时 | | 默认体力 | 50(新用户注册时) |
| 上限 | 5 | | 上限 | 50 |
| 恢复速度 | 每 **10 分钟** 恢复 1 点 | | 恢复速度 | 每 **10 分钟** 恢复 1 点 |
| 消耗 | 进入**未通关**关卡时消耗 1 点 | | 消耗 | 进入**未通关**关卡时消耗 1 点 |
| 已通关关卡 | 再次进入不消耗体力 | | 已通关关卡 | 再次进入不消耗体力 |
@@ -104,7 +104,7 @@ Authorization: Bearer <token>
```typescript ```typescript
interface StaminaInfo { interface StaminaInfo {
current: number; // 当前体力值(已计算恢复) current: number; // 当前体力值(已计算恢复)
max: number; // 体力上限,固定为 5 max: number; // 体力上限,固定为 50
nextRecoverAt: string | null; // 下一点体力恢复的时间ISO 8601满体力时为 null nextRecoverAt: string | null; // 下一点体力恢复的时间ISO 8601满体力时为 null
} }
``` ```
@@ -113,8 +113,8 @@ interface StaminaInfo {
```json ```json
{ {
"current": 3, "current": 45,
"max": 5, "max": 50,
"nextRecoverAt": "2026-04-10T12:10:00.000Z" "nextRecoverAt": "2026-04-10T12:10:00.000Z"
} }
``` ```
@@ -168,7 +168,7 @@ interface StaminaInfo {
"user": { "user": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"nickname": null, "nickname": null,
"stamina": 5 "stamina": 50
} }
}, },
"message": null, "message": null,
@@ -211,8 +211,8 @@ interface StaminaInfo {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"nickname": null, "nickname": null,
"stamina": { "stamina": {
"current": 3, "current": 45,
"max": 5, "max": 50,
"nextRecoverAt": "2026-04-10T12:10:00.000Z" "nextRecoverAt": "2026-04-10T12:10:00.000Z"
} }
}, },
@@ -258,8 +258,8 @@ interface StaminaInfo {
"user": { "user": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"stamina": { "stamina": {
"current": 5, "current": 50,
"max": 5, "max": 50,
"nextRecoverAt": null "nextRecoverAt": null
} }
}, },
@@ -278,7 +278,7 @@ interface StaminaInfo {
### 4. 获取关卡列表 ### 4. 获取关卡列表
获取所有关卡列表。**已通关的关卡**返回答案和线索,**未通关的关卡**不返回敏感数据。 获取所有关卡列表。**已通关的关卡**返回答案、谐音梗说明和线索,**未通关的关卡**不返回敏感数据。
**接口地址**`GET /api/v1/levels` **接口地址**`GET /api/v1/levels`
@@ -297,8 +297,12 @@ interface StaminaInfo {
interface LevelListItem { interface LevelListItem {
id: string; // 关卡 ID id: string; // 关卡 ID
level: number; // 关卡编号(从 1 开始) 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 answer: string | null; // 答案(仅已通关时返回,否则 null
punchline: string | null; // 谐音梗说明(仅已通关时返回,否则 null
hint1: string | null; // 线索1仅已通关时返回否则 null hint1: string | null; // 线索1仅已通关时返回否则 null
hint2: string | null; // 线索2仅已通关时返回否则 null hint2: string | null; // 线索2仅已通关时返回否则 null
hint3: string | null; // 线索3仅已通关时返回否则 null hint3: string | null; // 线索3仅已通关时返回否则 null
@@ -317,8 +321,12 @@ interface LevelListItem {
{ {
"id": "level_001", "id": "level_001",
"level": 1, "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": "梗答案", "answer": "梗答案",
"punchline": "谐音梗:鱼和猫的故事",
"hint1": "这是一个经典的...", "hint1": "这是一个经典的...",
"hint2": "和某个明星有关", "hint2": "和某个明星有关",
"hint3": null, "hint3": null,
@@ -328,8 +336,12 @@ interface LevelListItem {
{ {
"id": "level_002", "id": "level_002",
"level": 2, "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, "answer": null,
"punchline": null,
"hint1": null, "hint1": null,
"hint2": null, "hint2": null,
"hint3": null, "hint3": null,
@@ -346,8 +358,9 @@ interface LevelListItem {
**客户端使用说明** **客户端使用说明**
- 关卡选择页面使用此接口获取关卡列表 - 关卡选择页面使用此接口获取关卡列表
- 每个关卡有两张图片(`image1Url``image2Url`)和对应的文本说明
- 根据 `completed` 字段展示不同的 UI 状态(已通关/未通关) - 根据 `completed` 字段展示不同的 UI 状态(已通关/未通关)
- 未通关关卡的 `answer``hint1``hint2``hint3` 均为 `null`**客户端不应缓存这些字段** - 未通关关卡的 `answer``punchline``hint1``hint2``hint3` 均为 `null`**客户端不应缓存这些字段**
--- ---
@@ -373,8 +386,12 @@ interface LevelListItem {
{ {
id: string; id: string;
level: number; level: number;
imageUrl: string; image1Url: string;
image1Description: string | null;
image2Url: string;
image2Description: string | null;
answer: string; answer: string;
punchline: string | null;
hint1: string | null; hint1: string | null;
hint2: string | null; hint2: string | null;
hint3: string | null; hint3: string | null;
@@ -390,14 +407,18 @@ interface LevelListItem {
"data": { "data": {
"id": "level_002", "id": "level_002",
"level": 2, "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": "这是答案", "answer": "这是答案",
"punchline": "谐音梗:走和跑的故事",
"hint1": "第一个线索", "hint1": "第一个线索",
"hint2": "第二个线索", "hint2": "第二个线索",
"hint3": null, "hint3": null,
"stamina": { "stamina": {
"current": 2, "current": 47,
"max": 5, "max": 50,
"nextRecoverAt": "2026-04-10T12:10:00.000Z" "nextRecoverAt": "2026-04-10T12:10:00.000Z"
} }
}, },
@@ -658,7 +679,9 @@ interface GameState {
currentLevel: { // 当前正在玩的关卡 currentLevel: { // 当前正在玩的关卡
id: string; id: string;
answer: string; answer: string;
punchline: string | null;
hints: (string | null)[]; hints: (string | null)[];
images: { url: string; description: string | null }[];
startTime: number; // 开始时间戳,用于计算 timeSpent startTime: number; // 开始时间戳,用于计算 timeSpent
} | null; } | null;
} }
@@ -823,8 +846,12 @@ async function loadGameData(): Promise<GameData> {
interface LevelListItem { interface LevelListItem {
id: string; id: string;
level: number; level: number;
imageUrl: string; image1Url: string;
image1Description: string | null;
image2Url: string;
image2Description: string | null;
answer: string | null; answer: string | null;
punchline: string | null;
hint1: string | null; hint1: string | null;
hint2: string | null; hint2: string | null;
hint3: string | null; hint3: string | null;
@@ -847,8 +874,12 @@ async function getLevels(): Promise<LevelListItem[]> {
interface EnterLevelResponse { interface EnterLevelResponse {
id: string; id: string;
level: number; level: number;
imageUrl: string; image1Url: string;
image1Description: string | null;
image2Url: string;
image2Description: string | null;
answer: string; answer: string;
punchline: string | null;
hint1: string | null; hint1: string | null;
hint2: string | null; hint2: string | null;
hint3: string | null; hint3: string | null;
@@ -969,4 +1000,6 @@ export class GameEntry extends Component {
4. **已通关关卡免费进入**:已通关关卡再次进入不消耗体力 4. **已通关关卡免费进入**:已通关关卡再次进入不消耗体力
5. **通关上报仅限成功**:只在用户答对后调用 `complete` 接口,答错不需要上报 5. **通关上报仅限成功**:只在用户答对后调用 `complete` 接口,答错不需要上报
6. **hint 字段**`hint1/hint2/hint3` 可能为 `null`,表示该线索未配置 6. **hint 字段**`hint1/hint2/hint3` 可能为 `null`,表示该线索未配置
7. **网络异常处理**:建议所有接口调用加 loading 状态,并处理 401重新登录和网络错误 7. **punchline 字段**:谐音梗说明,仅已通关时返回,未通关时为 `null`
8. **双图结构**:每个关卡有两张图片(`image1Url``image2Url`),分别有对应的文本说明
9. **网络异常处理**:建议所有接口调用加 loading 状态,并处理 401重新登录和网络错误

View File

@@ -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;

View File

@@ -13,9 +13,7 @@ describe('HttpExceptionFilter', () => {
const mockJson = jest.fn(); const mockJson = jest.fn();
const mockStatus = jest.fn().mockReturnValue({ json: mockJson }); const mockStatus = jest.fn().mockReturnValue({ json: mockJson });
const mockGetResponse = jest.fn().mockReturnValue({ status: mockStatus }); const mockGetResponse = jest.fn().mockReturnValue({ status: mockStatus });
const mockGetRequest = jest const mockGetRequest = jest.fn().mockReturnValue({ url: '/api/v1/test' });
.fn()
.mockReturnValue({ url: '/api/v1/test' });
const mockHost: ArgumentsHost = { const mockHost: ArgumentsHost = {
switchToHttp: () => ({ switchToHttp: () => ({

View File

@@ -45,7 +45,7 @@ describe('JwtAuthGuard', () => {
mockJwtService.verifyAsync.mockResolvedValue(payload); mockJwtService.verifyAsync.mockResolvedValue(payload);
const context = createMockContext('Bearer valid-token'); const context = createMockContext('Bearer valid-token');
const request = context.switchToHttp().getRequest() as Record<string, unknown>; const request = context.switchToHttp().getRequest();
await guard.canActivate(context); await guard.canActivate(context);
expect(request.user).toEqual(payload); expect(request.user).toEqual(payload);

View File

@@ -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;

View File

@@ -3,6 +3,7 @@ import { JwtService } from '@nestjs/jwt';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { ApiResponseDto } from '../../common/dto/api-response.dto'; import { ApiResponseDto } from '../../common/dto/api-response.dto';
import { MAX_STAMINA } from '../../common/constants/game.constants';
describe('AuthController', () => { describe('AuthController', () => {
let controller: AuthController; let controller: AuthController;
@@ -31,7 +32,7 @@ describe('AuthController', () => {
it('should return success response with token and user info', async () => { it('should return success response with token and user info', async () => {
const loginResponse = { const loginResponse = {
token: 'jwt-token', 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); mockAuthService.wxLogin.mockResolvedValue(loginResponse);

View File

@@ -1,9 +1,5 @@
import { Body, Controller, Post } from '@nestjs/common'; import { Body, Controller, Post } from '@nestjs/common';
import { import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { WxLoginRequestDto, WxLoginResponseDto } from './dto/wx-login.dto'; import { WxLoginRequestDto, WxLoginResponseDto } from './dto/wx-login.dto';
import { ApiResponseDto } from '../../common/dto/api-response.dto'; import { ApiResponseDto } from '../../common/dto/api-response.dto';

View File

@@ -23,6 +23,11 @@ import { UserLevelProgressRepository } from './repositories/user-level-progress.
], ],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService, UserRepository, UserLevelProgressRepository], providers: [AuthService, UserRepository, UserLevelProgressRepository],
exports: [JwtModule, AuthService, UserRepository, UserLevelProgressRepository], exports: [
JwtModule,
AuthService,
UserRepository,
UserLevelProgressRepository,
],
}) })
export class AuthModule {} export class AuthModule {}

View File

@@ -6,6 +6,7 @@ import axios from 'axios';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { UserRepository } from './repositories/user.repository'; import { UserRepository } from './repositories/user.repository';
import { User } from './entities/user.entity'; import { User } from './entities/user.entity';
import { MAX_STAMINA } from '../../common/constants/game.constants';
jest.mock('axios'); jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>; const mockedAxios = axios as jest.Mocked<typeof axios>;
@@ -19,7 +20,7 @@ describe('AuthService', () => {
sessionKey: 'session-key-abc', sessionKey: 'session-key-abc',
nickname: 'TestUser', nickname: 'TestUser',
avatarUrl: null, avatarUrl: null,
stamina: 5, stamina: MAX_STAMINA,
staminaUpdatedAt: null, staminaUpdatedAt: null,
createdAt: new Date('2026-01-01'), createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'), updatedAt: new Date('2026-01-01'),
@@ -65,7 +66,7 @@ describe('AuthService', () => {
describe('wxLogin', () => { describe('wxLogin', () => {
it('should create a new user and return JWT token on first login', async () => { 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({ mockedAxios.get.mockResolvedValue({
data: { openid: 'wx-openid-123', session_key: 'session-key-abc' }, 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.token).toBe('jwt-token-xyz');
expect(result.user.id).toBe('user-uuid-1'); 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({ expect(mockUserRepository.create).toHaveBeenCalledWith({
openid: 'wx-openid-123', openid: 'wx-openid-123',
sessionKey: 'session-key-abc', sessionKey: 'session-key-abc',
stamina: 5, stamina: MAX_STAMINA,
}); });
expect(mockJwtService.signAsync).toHaveBeenCalledWith({ expect(mockJwtService.signAsync).toHaveBeenCalledWith({
sub: 'user-uuid-1', sub: 'user-uuid-1',

View File

@@ -1,14 +1,11 @@
import { import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
Injectable,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import axios from 'axios'; import axios from 'axios';
import { UserRepository } from './repositories/user.repository'; import { UserRepository } from './repositories/user.repository';
import { WxLoginResponseDto, UserInfoDto } from './dto/wx-login.dto'; import { WxLoginResponseDto, UserInfoDto } from './dto/wx-login.dto';
import { JwtPayload } from '../../common/guards/jwt-auth.guard'; import { JwtPayload } from '../../common/guards/jwt-auth.guard';
import { MAX_STAMINA } from '../../common/constants/game.constants';
interface WxSessionResponse { interface WxSessionResponse {
openid?: string; openid?: string;
@@ -53,7 +50,7 @@ export class AuthService {
user = this.userRepository.create({ user = this.userRepository.create({
openid: wxSession.openid, openid: wxSession.openid,
sessionKey: wxSession.session_key ?? null, sessionKey: wxSession.session_key ?? null,
stamina: 5, // 新用户默认 5 体力值 stamina: MAX_STAMINA,
}); });
user = await this.userRepository.save(user); user = await this.userRepository.save(user);
this.logger.log(`新用户注册: ${user.id}`); this.logger.log(`新用户注册: ${user.id}`);

View File

@@ -6,6 +6,7 @@ import {
UpdateDateColumn, UpdateDateColumn,
Index, Index,
} from 'typeorm'; } from 'typeorm';
import { MAX_STAMINA } from '../../../common/constants/game.constants';
@Entity('wx_users') @Entity('wx_users')
export class User { export class User {
@@ -25,8 +26,8 @@ export class User {
@Column({ type: 'text', name: 'avatar_url', nullable: true }) @Column({ type: 'text', name: 'avatar_url', nullable: true })
avatarUrl!: string | null; avatarUrl!: string | null;
/** 体力值(默认 5上限 5 */ /** 体力值(默认 MAX_STAMINA上限 MAX_STAMINA */
@Column({ type: 'int', default: 5 }) @Column({ type: 'int', default: MAX_STAMINA })
stamina!: number; stamina!: number;
/** 体力值最后更新时间(用于计算恢复) */ /** 体力值最后更新时间(用于计算恢复) */

View File

@@ -8,9 +8,7 @@ import { GameConfig } from '../wechat-game/entities/game-config.entity';
@Injectable() @Injectable()
export class GameConfigService { export class GameConfigService {
constructor( constructor(private readonly gameConfigRepository: GameConfigRepository) {}
private readonly gameConfigRepository: GameConfigRepository,
) {}
async getAllConfigs(): Promise<GameConfigListResponseDto> { async getAllConfigs(): Promise<GameConfigListResponseDto> {
const configs = await this.gameConfigRepository.findActiveConfigs(); const configs = await this.gameConfigRepository.findActiveConfigs();

View File

@@ -8,12 +8,24 @@ export class EnterLevelResponseDto {
@ApiProperty({ description: '关卡编号' }) @ApiProperty({ description: '关卡编号' })
level!: number; level!: number;
@ApiProperty({ description: '图片 URL' }) @ApiProperty({ description: '图片1 URL' })
imageUrl!: string; 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: '答案' }) @ApiProperty({ description: '答案' })
answer!: string; answer!: string;
@ApiProperty({ description: '谐音梗说明', nullable: true })
punchline!: string | null;
@ApiProperty({ description: '线索1', nullable: true }) @ApiProperty({ description: '线索1', nullable: true })
hint1!: string | null; hint1!: string | null;

View File

@@ -7,12 +7,24 @@ export class LevelListItemDto {
@ApiProperty({ description: '关卡编号' }) @ApiProperty({ description: '关卡编号' })
level!: number; level!: number;
@ApiProperty({ description: '图片 URL' }) @ApiProperty({ description: '图片1 URL' })
imageUrl!: string; 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 }) @ApiProperty({ description: '答案(仅已通关时返回)', nullable: true })
answer!: string | null; answer!: string | null;
@ApiProperty({ description: '谐音梗说明(仅已通关时返回)', nullable: true })
punchline!: string | null;
@ApiProperty({ description: '线索1仅已通关时返回', nullable: true }) @ApiProperty({ description: '线索1仅已通关时返回', nullable: true })
hint1!: string | null; hint1!: string | null;
@@ -25,7 +37,10 @@ export class LevelListItemDto {
@ApiProperty({ description: '是否已通关' }) @ApiProperty({ description: '是否已通关' })
completed!: boolean; completed!: boolean;
@ApiProperty({ description: '通关时长(秒),未通关时为 null', nullable: true }) @ApiProperty({
description: '通关时长(秒),未通关时为 null',
nullable: true,
})
timeSpent!: number | null; timeSpent!: number | null;
} }

View File

@@ -1,11 +1,4 @@
import { import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
Body,
Controller,
Get,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import { import {
ApiBearerAuth, ApiBearerAuth,
ApiOperation, ApiOperation,
@@ -49,7 +42,8 @@ export class LevelController {
@Post(':id/enter') @Post(':id/enter')
@ApiOperation({ @ApiOperation({
summary: '进入关卡', summary: '进入关卡',
description: '消耗 1 体力进入关卡,返回完整关卡详情(线索+答案)。已通关关卡不消耗体力。', description:
'消耗 1 体力进入关卡,返回完整关卡详情(线索+答案)。已通关关卡不消耗体力。',
}) })
@ApiResponse({ status: 200, description: '成功' }) @ApiResponse({ status: 200, description: '成功' })
@ApiResponse({ status: 400, description: '体力不足' }) @ApiResponse({ status: 400, description: '体力不足' })

View File

@@ -1,8 +1,4 @@
import { import { Injectable, NotFoundException, Logger } from '@nestjs/common';
Injectable,
NotFoundException,
Logger,
} from '@nestjs/common';
import { LevelRepository } from '../wechat-game/repositories/level.repository'; import { LevelRepository } from '../wechat-game/repositories/level.repository';
import { UserLevelProgressRepository } from '../auth/repositories/user-level-progress.repository'; import { UserLevelProgressRepository } from '../auth/repositories/user-level-progress.repository';
import { UserService } from '../user/user.service'; import { UserService } from '../user/user.service';
@@ -12,6 +8,10 @@ import {
CompleteLevelRequestDto, CompleteLevelRequestDto,
CompleteLevelResponseDto, CompleteLevelResponseDto,
} from './dto/complete-level.dto'; } from './dto/complete-level.dto';
import {
pickLevelImageFields,
pickLevelImageFieldsMasked,
} from '../wechat-game/level-fields.helper';
@Injectable() @Injectable()
export class LevelService { export class LevelService {
@@ -32,9 +32,7 @@ export class LevelService {
this.userLevelProgressRepository.findByUserId(userId), this.userLevelProgressRepository.findByUserId(userId),
]); ]);
const progressMap = new Map( const progressMap = new Map(progressList.map((p) => [p.levelId, p]));
progressList.map((p) => [p.levelId, p]),
);
const items: LevelListItemDto[] = levels.map((level, index) => { const items: LevelListItemDto[] = levels.map((level, index) => {
const progress = progressMap.get(level.id); const progress = progressMap.get(level.id);
@@ -43,11 +41,8 @@ export class LevelService {
return { return {
id: level.id, id: level.id,
level: index + 1, level: index + 1,
imageUrl: level.imageUrl, ...pickLevelImageFieldsMasked(level, completed),
answer: completed ? level.answer : null, answer: completed ? level.answer : null,
hint1: completed ? level.hint1 : null,
hint2: completed ? level.hint2 : null,
hint3: completed ? level.hint3 : null,
completed, completed,
timeSpent: completed ? progress.timeSpent : null, timeSpent: completed ? progress.timeSpent : null,
}; };
@@ -63,7 +58,6 @@ export class LevelService {
userId: string, userId: string,
levelId: string, levelId: string,
): Promise<EnterLevelResponseDto> { ): Promise<EnterLevelResponseDto> {
// 1. 并行查找关卡和通关记录
const [level, existing] = await Promise.all([ const [level, existing] = await Promise.all([
this.levelRepository.findById(levelId), this.levelRepository.findById(levelId),
this.userLevelProgressRepository.findByUserAndLevel(userId, levelId), this.userLevelProgressRepository.findByUserAndLevel(userId, levelId),
@@ -76,11 +70,10 @@ export class LevelService {
let staminaInfo; let staminaInfo;
if (existing) { if (existing) {
// 已通关,不消耗体力,直接返回 // Already completed — no stamina cost
const user = await this.userService.findUserOrThrow(userId); const user = await this.userService.findUserOrThrow(userId);
staminaInfo = this.userService.computeStamina(user); staminaInfo = this.userService.computeStamina(user);
} else { } else {
// 未通关,消耗体力(返回值已包含 stamina 信息,无需重复计算)
const result = await this.userService.consumeStamina(userId); const result = await this.userService.consumeStamina(userId);
staminaInfo = result.stamina; staminaInfo = result.stamina;
this.logger.log(`用户 ${userId} 进入关卡 ${levelId},消耗 1 体力`); this.logger.log(`用户 ${userId} 进入关卡 ${levelId},消耗 1 体力`);
@@ -89,11 +82,8 @@ export class LevelService {
return { return {
id: level.id, id: level.id,
level: level.sortOrder, level: level.sortOrder,
imageUrl: level.imageUrl, ...pickLevelImageFields(level),
answer: level.answer, answer: level.answer,
hint1: level.hint1,
hint2: level.hint2,
hint3: level.hint3,
stamina: staminaInfo, stamina: staminaInfo,
}; };
} }
@@ -106,7 +96,6 @@ export class LevelService {
levelId: string, levelId: string,
dto: CompleteLevelRequestDto, dto: CompleteLevelRequestDto,
): Promise<CompleteLevelResponseDto> { ): Promise<CompleteLevelResponseDto> {
// 并行验证关卡存在和检查通关记录
const [level, existing] = await Promise.all([ const [level, existing] = await Promise.all([
this.levelRepository.findById(levelId), this.levelRepository.findById(levelId),
this.userLevelProgressRepository.findByUserAndLevel(userId, levelId), this.userLevelProgressRepository.findByUserAndLevel(userId, levelId),
@@ -125,7 +114,6 @@ export class LevelService {
}; };
} }
// 记录通关进度
const progress = this.userLevelProgressRepository.create({ const progress = this.userLevelProgressRepository.create({
userId, userId,
levelId, levelId,
@@ -133,9 +121,7 @@ export class LevelService {
}); });
await this.userLevelProgressRepository.save(progress); await this.userLevelProgressRepository.save(progress);
this.logger.log( this.logger.log(`用户 ${userId} 通关 ${levelId},用时 ${dto.timeSpent}`);
`用户 ${userId} 通关 ${levelId},用时 ${dto.timeSpent}`,
);
return { return {
firstClear: true, firstClear: true,

View File

@@ -1,5 +1,11 @@
import { ApiProperty } from '@nestjs/swagger'; 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 { export class ReportLevelProgressDto {
@ApiProperty({ description: '分享码' }) @ApiProperty({ description: '分享码' })

View File

@@ -18,12 +18,24 @@ export class ShareLevelDto {
@ApiProperty() @ApiProperty()
level!: number; level!: number;
@ApiProperty() @ApiProperty({ description: '图片1 URL' })
imageUrl!: string; 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() @ApiProperty()
answer!: string; answer!: string;
@ApiProperty({ description: '谐音梗说明', nullable: true })
punchline!: string | null;
@ApiProperty({ nullable: true }) @ApiProperty({ nullable: true })
hint1!: string | null; hint1!: string | null;

View File

@@ -38,6 +38,11 @@ export class ShareLevelProgress {
@Column({ type: 'int', default: 0, name: 'time_spent' }) @Column({ type: 'int', default: 0, name: 'time_spent' })
timeSpent!: number; 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; completedAt!: Date | null;
} }

View File

@@ -21,8 +21,12 @@ describe('ShareService', () => {
const mockLevels: Level[] = Array.from({ length: 6 }, (_, i) => ({ const mockLevels: Level[] = Array.from({ length: 6 }, (_, i) => ({
id: `level-${i + 1}`, 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}`, answer: `答案${i + 1}`,
punchline: null,
hint1: `提示${i + 1}`, hint1: `提示${i + 1}`,
hint2: null, hint2: null,
hint3: null, hint3: null,

View File

@@ -8,6 +8,7 @@ import { ShareConfigRepository } from './repositories/share-config.repository';
import { ShareParticipantRepository } from './repositories/share-participant.repository'; import { ShareParticipantRepository } from './repositories/share-participant.repository';
import { ShareLevelProgressRepository } from './repositories/share-level-progress.repository'; import { ShareLevelProgressRepository } from './repositories/share-level-progress.repository';
import { LevelRepository } from '../wechat-game/repositories/level.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 { CreateShareDto } from './dto/create-share.dto';
import { ReportLevelProgressDto } from './dto/report-level-progress.dto'; import { ReportLevelProgressDto } from './dto/report-level-progress.dto';
import { import {
@@ -83,7 +84,7 @@ export class ShareService {
await this.shareParticipantRepository.addParticipant(config.id, userId); 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 allLevels = await this.levelRepository.findByIds(config.levelIds);
const levelMap = new Map(allLevels.map((l) => [l.id, l])); const levelMap = new Map(allLevels.map((l) => [l.id, l]));
@@ -95,11 +96,8 @@ export class ShareService {
return { return {
id: level.id, id: level.id,
level: index + 1, level: index + 1,
imageUrl: level.imageUrl, ...pickLevelImageFields(level),
answer: level.answer, answer: level.answer,
hint1: level.hint1,
hint2: level.hint2,
hint3: level.hint3,
sortOrder: level.sortOrder, sortOrder: level.sortOrder,
}; };
}); });

View File

@@ -7,7 +7,10 @@ export class StaminaInfoDto {
@ApiProperty({ description: '体力值上限' }) @ApiProperty({ description: '体力值上限' })
max!: number; max!: number;
@ApiProperty({ description: '下次恢复时间ISO 字符串),满体力时为 null', nullable: true }) @ApiProperty({
description: '下次恢复时间ISO 字符串),满体力时为 null',
nullable: true,
})
nextRecoverAt!: string | null; nextRecoverAt!: string | null;
} }

View File

@@ -6,7 +6,10 @@ import {
ApiTags, ApiTags,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { UserService } from './user.service'; 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 { ApiResponseDto } from '../../common/dto/api-response.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import type { JwtPayload } from '../../common/guards/jwt-auth.guard'; import type { JwtPayload } from '../../common/guards/jwt-auth.guard';

View File

@@ -11,9 +11,12 @@ import {
UserProfileResponseDto, UserProfileResponseDto,
GameDataResponseDto, GameDataResponseDto,
} from './dto/user-profile.dto'; } from './dto/user-profile.dto';
import {
MAX_STAMINA,
RECOVER_INTERVAL_MS,
} from '../../common/constants/game.constants';
export const MAX_STAMINA = 5; export { MAX_STAMINA, RECOVER_INTERVAL_MS };
export const RECOVER_INTERVAL_MS = 10 * 60 * 1000; // 10 分钟
@Injectable() @Injectable()
export class UserService { export class UserService {
@@ -53,12 +56,15 @@ export class UserService {
return { current: currentStamina, max: MAX_STAMINA, nextRecoverAt }; return { current: currentStamina, max: MAX_STAMINA, nextRecoverAt };
} }
private static readonly MAX_STAMINA_RETRIES = 3;
/** /**
* 消耗 1 点体力,返回消耗后的体力信息。 * 消耗 1 点体力,返回消耗后的体力信息。
* 使用原子更新防止并发竞态条件(双击进入关卡场景)。 * 使用原子更新防止并发竞态条件(双击进入关卡场景)。
*/ */
async consumeStamina( async consumeStamina(
userId: string, userId: string,
retries = 0,
): Promise<{ user: User; stamina: StaminaInfoDto }> { ): Promise<{ user: User; stamina: StaminaInfoDto }> {
const user = await this.findUserOrThrow(userId); const user = await this.findUserOrThrow(userId);
const staminaInfo = this.computeStamina(user); const staminaInfo = this.computeStamina(user);
@@ -70,7 +76,6 @@ export class UserService {
const newStamina = staminaInfo.current - 1; const newStamina = staminaInfo.current - 1;
const now = new Date(); const now = new Date();
// 原子更新:使用 WHERE 条件确保并发安全
const result = await this.userRepository.updateStaminaAtomic( const result = await this.userRepository.updateStaminaAtomic(
userId, userId,
user.stamina, user.stamina,
@@ -79,14 +84,30 @@ export class UserService {
); );
if (result.affected === 0) { if (result.affected === 0) {
// 并发冲突,重试一次 if (retries >= UserService.MAX_STAMINA_RETRIES) {
return this.consumeStamina(userId); throw new BadRequestException('操作冲突,请重试');
}
return this.consumeStamina(userId, retries + 1);
} }
const updatedUser = { ...user, stamina: newStamina, staminaUpdatedAt: now }; const updatedStamina: StaminaInfoDto =
const updatedStamina = this.computeStamina(updatedUser as User); 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<UserProfileResponseDto> { async getUserProfile(userId: string): Promise<UserProfileResponseDto> {

View File

@@ -7,12 +7,24 @@ export class LevelResponseDto {
@ApiProperty({ description: '关卡ID' }) @ApiProperty({ description: '关卡ID' })
id!: string; id!: string;
@ApiProperty({ description: '图片URL' }) @ApiProperty({ description: '图片1 URL' })
imageUrl!: string; 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: '答案' }) @ApiProperty({ description: '答案' })
answer!: string; answer!: string;
@ApiProperty({ description: '谐音梗说明', nullable: true })
punchline!: string | null;
@ApiProperty({ description: '提示1', nullable: true }) @ApiProperty({ description: '提示1', nullable: true })
hint1!: string | null; hint1!: string | null;

View File

@@ -11,12 +11,34 @@ export class Level {
@PrimaryColumn({ type: 'varchar', length: 191 }) @PrimaryColumn({ type: 'varchar', length: 191 })
id!: string; id!: string;
@Column({ type: 'varchar', length: 191, name: 'image_url' }) @Column({ type: 'varchar', length: 500, name: 'image1_url' })
imageUrl!: string; 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 }) @Column({ type: 'varchar', length: 191 })
answer!: string; answer!: string;
@Column({ type: 'varchar', length: 500, nullable: true })
punchline!: string | null;
@Column({ type: 'varchar', length: 191, nullable: true }) @Column({ type: 'varchar', length: 191, nullable: true })
hint1!: string | null; hint1!: string | null;

View File

@@ -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,
};
}