feat: 支持新的关卡数据结构
This commit is contained in:
5
src/common/constants/game.constants.ts
Normal file
5
src/common/constants/game.constants.ts
Normal 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;
|
||||
@@ -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: () => ({
|
||||
|
||||
@@ -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);
|
||||
|
||||
32
src/database/migrations/003_level_dual_image_stamina.sql
Normal file
32
src/database/migrations/003_level_dual_image_stamina.sql
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
/** 体力值最后更新时间(用于计算恢复) */
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: '体力不足' })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '分享码' })
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
49
src/modules/wechat-game/level-fields.helper.ts
Normal file
49
src/modules/wechat-game/level-fields.helper.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user