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

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 mockStatus = jest.fn().mockReturnValue({ json: mockJson });
const mockGetResponse = jest.fn().mockReturnValue({ status: mockStatus });
const mockGetRequest = jest
.fn()
.mockReturnValue({ url: '/api/v1/test' });
const mockGetRequest = jest.fn().mockReturnValue({ url: '/api/v1/test' });
const mockHost: ArgumentsHost = {
switchToHttp: () => ({

View File

@@ -45,7 +45,7 @@ describe('JwtAuthGuard', () => {
mockJwtService.verifyAsync.mockResolvedValue(payload);
const context = createMockContext('Bearer valid-token');
const request = context.switchToHttp().getRequest() as Record<string, unknown>;
const request = context.switchToHttp().getRequest();
await guard.canActivate(context);
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 { AuthService } from './auth.service';
import { ApiResponseDto } from '../../common/dto/api-response.dto';
import { MAX_STAMINA } from '../../common/constants/game.constants';
describe('AuthController', () => {
let controller: AuthController;
@@ -31,7 +32,7 @@ describe('AuthController', () => {
it('should return success response with token and user info', async () => {
const loginResponse = {
token: 'jwt-token',
user: { id: 'user-uuid-1', nickname: 'Test', stamina: 5 },
user: { id: 'user-uuid-1', nickname: 'Test', stamina: MAX_STAMINA },
};
mockAuthService.wxLogin.mockResolvedValue(loginResponse);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,12 +8,24 @@ export class EnterLevelResponseDto {
@ApiProperty({ description: '关卡编号' })
level!: number;
@ApiProperty({ description: '图片 URL' })
imageUrl!: string;
@ApiProperty({ description: '图片1 URL' })
image1Url!: string;
@ApiProperty({ description: '图片1 文本说明', nullable: true })
image1Description!: string | null;
@ApiProperty({ description: '图片2 URL' })
image2Url!: string;
@ApiProperty({ description: '图片2 文本说明', nullable: true })
image2Description!: string | null;
@ApiProperty({ description: '答案' })
answer!: string;
@ApiProperty({ description: '谐音梗说明', nullable: true })
punchline!: string | null;
@ApiProperty({ description: '线索1', nullable: true })
hint1!: string | null;

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
import {
IsBoolean,
IsNotEmpty,
IsNumber,
IsString,
Min,
} from 'class-validator';
export class ReportLevelProgressDto {
@ApiProperty({ description: '分享码' })

View File

@@ -18,12 +18,24 @@ export class ShareLevelDto {
@ApiProperty()
level!: number;
@ApiProperty()
imageUrl!: string;
@ApiProperty({ description: '图片1 URL' })
image1Url!: string;
@ApiProperty({ description: '图片1 文本说明', nullable: true })
image1Description!: string | null;
@ApiProperty({ description: '图片2 URL' })
image2Url!: string;
@ApiProperty({ description: '图片2 文本说明', nullable: true })
image2Description!: string | null;
@ApiProperty()
answer!: string;
@ApiProperty({ description: '谐音梗说明', nullable: true })
punchline!: string | null;
@ApiProperty({ nullable: true })
hint1!: string | null;

View File

@@ -38,6 +38,11 @@ export class ShareLevelProgress {
@Column({ type: 'int', default: 0, name: 'time_spent' })
timeSpent!: number;
@Column({ type: 'timestamp', name: 'completed_at', nullable: true, default: null })
@Column({
type: 'timestamp',
name: 'completed_at',
nullable: true,
default: null,
})
completedAt!: Date | null;
}

View File

@@ -21,8 +21,12 @@ describe('ShareService', () => {
const mockLevels: Level[] = Array.from({ length: 6 }, (_, i) => ({
id: `level-${i + 1}`,
imageUrl: `https://example.com/meme${i + 1}.jpg`,
image1Url: `https://example.com/meme${i + 1}_1.jpg`,
image1Description: null,
image2Url: `https://example.com/meme${i + 1}_2.jpg`,
image2Description: null,
answer: `答案${i + 1}`,
punchline: null,
hint1: `提示${i + 1}`,
hint2: null,
hint3: null,

View File

@@ -8,6 +8,7 @@ import { ShareConfigRepository } from './repositories/share-config.repository';
import { ShareParticipantRepository } from './repositories/share-participant.repository';
import { ShareLevelProgressRepository } from './repositories/share-level-progress.repository';
import { LevelRepository } from '../wechat-game/repositories/level.repository';
import { pickLevelImageFields } from '../wechat-game/level-fields.helper';
import { CreateShareDto } from './dto/create-share.dto';
import { ReportLevelProgressDto } from './dto/report-level-progress.dto';
import {
@@ -83,7 +84,7 @@ export class ShareService {
await this.shareParticipantRepository.addParticipant(config.id, userId);
}
// 单次查询获取所有关卡,再按 levelIds 顺序排列
// Single query, then reorder to match levelIds sequence
const allLevels = await this.levelRepository.findByIds(config.levelIds);
const levelMap = new Map(allLevels.map((l) => [l.id, l]));
@@ -95,11 +96,8 @@ export class ShareService {
return {
id: level.id,
level: index + 1,
imageUrl: level.imageUrl,
...pickLevelImageFields(level),
answer: level.answer,
hint1: level.hint1,
hint2: level.hint2,
hint3: level.hint3,
sortOrder: level.sortOrder,
};
});

View File

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

View File

@@ -6,7 +6,10 @@ import {
ApiTags,
} from '@nestjs/swagger';
import { UserService } from './user.service';
import { UserProfileResponseDto, GameDataResponseDto } from './dto/user-profile.dto';
import {
UserProfileResponseDto,
GameDataResponseDto,
} from './dto/user-profile.dto';
import { ApiResponseDto } from '../../common/dto/api-response.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import type { JwtPayload } from '../../common/guards/jwt-auth.guard';

View File

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

View File

@@ -7,12 +7,24 @@ export class LevelResponseDto {
@ApiProperty({ description: '关卡ID' })
id!: string;
@ApiProperty({ description: '图片URL' })
imageUrl!: string;
@ApiProperty({ description: '图片1 URL' })
image1Url!: string;
@ApiProperty({ description: '图片1 文本说明', nullable: true })
image1Description!: string | null;
@ApiProperty({ description: '图片2 URL' })
image2Url!: string;
@ApiProperty({ description: '图片2 文本说明', nullable: true })
image2Description!: string | null;
@ApiProperty({ description: '答案' })
answer!: string;
@ApiProperty({ description: '谐音梗说明', nullable: true })
punchline!: string | null;
@ApiProperty({ description: '提示1', nullable: true })
hint1!: string | null;

View File

@@ -11,12 +11,34 @@ export class Level {
@PrimaryColumn({ type: 'varchar', length: 191 })
id!: string;
@Column({ type: 'varchar', length: 191, name: 'image_url' })
imageUrl!: string;
@Column({ type: 'varchar', length: 500, name: 'image1_url' })
image1Url!: string;
@Column({
type: 'varchar',
length: 500,
name: 'image1_description',
nullable: true,
})
image1Description!: string | null;
@Column({ type: 'varchar', length: 500, name: 'image2_url', default: '' })
image2Url!: string;
@Column({
type: 'varchar',
length: 500,
name: 'image2_description',
nullable: true,
})
image2Description!: string | null;
@Column({ type: 'varchar', length: 191 })
answer!: string;
@Column({ type: 'varchar', length: 500, nullable: true })
punchline!: string | null;
@Column({ type: 'varchar', length: 191, nullable: true })
hint1!: string | null;

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