- 扩大玩家ID随机数位宽降低碰撞概率 - 授信额度变更加 lockForUpdate 防并发下调 - 结算冲正与入账利用唯一索引实现幂等闸门 - 失败登录计数加锁防丢失 - 代理子树查询改用子查询替代全表 pluck - 修复返点默认值百分比单位转换 - 测试中全量 fake 广播事件防 CI 500
This commit is contained in:
@@ -127,11 +127,19 @@ final class AgentSiteProvisioningService
|
||||
AgentPlatformRole::assignPrimaryOperator($user, $node);
|
||||
|
||||
$defaults = config('agent_line_defaults', []);
|
||||
// rebate_limit / default_player_rebate 在 DB 存储为小数(0-1),但 controller 与 field rules
|
||||
// 按百分比(0-100)入参,upsertForNode 内部会做 /100;故透传 config 默认值时需 *100 转回。
|
||||
$defaultRebateLimitPct = isset($defaults['rebate_limit']) ? (float) $defaults['rebate_limit'] * 100 : 0.0;
|
||||
$defaultPlayerRebatePct = isset($defaults['default_player_rebate']) ? (float) $defaults['default_player_rebate'] * 100 : 0.0;
|
||||
$this->agentProfileService->upsertForNode($node, [
|
||||
'total_share_rate' => (float) ($payload['total_share_rate'] ?? $defaults['total_share_rate'] ?? 100),
|
||||
'credit_limit' => (int) ($payload['credit_limit'] ?? $defaults['credit_limit'] ?? 0),
|
||||
'rebate_limit' => (float) ($payload['rebate_limit'] ?? $defaults['rebate_limit'] ?? 0),
|
||||
'default_player_rebate' => (float) ($payload['default_player_rebate'] ?? $defaults['default_player_rebate'] ?? 0),
|
||||
'rebate_limit' => array_key_exists('rebate_limit', $payload)
|
||||
? (float) $payload['rebate_limit']
|
||||
: $defaultRebateLimitPct,
|
||||
'default_player_rebate' => array_key_exists('default_player_rebate', $payload)
|
||||
? (float) $payload['default_player_rebate']
|
||||
: $defaultPlayerRebatePct,
|
||||
'can_grant_extra_rebate' => (bool) ($payload['can_grant_extra_rebate'] ?? true),
|
||||
'can_create_child_agent' => (bool) ($payload['can_create_child_agent'] ?? true),
|
||||
'can_create_player' => (bool) ($payload['can_create_player'] ?? true),
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Models\Player;
|
||||
use App\Models\TicketItem;
|
||||
use App\Services\Player\PlayerCreditService;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class GameSettlementReversalService
|
||||
@@ -24,21 +25,32 @@ final class GameSettlementReversalService
|
||||
$settledAt = now();
|
||||
|
||||
DB::transaction(function () use ($item, $ledger, $settledAt): void {
|
||||
DB::table('share_ledger')->insert([
|
||||
'ticket_item_id' => $item->id,
|
||||
'player_id' => $ledger->player_id,
|
||||
'agent_node_id' => $ledger->agent_node_id,
|
||||
'agent_path' => $ledger->agent_path,
|
||||
'share_snapshot' => $ledger->share_snapshot,
|
||||
'game_win_loss' => -1 * (int) $ledger->game_win_loss,
|
||||
'basic_rebate' => -1 * (int) $ledger->basic_rebate,
|
||||
'shared_net_win_loss' => -1 * (int) $ledger->shared_net_win_loss,
|
||||
'allocations_json' => $ledger->allocations_json,
|
||||
'reversal_of_id' => $ledger->id,
|
||||
'settled_at' => $settledAt,
|
||||
'created_at' => $settledAt,
|
||||
'updated_at' => $settledAt,
|
||||
]);
|
||||
// 幂等闸门:share_ledger.reversal_of_id 上有 partial unique 索引,
|
||||
// 同一原账不允许插入多条反转记录。重复调用时唯一约束冲突即视为已反转、跳过。
|
||||
// 一次只让 reversal_of_id 出现一次写入,确保所有后续副作用(rebate、credit 冲正)
|
||||
// 只在唯一反转成功路径上执行。
|
||||
try {
|
||||
DB::table('share_ledger')->insert([
|
||||
'ticket_item_id' => $item->id,
|
||||
'player_id' => $ledger->player_id,
|
||||
'agent_node_id' => $ledger->agent_node_id,
|
||||
'agent_path' => $ledger->agent_path,
|
||||
'share_snapshot' => $ledger->share_snapshot,
|
||||
'game_win_loss' => -1 * (int) $ledger->game_win_loss,
|
||||
'basic_rebate' => -1 * (int) $ledger->basic_rebate,
|
||||
'shared_net_win_loss' => -1 * (int) $ledger->shared_net_win_loss,
|
||||
'allocations_json' => $ledger->allocations_json,
|
||||
'reversal_of_id' => $ledger->id,
|
||||
'settled_at' => $settledAt,
|
||||
'created_at' => $settledAt,
|
||||
'updated_at' => $settledAt,
|
||||
]);
|
||||
} catch (QueryException $e) {
|
||||
if ($this->isUniqueViolation($e)) {
|
||||
return;
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$rebates = DB::table('rebate_records')
|
||||
->where('ticket_item_id', $item->id)
|
||||
@@ -72,4 +84,31 @@ final class GameSettlementReversalService
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别数据库唯一约束冲突(PostgreSQL SQLSTATE 23505;SQLite 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 23505;SQLite 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Lottery\ErrorCode;
|
||||
use App\Models\Player;
|
||||
use App\Support\PlayerAuthSource;
|
||||
use Firebase\JWT\JWT;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use App\Exceptions\PlayerAuthenticationException;
|
||||
|
||||
@@ -114,15 +115,31 @@ final class PlayerNativeAuthService
|
||||
{
|
||||
$max = (int) config('lottery.player_auth.native.max_login_attempts', 8);
|
||||
$lockMinutes = (int) config('lottery.player_auth.native.lock_minutes', 15);
|
||||
$count = (int) $player->login_failed_count + 1;
|
||||
|
||||
$updates = ['login_failed_count' => $count];
|
||||
if ($count >= $max) {
|
||||
$updates['login_locked_until'] = now()->addMinutes($lockMinutes);
|
||||
$updates['login_failed_count'] = 0;
|
||||
}
|
||||
// 并发场景下两个错误登录请求都拿到旧 login_failed_count 各自 +1 时,会出现 last-write-wins
|
||||
// 丢失一次失败计数(影响锁定判定准确性)。用 lockForUpdate 取最新计数后再写。
|
||||
\DB::transaction(function () use ($player, $max, $lockMinutes): void {
|
||||
$latest = Player::query()
|
||||
->whereKey((int) $player->id)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
$player->forceFill($updates)->save();
|
||||
if ($latest === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$count = (int) $latest->login_failed_count + 1;
|
||||
|
||||
$updates = ['login_failed_count' => $count];
|
||||
if ($count >= $max) {
|
||||
$updates['login_locked_until'] = now()->addMinutes($lockMinutes);
|
||||
$updates['login_failed_count'] = 0;
|
||||
}
|
||||
|
||||
Player::query()
|
||||
->whereKey((int) $player->id)
|
||||
->update($updates);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user