diff --git a/app/Http/Controllers/BaccaratController.php b/app/Http/Controllers/BaccaratController.php index b8b894c..ffa5e3a 100644 --- a/app/Http/Controllers/BaccaratController.php +++ b/app/Http/Controllers/BaccaratController.php @@ -19,6 +19,7 @@ use App\Enums\CurrencySource; use App\Models\BaccaratBet; use App\Models\BaccaratRound; use App\Models\GameConfig; +use App\Models\User; use App\Services\BaccaratLossCoverService; use App\Services\GameBetBroadcastService; use App\Services\GameRoomScopeService; @@ -133,7 +134,7 @@ class BaccaratController extends Controller $user = $request->user(); - // 检查用户金币余额(金币字段为 jjb) + // 快速过滤(非锁) if (($user->jjb ?? 0) < $data['amount']) { return response()->json(['ok' => false, 'message' => '金币不足,无法下注。']); } @@ -142,10 +143,21 @@ class BaccaratController extends Controller $lossCoverService = $this->lossCoverService; return DB::transaction(function () use ($user, $round, $data, $currency, $lossCoverService): JsonResponse { - // 幂等:同一局只能下一注 + // 1. 悲观锁锁定用户行 + $lockedUser = User::query() + ->whereKey($user->id) + ->lockForUpdate() + ->firstOrFail(); + + // 2. 锁保护下二次校验余额 + if ((int) $lockedUser->jjb < $data['amount']) { + return response()->json(['ok' => false, 'message' => '金币不足,无法下注。']); + } + + // 3. 幂等:同一局只能下一注 $existing = BaccaratBet::query() ->where('round_id', $round->id) - ->where('user_id', $user->id) + ->where('user_id', $lockedUser->id) ->lockForUpdate() ->exists(); @@ -153,9 +165,9 @@ class BaccaratController extends Controller return response()->json(['ok' => false, 'message' => '本局您已下注,请等待开奖。']); } - // 扣除金币 + // 4. 扣除金币,传入锁定的用户实例 $currency->change( - $user, + $lockedUser, 'gold', -$data['amount'], CurrencySource::BACCARAT_BET, @@ -167,16 +179,19 @@ class BaccaratController extends Controller // 下注时间命中活动窗口时,将本次下注挂到对应的买单活动下。 $lossCoverEvent = $lossCoverService->findEventForBetTime(now()); - // 写入下注记录 + // 5. 写入下注记录 $bet = BaccaratBet::create([ 'round_id' => $round->id, - 'user_id' => $user->id, + 'user_id' => $lockedUser->id, 'loss_cover_event_id' => $lossCoverEvent?->id, 'bet_type' => $data['bet_type'], 'amount' => $data['amount'], 'status' => 'pending', ]); + // 同步修改 Auth 内存实例的金币 + $user->setAttribute('jjb', $lockedUser->jjb); + // 命中活动的下注要同步累计到用户活动记录中,便于后续前台查看。 $lossCoverService->registerBet($bet); diff --git a/app/Http/Controllers/BankController.php b/app/Http/Controllers/BankController.php index 21e497c..c3d9f73 100644 --- a/app/Http/Controllers/BankController.php +++ b/app/Http/Controllers/BankController.php @@ -17,7 +17,6 @@ use App\Enums\CurrencySource; use App\Models\BankLog; use App\Models\Sysparam; use App\Models\User; -use App\Models\UserCurrencyLog; use App\Services\UserCurrencyService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -32,6 +31,7 @@ class BankController extends Controller public function __construct( private readonly UserCurrencyService $currencyService, ) {} + /** * 查询银行余额及最近20条流水记录 */ @@ -105,6 +105,7 @@ class BankController extends Controller $amount = $request->integer('amount'); $user = Auth::user(); + // 快速过滤(非锁),降低非法请求穿透到数据库的概率 if (($user->jjb ?? 0) < $amount) { return response()->json([ 'status' => 'error', @@ -112,26 +113,49 @@ class BankController extends Controller ]); } - DB::transaction(function () use ($user, $amount): void { - $this->currencyService->change($user, 'gold', -$amount, CurrencySource::BANK_DEPOSIT, "存入银行 {$amount} 金币"); + try { + DB::transaction(function () use ($user, $amount): void { + // 1. 强制在数据库层面对用户行数据加写锁(X锁) + $lockedUser = User::query() + ->whereKey($user->id) + ->lockForUpdate() + ->firstOrFail(); - $user->increment('bank_jjb', $amount); + // 2. 在锁保护下安全校验最新余额 + if ((int) $lockedUser->jjb < $amount) { + throw new \Exception('流通金币不足!当前余额 '.(int) $lockedUser->jjb." 枚,无法存入 {$amount} 枚。"); + } - BankLog::create([ - 'user_id' => $user->id, - 'type' => 'deposit', - 'amount' => $amount, - 'balance_after' => $user->fresh()->bank_jjb, + // 3. 执行资产扣除,将已加锁的 lockedUser 传给 change 方法 + $this->currencyService->change($lockedUser, 'gold', -$amount, CurrencySource::BANK_DEPOSIT, "存入银行 {$amount} 金币"); + + // 4. 增加银行余额 + $lockedUser->increment('bank_jjb', $amount); + + // 5. 写入银行流水记录 + BankLog::create([ + 'user_id' => $lockedUser->id, + 'type' => 'deposit', + 'amount' => $amount, + 'balance_after' => $lockedUser->fresh()->bank_jjb, + ]); + + // 6. 同步 Auth 内存状态,保障同生命周期内其他地方拿到的是正确数据 + $user->setAttribute('jjb', $lockedUser->jjb); + $user->setAttribute('bank_jjb', $lockedUser->bank_jjb); + }); + } catch (\Exception $e) { + return response()->json([ + 'status' => 'error', + 'message' => $e->getMessage(), ]); - }); - - $fresh = $user->fresh(); + } return response()->json([ 'status' => 'success', 'message' => "成功存入 {$amount} 枚金币!", - 'jjb' => $fresh->jjb, - 'bank_jjb' => $fresh->bank_jjb, + 'jjb' => $user->jjb, + 'bank_jjb' => $user->bank_jjb, ]); } @@ -149,6 +173,7 @@ class BankController extends Controller $amount = $request->integer('amount'); $user = Auth::user(); + // 快速过滤(非锁) if (($user->bank_jjb ?? 0) < $amount) { return response()->json([ 'status' => 'error', @@ -156,26 +181,49 @@ class BankController extends Controller ]); } - DB::transaction(function () use ($user, $amount): void { - $user->decrement('bank_jjb', $amount); + try { + DB::transaction(function () use ($user, $amount): void { + // 1. 强制在数据库层面对用户行数据加写锁(X锁) + $lockedUser = User::query() + ->whereKey($user->id) + ->lockForUpdate() + ->firstOrFail(); - $this->currencyService->change($user, 'gold', $amount, CurrencySource::BANK_WITHDRAW, "取出银行存款 {$amount} 金币"); + // 2. 校验银行存款是否足够 + if ((int) $lockedUser->bank_jjb < $amount) { + throw new \Exception('银行余额不足!当前存款 '.($lockedUser->bank_jjb ?? 0)." 枚,无法取出 {$amount} 枚。"); + } - BankLog::create([ - 'user_id' => $user->id, - 'type' => 'withdraw', - 'amount' => $amount, - 'balance_after' => $user->fresh()->bank_jjb, + // 3. 扣除银行存款 + $lockedUser->decrement('bank_jjb', $amount); + + // 4. 增加流通金币并记录划转日志 + $this->currencyService->change($lockedUser, 'gold', $amount, CurrencySource::BANK_WITHDRAW, "取出银行存款 {$amount} 金币"); + + // 5. 记录银行账户流水 + BankLog::create([ + 'user_id' => $lockedUser->id, + 'type' => 'withdraw', + 'amount' => $amount, + 'balance_after' => $lockedUser->fresh()->bank_jjb, + ]); + + // 6. 同步 Auth 内存状态 + $user->setAttribute('jjb', $lockedUser->jjb); + $user->setAttribute('bank_jjb', $lockedUser->bank_jjb); + }); + } catch (\Exception $e) { + return response()->json([ + 'status' => 'error', + 'message' => $e->getMessage(), ]); - }); - - $fresh = $user->fresh(); + } return response()->json([ 'status' => 'success', 'message' => "成功取出 {$amount} 枚金币!", - 'jjb' => $fresh->jjb, - 'bank_jjb' => $fresh->bank_jjb, + 'jjb' => $user->jjb, + 'bank_jjb' => $user->bank_jjb, ]); } diff --git a/app/Http/Controllers/GomokuController.php b/app/Http/Controllers/GomokuController.php index a249891..4839c5b 100644 --- a/app/Http/Controllers/GomokuController.php +++ b/app/Http/Controllers/GomokuController.php @@ -24,6 +24,7 @@ use App\Events\GomokuInviteEvent; use App\Events\GomokuMovedEvent; use App\Models\GameConfig; use App\Models\GomokuGame; +use App\Models\User; use App\Services\GameRoomScopeService; use App\Services\GomokuAiService; use App\Services\UserCurrencyService; @@ -92,13 +93,27 @@ class GomokuController extends Controller return DB::transaction(function () use ($user, $data, $entryFee): JsonResponse { // PvE 扣除入场费 if ($entryFee > 0) { + // 1. 悲观锁锁定用户行 + $lockedUser = User::query() + ->whereKey($user->id) + ->lockForUpdate() + ->firstOrFail(); + + // 2. 二次确认金币是否足够 + if ((int) $lockedUser->jjb < $entryFee) { + return response()->json(['ok' => false, 'message' => '金币不足,无法加入游戏对局。']); + } + $this->currency->change( - $user, + $lockedUser, 'gold', -$entryFee, CurrencySource::GOMOKU_ENTRY_FEE, "五子棋 AI 对战入场费(难度{$data['ai_level']})", ); + + // 同步修改 Auth 内存实例的金币 + $user->setAttribute('jjb', $lockedUser->jjb); } $timeout = (int) GameConfig::param('gomoku', 'invite_timeout', 60); diff --git a/app/Services/UserCurrencyService.php b/app/Services/UserCurrencyService.php index b0c49dd..f5b04dd 100644 --- a/app/Services/UserCurrencyService.php +++ b/app/Services/UserCurrencyService.php @@ -65,21 +65,30 @@ class UserCurrencyService } DB::transaction(function () use ($user, $currency, $amount, $source, $remark, $roomId, $field) { - // 原子性更新用户属性(用 increment/decrement 防并发竞态) + // 原子性更新用户属性(用 lockForUpdate 悲观锁锁定对应的数据库行) + $lockedUser = User::query() + ->whereKey($user->id) + ->lockForUpdate() + ->firstOrFail(); + if ($amount > 0) { - $user->increment($field, $amount); + $lockedUser->increment($field, $amount); } else { - // 扣除时不让余额低于 0 - $user->decrement($field, min(abs($amount), $user->$field ?? 0)); + // 扣除时不让余额低于 0,基于锁定的最新数据计算 + $currentBalance = (int) $lockedUser->$field; + $deductAmount = min(abs($amount), $currentBalance); + if ($deductAmount > 0) { + $lockedUser->decrement($field, $deductAmount); + } } // 重新读取最新余额(避免缓存脏数据) - $balanceAfter = (int) $user->fresh()->$field; + $balanceAfter = (int) $lockedUser->fresh()->$field; // 写入流水记录(快照当前用户名,排行 JOIN 时再取最新名) UserCurrencyLog::create([ - 'user_id' => $user->id, - 'username' => $user->username, + 'user_id' => $lockedUser->id, + 'username' => $lockedUser->username, 'currency' => $currency, 'amount' => $amount, 'balance_after' => $balanceAfter, @@ -87,6 +96,9 @@ class UserCurrencyService 'remark' => $remark, 'room_id' => $roomId, ]); + + // 同步修改内存中 $user 的实例属性,避免后续逻辑拿到过期的数据 + $user->setAttribute($field, $balanceAfter); }); }