fix: 修复并发竞态与幂等性缺陷并优化子树查询
Some checks failed
lotterLaravel CI / test (push) Has been cancelled

- 扩大玩家ID随机数位宽降低碰撞概率
- 授信额度变更加 lockForUpdate 防并发下调
- 结算冲正与入账利用唯一索引实现幂等闸门
- 失败登录计数加锁防丢失
- 代理子树查询改用子查询替代全表 pluck
- 修复返点默认值百分比单位转换
- 测试中全量 fake 广播事件防 CI 500
This commit is contained in:
2026-06-18 09:38:24 +08:00
parent 2e0b257160
commit 6b2ea39ea1
13 changed files with 400 additions and 135 deletions

View File

@@ -6,6 +6,7 @@ use App\Models\Player;
use App\Support\AgentOverdueGuard;
use App\Support\CreditAmountScale;
use App\Support\PlayerFundingMode;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
@@ -18,36 +19,43 @@ final class PlayerCreditService
{
$limit = max(0, (int) ($payload['credit_limit'] ?? 0));
$now = now();
$row = DB::table('player_credit_accounts')
->where('player_id', $player->id)
->first();
if ($row !== null) {
$usedTotal = (int) $row->used_credit + (int) $row->frozen_credit;
if ($limit < $usedTotal) {
throw ValidationException::withMessages([
'credit_limit' => ['below_player_used'],
]);
// 整段包在事务中:并发下调额(先 SELECT used_credit 后 UPDATE若不加锁
// 两个请求都拿到旧 used_credit 并各自通过 below_player_used 校验,导致额度被收缩到比已用更小。
// lockForUpdate 保证同时只有一个请求能修改该玩家授信记录。
DB::transaction(function () use ($player, $limit, $now): void {
$row = DB::table('player_credit_accounts')
->where('player_id', $player->id)
->lockForUpdate()
->first();
if ($row !== null) {
$usedTotal = (int) $row->used_credit + (int) $row->frozen_credit;
if ($limit < $usedTotal) {
throw ValidationException::withMessages([
'credit_limit' => ['below_player_used'],
]);
}
DB::table('player_credit_accounts')
->where('player_id', $player->id)
->update([
'credit_limit' => $limit,
'updated_at' => $now,
]);
return;
}
DB::table('player_credit_accounts')
->where('player_id', $player->id)
->update([
'credit_limit' => $limit,
'updated_at' => $now,
]);
return;
}
DB::table('player_credit_accounts')->insert([
'player_id' => $player->id,
'credit_limit' => $limit,
'used_credit' => 0,
'frozen_credit' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $player->id,
'credit_limit' => $limit,
'used_credit' => 0,
'frozen_credit' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
});
}
/** 可用授信(主货币整数,与后台「授信额度」一致)。 */
@@ -163,23 +171,43 @@ final class PlayerCreditService
$majorDelta = CreditAmountScale::minorToMajor($amountMinor, $currency);
$now = now();
// 先写 credit_ledger以 (ref_type, ref_id) partial unique 索引为幂等闸门。
// 已存在同 (ticket_item, game_settlement_loss) 的流水则直接返回,避免并发/重入场景下重复扣减 used_credit。
try {
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => -$amountMinor,
'reason' => 'game_settlement_loss',
'ref_type' => 'ticket_item',
'ref_id' => $ticketItemId,
'created_at' => $now,
'updated_at' => $now,
]);
} catch (QueryException $e) {
// 23505 (pgsql) / 23000/19 (sqlite) 唯一约束冲突即幂等成功,跳过余额更新。
if ($this->isUniqueViolation($e)) {
return;
}
throw $e;
}
// 余额变更在 lockForUpdate 保护下做条件 update。
$row = DB::table('player_credit_accounts')
->where('player_id', $player->id)
->lockForUpdate()
->first();
if ($row === null) {
return;
}
DB::table('player_credit_accounts')
->where('player_id', $player->id)
->update([
'used_credit' => DB::raw('used_credit + '.$majorDelta),
'used_credit' => (int) $row->used_credit + $majorDelta,
'updated_at' => $now,
]);
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => -$amountMinor,
'reason' => 'game_settlement_loss',
'ref_type' => 'ticket_item',
'ref_id' => $ticketItemId,
'created_at' => now(),
'updated_at' => now(),
]);
}
public function applySettledWin(Player $player, int $amountMinor, int $ticketItemId): void
@@ -337,18 +365,26 @@ final class PlayerCreditService
return;
}
$this->decreaseUsedCredit($player, $amountMinor);
// 幂等闸门credit_ledger (settlement_bill, settlement_confirm) 唯一;重复调用直接返回。
try {
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => $amountMinor,
'reason' => 'settlement_confirm',
'ref_type' => 'settlement_bill',
'ref_id' => $billId,
'created_at' => now(),
'updated_at' => now(),
]);
} catch (QueryException $e) {
if ($this->isUniqueViolation($e)) {
return;
}
throw $e;
}
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => $amountMinor,
'reason' => 'settlement_confirm',
'ref_type' => 'settlement_bill',
'ref_id' => $billId,
'created_at' => now(),
'updated_at' => now(),
]);
$this->decreaseUsedCredit($player, $amountMinor);
}
public function applySettlementPayout(Player $player, int $amountMinor, int $billId): void
@@ -361,16 +397,24 @@ final class PlayerCreditService
return;
}
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => $amountMinor,
'reason' => 'settlement_payout',
'ref_type' => 'settlement_bill',
'ref_id' => $billId,
'created_at' => now(),
'updated_at' => now(),
]);
// 幂等闸门credit_ledger (settlement_bill, settlement_payout) 唯一;重复入账直接返回。
try {
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => $amountMinor,
'reason' => 'settlement_payout',
'ref_type' => 'settlement_bill',
'ref_id' => $billId,
'created_at' => now(),
'updated_at' => now(),
]);
} catch (QueryException $e) {
if ($this->isUniqueViolation($e)) {
return;
}
throw $e;
}
}
private function decreaseUsedCredit(Player $player, int $amountMinor): void
@@ -398,4 +442,31 @@ final class PlayerCreditService
'updated_at' => now(),
]);
}
/**
* 识别数据库唯一约束冲突PostgreSQL SQLSTATE 23505SQLite SQLSTATE 23000/error code 19)。
* 兜底扫描消息中 unique 字样,兼容驱动返回的 SQLSTATE 缺失场景。
*/
private function isUniqueViolation(QueryException $e): bool
{
$sqlState = (string) $e->getCode();
$errorInfo = $e->errorInfo ?? null;
$driverCode = is_array($errorInfo) ? (int) ($errorInfo[1] ?? 0) : 0;
if ($sqlState === '23505') {
return true;
}
if ($sqlState === '23000' || $sqlState === '19') {
return true;
}
if ($driverCode === 19 || $driverCode === 1062) {
return true;
}
$message = $e->getMessage();
if (stripos($message, 'unique') !== false || stripos($message, 'UNIQUE constraint') !== false) {
return true;
}
return false;
}
}