feat: 引入事务悲观锁(lockForUpdate)与余额二次校验,防范高并发资产越权与透支漏洞

This commit is contained in:
pllx
2026-06-30 11:32:52 +08:00
parent d1409d16bb
commit 3563b45038
4 changed files with 132 additions and 42 deletions
+22 -7
View File
@@ -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);
+75 -27
View File
@@ -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,
]);
}
+16 -1
View File
@@ -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);
+19 -7
View File
@@ -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);
});
}