From 2799b2ecb8903033f8b8a24b3f6828cf569d0fe7 Mon Sep 17 00:00:00 2001 From: xiangwang Date: Sat, 19 Sep 2026 04:21:53 +0800 Subject: [PATCH] Revert "feat: deploy adventure wallet progression" This reverts commit 37c97708e34cb67ba23b403c6d8f80495040e453. --- .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, 32 insertions(+), 362 deletions(-) delete mode 100644 ADVENTURE.md delete mode 100644 src/business/player/adventure.service.spec.ts delete mode 100644 src/business/player/adventure.service.ts delete mode 100644 src/business/player/adventure_chapters.json delete mode 100644 src/business/player/dto/adventure_command.dto.ts delete mode 100644 src/core/db/user_wallets/add-adventure-state.sql diff --git a/.gitignore b/.gitignore index 6af078e..3e0dd68 100644 --- a/.gitignore +++ b/.gitignore @@ -75,4 +75,3 @@ Thumbs.db *~ !src/business/auth/skin_defaults.spec.ts -!src/business/player/adventure.service.spec.ts diff --git a/ADVENTURE.md b/ADVENTURE.md deleted file mode 100644 index 1debb1f..0000000 --- a/ADVENTURE.md +++ /dev/null @@ -1,49 +0,0 @@ -# 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 192073e..86834cd 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,6 @@ "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 b6cbe1f..c4ba395 100644 --- a/scripts/run_migrations.ts +++ b/scripts/run_migrations.ts @@ -3,7 +3,6 @@ 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 deleted file mode 100644 index b0e121e..0000000 --- a/src/business/player/adventure.service.spec.ts +++ /dev/null @@ -1,89 +0,0 @@ -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 deleted file mode 100644 index 327d7e3..0000000 --- a/src/business/player/adventure.service.ts +++ /dev/null @@ -1,102 +0,0 @@ -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 deleted file mode 100644 index bd54e08..0000000 --- a/src/business/player/adventure_chapters.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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 deleted file mode 100644 index 0aa10ef..0000000 --- a/src/business/player/dto/adventure_command.dto.ts +++ /dev/null @@ -1,11 +0,0 @@ -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 5131491..3ebc67d 100644 --- a/src/business/player/player.controller.ts +++ b/src/business/player/player.controller.ts @@ -1,7 +1,4 @@ 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'; @@ -21,15 +18,8 @@ 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 fccaad8..43ed0fe 100644 --- a/src/business/player/player.module.ts +++ b/src/business/player/player.module.ts @@ -1,5 +1,4 @@ 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'; @@ -11,7 +10,7 @@ import { PlayerStateService } from './player_state.service'; @Module({ imports: [AuthModule, LoginCoreModule], controllers: [PlayerController, InventoryController], - providers: [EconomyService, InventoryService, PlayerStateService, AdventureService], + providers: [EconomyService, InventoryService, PlayerStateService], 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 deleted file mode 100644 index 4cb8734..0000000 --- a/src/core/db/user_wallets/add-adventure-state.sql +++ /dev/null @@ -1,8 +0,0 @@ -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 e029525..a5c831e 100644 --- a/src/core/db/user_wallets/user_wallets.entity.ts +++ b/src/core/db/user_wallets/user_wallets.entity.ts @@ -24,9 +24,6 @@ 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 e4122ec..1200b34 100644 --- a/src/core/db/user_wallets/user_wallets.service.ts +++ b/src/core/db/user_wallets/user_wallets.service.ts @@ -31,28 +31,6 @@ 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 }, @@ -61,21 +39,14 @@ export class UserWalletsService { return existing; } - 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; - }); + 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; } async getBalance(userId: bigint): Promise { @@ -92,7 +63,20 @@ export class UserWalletsService { throw new BadRequestException('鲸币消费数量不正确'); } - return this.changeBalance(userId, -amount, referenceType, referenceId, note || ''); + 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, + }; } async earn(userId: bigint, amount: number, referenceType: string, referenceId: string, note?: string): Promise { @@ -100,24 +84,16 @@ export class UserWalletsService { throw new BadRequestException('鲸币收入数量不正确'); } - return this.changeBalance(userId, amount, referenceType, referenceId, note || ''); - } + 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 || ''); - 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 }; - }); + return { + wallet: savedWallet, + 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 1dd13a5..f565b02 100644 --- a/src/core/db/user_wallets/user_wallets_memory.service.ts +++ b/src/core/db/user_wallets/user_wallets_memory.service.ts @@ -5,24 +5,6 @@ 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);