From 37c97708e34cb67ba23b403c6d8f80495040e453 Mon Sep 17 00:00:00 2001 From: xiangwang Date: Sat, 19 Sep 2026 04:19:41 +0800 Subject: [PATCH] feat: deploy adventure wallet progression --- .gitignore | 1 + ADVENTURE.md | 49 +++++++++ package.json | 1 + scripts/run_migrations.ts | 1 + src/business/player/adventure.service.spec.ts | 89 +++++++++++++++ src/business/player/adventure.service.ts | 102 ++++++++++++++++++ src/business/player/adventure_chapters.json | 12 +++ .../player/dto/adventure_command.dto.ts | 11 ++ src/business/player/player.controller.ts | 10 ++ src/business/player/player.module.ts | 3 +- .../db/user_wallets/add-adventure-state.sql | 8 ++ .../db/user_wallets/user_wallets.entity.ts | 3 + .../db/user_wallets/user_wallets.service.ts | 86 +++++++++------ .../user_wallets_memory.service.ts | 18 ++++ 14 files changed, 362 insertions(+), 32 deletions(-) create mode 100644 ADVENTURE.md create mode 100644 src/business/player/adventure.service.spec.ts create mode 100644 src/business/player/adventure.service.ts create mode 100644 src/business/player/adventure_chapters.json create mode 100644 src/business/player/dto/adventure_command.dto.ts create mode 100644 src/core/db/user_wallets/add-adventure-state.sql diff --git a/.gitignore b/.gitignore index 3e0dd68..6af078e 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,4 @@ Thumbs.db *~ !src/business/auth/skin_defaults.spec.ts +!src/business/player/adventure.service.spec.ts diff --git a/ADVENTURE.md b/ADVENTURE.md new file mode 100644 index 0000000..1debb1f --- /dev/null +++ b/ADVENTURE.md @@ -0,0 +1,49 @@ +# Battle and learning settlement + +`POST /player/adventure` uses the existing JWT user identity and existing +`user_wallets` balance. It never imports the old client-local coin save. + +Actions: + +- `snapshot`: wallet, level, experience, health, enrollments and current battle. +- `start`, `encounter`: create/resume one battle per account; response contains session ID and question without the answer. +- `answer`, `session`, `turn`, `choice`: server grades the answer. Correct removes one of three hearts; wrong removes ten HP. Victory adds ten coins and no experience. Duplicate identical last-turn submission is idempotent. +- `escape`, `session`: closes an active battle without a reward. +- `rest`: restores health for free when not in battle. +- `recover`: spends five coins for up to thirty HP, only when injured and out of battle. +- `enroll`, `chapter`: spends twenty coins once for a published chapter. +- `complete`, `chapter`, `answers`: validates option indexes against the server chapter catalog; grants twenty learning experience once. + +HP maximum is 10, 15, 20, 25, 30 for levels 1–5. Level requirements are +40, 60, 80, 100 experience. Only chapter completion grants experience. + +## Deployment + +Run `npm run db:migrate` with the target database environment before deploying +the new backend, then build/restart it and deploy the paired frontend. The +migration adds a nullable JSON `adventure` column to `user_wallets` and can be +run repeatedly. It does not change existing balances or grant old local coins. +Do not run the new entity against a database without the migration. + +Database mode locks the wallet row and atomically saves progress, balance and +transaction history. Existing spend/earn operations now lock the same wallet +row so mall payments cannot overwrite adventure rewards. Memory mode supports +development/tests only and is not durable across server restarts. + +`adventure_chapters.json` currently contains the two published chapter quizzes +from hello-agents and happy-llm. Update it with the corresponding frontend book +package when publishing a new chapter. Two chapters can reach level 2; levels +3–5 need additional published learning content. Completed chapters and defeated +encounters persist per account. No client-provided coin, damage or XP amounts +are accepted. Encounter proximity and movement are still client-authoritative; +this is not a server-authoritative world simulation. + +## Verification + +`npm run build`, `npm test`, and `npm run test:adventure`. +The adventure suite covers replayed/concurrent victory and enrollment requests, +wrong answers, insufficient funds, cross-account battle access, and chapter +rewards. On macOS with the sibling frontend and Godot installed it also runs the +real Godot API client against an isolated NestJS HTTP server using a test-only +guard. Production JWT validation is unchanged. MySQL migration/locking must also +be exercised in the target database environment before rollout. diff --git a/package.json b/package.json index 86834cd..192073e 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "description": "WhaleTown V2 NestJS backend and administration service", "main": "dist/main.js", "scripts": { + "test:adventure": "jest --runInBand --runTestsByPath src/business/player/adventure.service.spec.ts", "dev": "nest start --watch", "build": "nest build", "start": "node dist/main.js", diff --git a/scripts/run_migrations.ts b/scripts/run_migrations.ts index c4ba395..b6cbe1f 100644 --- a/scripts/run_migrations.ts +++ b/scripts/run_migrations.ts @@ -3,6 +3,7 @@ import { resolve } from 'path'; import { createConnection } from 'mysql2/promise'; const MIGRATIONS = [ + 'src/core/db/user_wallets/add-adventure-state.sql', 'src/business/invitation/migrations/create-invitation-codes.sql', ]; diff --git a/src/business/player/adventure.service.spec.ts b/src/business/player/adventure.service.spec.ts new file mode 100644 index 0000000..b0e121e --- /dev/null +++ b/src/business/player/adventure.service.spec.ts @@ -0,0 +1,89 @@ +import { AdventureService } from './adventure.service'; +import { UserWalletsMemoryService } from '../../core/db/user_wallets/user_wallets_memory.service'; +import chapters from './adventure_chapters.json'; +import { Test } from '@nestjs/testing'; +import { PlayerController } from './player.controller'; +import { PlayerStateService } from './player_state.service'; +import { EconomyService } from './economy.service'; +import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { existsSync } from 'fs'; +import { resolve } from 'path'; + +describe('Adventure settlement', () => { + let wallets: UserWalletsMemoryService; + let service: AdventureService; + beforeEach(() => { wallets = new UserWalletsMemoryService(); service = new AdventureService(wallets); }); + const user = 1n; + it('awards exactly ten coins, no experience, and cannot replay a victory', async () => { + const start = await service.execute(user, { action: 'start', encounter: 'starting_encounter' }); + const session = start.battle.id; + for (let turn = 0; turn < 2; turn++) await service.execute(user, { action: 'answer', session, turn, choice: [1, 2][turn] }); + const results = await Promise.all(Array.from({ length: 8 }, () => service.execute(user, { action: 'answer', session, turn: 2, choice: 0 }))); + results.forEach(result => { + expect(result.wallet.balance).toBe(1210); + expect(result.progress.experience).toBe(0); + expect(result.battle.status).toBe('victory'); + }); + await expect(service.execute(user, { action: 'start', encounter: 'starting_encounter' })).rejects.toThrow(); + expect((await wallets.getBalance(user)).balance).toBe(1210); + }); + it('rejects another user/session and makes a level one error fatal', async () => { + const start = await service.execute(user, { action: 'start', encounter: 'starting_encounter' }); + const command = { action: 'answer', session: start.battle.id, turn: 0, choice: 0 }; + await expect(service.execute(2n, command)).rejects.toThrow(); + const result = await service.execute(user, command); + expect(result.progress.health).toBe(0); + expect(result.battle.status).toBe('defeat'); + expect(result.wallet.balance).toBe(1200); + expect((await service.execute(user, { action: 'rest' })).progress.health).toBe(10); + }); + it('charges once, validates chapter answers and only grants experience once', async () => { + const chapter = Object.keys(chapters)[0]; + await expect(service.execute(user, { action: 'complete', chapter, answers: [] })).rejects.toThrow(); + await Promise.all(Array.from({ length: 5 }, () => service.execute(user, { action: 'enroll', chapter }))); + expect((await wallets.getBalance(user)).balance).toBe(1180); + await expect(service.execute(user, { action: 'complete', chapter, answers: [-1] })).rejects.toThrow(); + const answers = chapters[chapter].answers; + const results = await Promise.all(Array.from({ length: 5 }, () => service.execute(user, { action: 'complete', chapter, answers }))); + expect(results[4].progress.experience).toBe(20); + expect(results[4].wallet.balance).toBe(1180); + }); + it('rolls back insufficient funds without enrolling', async () => { + await wallets.spend(user, 1200, 'test', 'zero'); + const chapter = Object.keys(chapters)[0]; + await expect(service.execute(user, { action: 'enroll', chapter })).rejects.toThrow(); + const result = await service.execute(user, { action: 'snapshot' }); + expect(result.progress.chapters).toEqual({}); + expect(result.wallet.balance).toBe(0); + }); + + const godot = '/Applications/Godot.app/Contents/MacOS/Godot'; + const frontend = resolve(__dirname, '../../../../whale-town-front-v2'); + (existsSync(godot) && existsSync(frontend) ? it : it.skip)('runs the Godot client against the real HTTP controller', async () => { + const module = await Test.createTestingModule({ controllers: [PlayerController], providers: [ + { provide: AdventureService, useValue: service }, + { provide: PlayerStateService, useValue: {} }, + { provide: EconomyService, useValue: {} }, + ] }).overrideGuard(JwtAuthGuard).useValue({ canActivate: context => { + const request = context.switchToHttp().getRequest(); + request.user = { sub: '88' }; + return request.headers.authorization === 'Bearer adventure-test-only'; + } }).compile(); + const app = module.createNestApplication(); + await app.listen(0, '127.0.0.1'); + try { + const url = await app.getUrl(); + const denied = await fetch(url + '/player/adventure', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'snapshot' }) }); + expect(denied.status).toBe(403); + const invalid = await fetch(url + '/player/adventure', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer adventure-test-only' }, body: JSON.stringify({ action: 'snapshot', coins: 10000 }) }); + expect(invalid.status).toBe(400); + const { stdout, stderr } = await promisify(execFile)(godot, ['--headless', '--path', frontend, '--script', 'scripts/test_adventure_http.gd'], { + env: { ...process.env, WHALETOWN_API_BASE_URL: url }, timeout: 20000, + }); + expect(stdout).toContain('ADVENTURE_HTTP_OK'); + expect(stderr).not.toContain('SCRIPT ERROR'); + } finally { await app.close(); } + }, 30000); +}); diff --git a/src/business/player/adventure.service.ts b/src/business/player/adventure.service.ts new file mode 100644 index 0000000..327d7e3 --- /dev/null +++ b/src/business/player/adventure.service.ts @@ -0,0 +1,102 @@ +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { UserWallets } from '../../core/db/user_wallets/user_wallets.entity'; +import chapters from './adventure_chapters.json'; + +const QUESTIONS = [ + { text: 'Python 中列表通常使用哪一种括号表示?', options: ['{}', '[]', '()', '<>'], answer: 1 }, + { text: 'Python 中哪个关键字用于定义函数?', options: ['class', 'for', 'def', 'return'], answer: 2 }, + { text: 'Python 中 len([1, 2, 3]) 的结果是什么?', options: ['3', '2', '1', '0'], answer: 0 }, + { text: 'Python 中哪个值表示逻辑真?', options: ['None', 'False', '0', 'True'], answer: 3 }, +]; +export interface AdventureCommand { action: string; encounter?: string; session?: string; turn?: number; choice?: number; chapter?: string; answers?: number[]; } + +@Injectable() +export class AdventureService { + constructor(@Inject('IUserWalletsService') private readonly wallets: { + mutateAdventure(id: bigint, operation: (wallet: UserWallets) => T): Promise; + }) {} + + execute(userId: bigint, command: AdventureCommand) { + return this.wallets.mutateAdventure(userId, wallet => { + const s = wallet.adventure ||= { level: 1, experience: 0, health: 10, chapters: {}, defeated: {}, sequence: 0, battle: null }; + const maxHealth = () => 10 + (s.level - 1) * 5; + const spend = (amount: number) => { + if (wallet.balance < amount) throw new BadRequestException('鲸币余额不足'); + wallet.balance -= amount; + }; + let correct: boolean | undefined; + const active = s.battle?.status === 'active'; + if (active && ['rest', 'recover', 'enroll', 'complete'].includes(command.action)) throw new BadRequestException('请先结束战斗'); + switch (command.action) { + case 'snapshot': break; + case 'start': { + if (!command.encounter || !/^(starting_encounter|-?\d+_-?\d+)$/.test(command.encounter)) throw new BadRequestException('无效遭遇'); + if (active) { + if (s.battle.encounter !== command.encounter) throw new BadRequestException('已有进行中的战斗'); + break; + } + if (s.defeated[command.encounter]) throw new BadRequestException('该怪物已被击败'); + if (s.health <= 0) throw new BadRequestException('请返回营地恢复'); + s.battle = { id: randomUUID(), encounter: command.encounter, turn: 0, hearts: 3, status: 'active', last: null }; + break; + } + case 'answer': { + const b = s.battle; + if (!b || command.session !== b.id) throw new BadRequestException('战斗不存在'); + if (b.last && command.turn === b.last.turn) { + if (command.choice !== b.last.choice) throw new BadRequestException('本回合已结算'); + correct = b.last.correct; + break; + } + if (b.status !== 'active' || command.turn !== b.turn || !Number.isInteger(command.choice) || command.choice < 0 || command.choice > 3) throw new BadRequestException('回合或答案无效'); + correct = command.choice === QUESTIONS[b.turn % QUESTIONS.length].answer; + b.last = { turn: b.turn, choice: command.choice, correct }; + b.turn++; + if (correct) b.hearts--; else s.health = Math.max(0, s.health - 10); + if (b.hearts === 0) { + b.status = 'victory'; + s.defeated[b.encounter] = true; + wallet.balance += 10; + } else if (!s.health) b.status = 'defeat'; + break; + } + case 'escape': + if (!s.battle || s.battle.id !== command.session) throw new BadRequestException('战斗不存在'); + if (active) s.battle.status = 'escaped'; + break; + case 'rest': s.health = maxHealth(); break; + case 'recover': + if (s.health < maxHealth()) { spend(5); s.health = Math.min(maxHealth(), s.health + 30); } + break; + case 'enroll': + case 'complete': { + const chapter = (chapters as Record)[command.chapter]; + if (!chapter) throw new BadRequestException('章节不存在'); + if (command.action === 'enroll') { + if (!s.chapters[command.chapter]) { spend(20); s.chapters[command.chapter] = 'enrolled'; } + } else { + if (!s.chapters[command.chapter]) throw new BadRequestException('请先报名'); + if (s.chapters[command.chapter] === 'completed') break; + if (!Array.isArray(command.answers) || command.answers.length !== chapter.answers.length || chapter.answers.some((answer, i) => command.answers[i] !== answer)) throw new BadRequestException('请完成章节练习'); + s.chapters[command.chapter] = 'completed'; + if (s.level < 5) s.experience += 20; + while (s.level < 5 && s.experience >= 20 * (s.level + 1)) { + s.experience -= 20 * (s.level + 1); s.level++; s.health += 5; + } + } + break; + } + default: throw new BadRequestException('未知操作'); + } + s.sequence++; + const b = s.battle; + const question = QUESTIONS[(b?.turn || 0) % QUESTIONS.length]; + return { wallet: { user_id: userId.toString(), balance: wallet.balance, currency: 'whale_coin' }, + progress: { level: s.level, experience: s.experience, health: s.health, max_health: maxHealth(), chapters: s.chapters, defeated: s.defeated }, + battle: b ? { id: b.id, encounter: b.encounter, turn: b.turn, hearts: b.hearts, status: b.status, question: { text: question.text, options: question.options } } : null, + correct, + }; + }); + } +} diff --git a/src/business/player/adventure_chapters.json b/src/business/player/adventure_chapters.json new file mode 100644 index 0000000..bd54e08 --- /dev/null +++ b/src/business/player/adventure_chapters.json @@ -0,0 +1,12 @@ +{ + "hello-agents:chapter-01-第一章-初识智能体": { + "answers": [ + 0 + ] + }, + "happy-llm:chapter-01-第一章-nlp-基础概念": { + "answers": [ + 0 + ] + } +} diff --git a/src/business/player/dto/adventure_command.dto.ts b/src/business/player/dto/adventure_command.dto.ts new file mode 100644 index 0000000..0aa10ef --- /dev/null +++ b/src/business/player/dto/adventure_command.dto.ts @@ -0,0 +1,11 @@ +import { ArrayMaxSize, IsArray, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; + +export class AdventureCommandDto { + @IsIn(['snapshot', 'start', 'answer', 'escape', 'rest', 'recover', 'enroll', 'complete']) action: string; + @IsOptional() @IsString() @MaxLength(100) encounter?: string; + @IsOptional() @IsString() @MaxLength(100) session?: string; + @IsOptional() @IsInt() @Min(0) @Max(1000000) turn?: number; + @IsOptional() @IsInt() @Min(0) @Max(3) choice?: number; + @IsOptional() @IsString() @MaxLength(200) chapter?: string; + @IsOptional() @IsArray() @ArrayMaxSize(100) @IsInt({ each: true }) answers?: number[]; +} diff --git a/src/business/player/player.controller.ts b/src/business/player/player.controller.ts index 3ebc67d..5131491 100644 --- a/src/business/player/player.controller.ts +++ b/src/business/player/player.controller.ts @@ -1,4 +1,7 @@ import { Body, Controller, Get, HttpStatus, Patch, Res, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common'; +import { Post } from '@nestjs/common'; +import { AdventureService } from './adventure.service'; +import { AdventureCommandDto } from './dto/adventure_command.dto'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiResponse as SwaggerApiResponse, ApiTags } from '@nestjs/swagger'; import { Response } from 'express'; import { CurrentUser } from '../../gateway/auth/current_user.decorator'; @@ -18,8 +21,15 @@ export class PlayerController { constructor( private readonly playerStateService: PlayerStateService, private readonly economyService: EconomyService, + private readonly adventureService: AdventureService, ) {} + @Post('adventure') + @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) + async adventure(@CurrentUser() user: JwtPayload, @Body() dto: AdventureCommandDto) { + return { success: true, data: await this.adventureService.execute(BigInt(user.sub), dto) }; + } + @ApiOperation({ summary: '获取当前玩家快照' }) @SwaggerApiResponse({ status: 200, description: '玩家快照获取成功' }) @Get('snapshot') diff --git a/src/business/player/player.module.ts b/src/business/player/player.module.ts index 43ed0fe..fccaad8 100644 --- a/src/business/player/player.module.ts +++ b/src/business/player/player.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { AdventureService } from './adventure.service'; import { AuthModule } from '../auth/auth.module'; import { LoginCoreModule } from '../../core/login_core/login_core.module'; import { InventoryController } from './inventory.controller'; @@ -10,7 +11,7 @@ import { PlayerStateService } from './player_state.service'; @Module({ imports: [AuthModule, LoginCoreModule], controllers: [PlayerController, InventoryController], - providers: [EconomyService, InventoryService, PlayerStateService], + providers: [EconomyService, InventoryService, PlayerStateService, AdventureService], exports: [EconomyService, InventoryService, PlayerStateService], }) export class PlayerModule {} diff --git a/src/core/db/user_wallets/add-adventure-state.sql b/src/core/db/user_wallets/add-adventure-state.sql new file mode 100644 index 0000000..4cb8734 --- /dev/null +++ b/src/core/db/user_wallets/add-adventure-state.sql @@ -0,0 +1,8 @@ +SET @ddl = IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'user_wallets' AND column_name = 'adventure'), + 'SELECT 1', + 'ALTER TABLE user_wallets ADD COLUMN adventure JSON NULL' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/src/core/db/user_wallets/user_wallets.entity.ts b/src/core/db/user_wallets/user_wallets.entity.ts index a5c831e..e029525 100644 --- a/src/core/db/user_wallets/user_wallets.entity.ts +++ b/src/core/db/user_wallets/user_wallets.entity.ts @@ -24,6 +24,9 @@ export class UserWallets { }) balance: number; + @Column({ type: 'json', nullable: true }) + adventure: Record | null; + @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', diff --git a/src/core/db/user_wallets/user_wallets.service.ts b/src/core/db/user_wallets/user_wallets.service.ts index 1200b34..e4122ec 100644 --- a/src/core/db/user_wallets/user_wallets.service.ts +++ b/src/core/db/user_wallets/user_wallets.service.ts @@ -31,6 +31,28 @@ export class UserWalletsService { private readonly walletTransactionsRepository: Repository, ) {} + async mutateAdventure(userId: bigint, operation: (wallet: UserWallets) => T): Promise { + await this.ensureWallet(userId); + return this.userWalletsRepository.manager.transaction(async manager => { + const wallet = await manager.findOneOrFail(UserWallets, { + where: { user_id: userId }, lock: { mode: 'pessimistic_write' }, + }); + const before = wallet.balance; + const result = operation(wallet); + wallet.updated_at = new Date(); + await manager.save(UserWallets, wallet); + if (wallet.balance !== before) { + await manager.save(WalletTransactions, manager.create(WalletTransactions, { + user_id: userId, type: wallet.balance > before ? 'earn' : 'spend', + amount: wallet.balance - before, balance_after: wallet.balance, + reference_type: 'adventure', reference_id: String(wallet.adventure?.sequence || 0), + note: '战斗与学习结算', created_at: new Date(), + })); + } + return result; + }); + } + async ensureWallet(userId: bigint): Promise { const existing = await this.userWalletsRepository.findOne({ where: { user_id: userId }, @@ -39,14 +61,21 @@ export class UserWalletsService { return existing; } - const wallet = new UserWallets(); - wallet.user_id = userId; - wallet.balance = DEFAULT_INITIAL_WHALE_COINS; - wallet.created_at = new Date(); - wallet.updated_at = new Date(); - const savedWallet = await this.userWalletsRepository.save(wallet); - await this.createTransaction(userId, 'grant', DEFAULT_INITIAL_WHALE_COINS, savedWallet.balance, 'registration', 'initial_wallet', '新用户初始鲸币'); - return savedWallet; + return this.userWalletsRepository.manager.transaction(async manager => { + const inserted = await manager.createQueryBuilder().insert().into(UserWallets).values({ + user_id: userId, balance: DEFAULT_INITIAL_WHALE_COINS, + created_at: new Date(), updated_at: new Date(), + }).orIgnore().execute(); + const wallet = await manager.findOneOrFail(UserWallets, { where: { user_id: userId } }); + if (inserted.raw.affectedRows === 1) { + await manager.save(WalletTransactions, manager.create(WalletTransactions, { + user_id: userId, type: 'grant', amount: DEFAULT_INITIAL_WHALE_COINS, + balance_after: DEFAULT_INITIAL_WHALE_COINS, reference_type: 'registration', + reference_id: 'initial_wallet', note: '新用户初始鲸币', created_at: new Date(), + })); + } + return wallet; + }); } async getBalance(userId: bigint): Promise { @@ -63,20 +92,7 @@ export class UserWalletsService { throw new BadRequestException('鲸币消费数量不正确'); } - const wallet = await this.ensureWallet(userId); - if (wallet.balance < amount) { - throw new BadRequestException('鲸币余额不足'); - } - - wallet.balance -= amount; - wallet.updated_at = new Date(); - const savedWallet = await this.userWalletsRepository.save(wallet); - const transaction = await this.createTransaction(userId, 'spend', -amount, savedWallet.balance, referenceType, referenceId, note || ''); - - return { - wallet: savedWallet, - transaction, - }; + return this.changeBalance(userId, -amount, referenceType, referenceId, note || ''); } async earn(userId: bigint, amount: number, referenceType: string, referenceId: string, note?: string): Promise { @@ -84,16 +100,24 @@ export class UserWalletsService { throw new BadRequestException('鲸币收入数量不正确'); } - const wallet = await this.ensureWallet(userId); - wallet.balance += amount; - wallet.updated_at = new Date(); - const savedWallet = await this.userWalletsRepository.save(wallet); - const transaction = await this.createTransaction(userId, 'earn', amount, savedWallet.balance, referenceType, referenceId, note || ''); + return this.changeBalance(userId, amount, referenceType, referenceId, note || ''); + } - return { - wallet: savedWallet, - transaction, - }; + private async changeBalance(userId: bigint, delta: number, referenceType: string, referenceId: string, note: string): Promise { + await this.ensureWallet(userId); + return this.userWalletsRepository.manager.transaction(async manager => { + const wallet = await manager.findOneOrFail(UserWallets, { where: { user_id: userId }, lock: { mode: 'pessimistic_write' } }); + if (wallet.balance + delta < 0) throw new BadRequestException('鲸币余额不足'); + wallet.balance += delta; + wallet.updated_at = new Date(); + await manager.save(UserWallets, wallet); + const transaction = await manager.save(WalletTransactions, manager.create(WalletTransactions, { + user_id: userId, type: delta < 0 ? 'spend' : 'earn', amount: delta, + balance_after: wallet.balance, reference_type: referenceType, reference_id: referenceId, + note, created_at: new Date(), + })); + return { wallet, transaction }; + }); } private async createTransaction( diff --git a/src/core/db/user_wallets/user_wallets_memory.service.ts b/src/core/db/user_wallets/user_wallets_memory.service.ts index f565b02..1dd13a5 100644 --- a/src/core/db/user_wallets/user_wallets_memory.service.ts +++ b/src/core/db/user_wallets/user_wallets_memory.service.ts @@ -5,6 +5,24 @@ import { WalletTransactions } from './wallet_transactions.entity'; @Injectable() export class UserWalletsMemoryService { + private adventureQueues = new Map>(); + + async mutateAdventure(userId: bigint, operation: (wallet: UserWallets) => T): Promise { + const previous = this.adventureQueues.get(userId) || Promise.resolve(); + const next = previous.catch(() => {}).then(async () => { + const original = await this.ensureWallet(userId); + const draft = { ...original, adventure: JSON.parse(JSON.stringify(original.adventure || null)) }; + const result = operation(draft); + const delta = draft.balance - original.balance; + Object.assign(original, draft); + if (delta) await this.createTransaction(userId, delta > 0 ? 'earn' : 'spend', delta, draft.balance, 'adventure', String(draft.adventure?.sequence || 0), '战斗与学习结算'); + return result; + }); + this.adventureQueues.set(userId, next); + try { return await next; } finally { + if (this.adventureQueues.get(userId) === next) this.adventureQueues.delete(userId); + } + } private wallets: Map = new Map(); private transactions: WalletTransactions[] = []; private currentWalletId: bigint = BigInt(1);