- 扩大玩家ID随机数位宽降低碰撞概率 - 授信额度变更加 lockForUpdate 防并发下调 - 结算冲正与入账利用唯一索引实现幂等闸门 - 失败登录计数加锁防丢失 - 代理子树查询改用子查询替代全表 pluck - 修复返点默认值百分比单位转换 - 测试中全量 fake 广播事件防 CI 500
This commit is contained in:
@@ -234,8 +234,11 @@ final class AdminPlayerStoreController extends Controller
|
|||||||
$prefix = strtoupper(substr(preg_replace('/[^A-Za-z]/', '', $siteCode) ?: 'LP', 0, 2));
|
$prefix = strtoupper(substr(preg_replace('/[^A-Za-z]/', '', $siteCode) ?: 'LP', 0, 2));
|
||||||
$prefix = str_pad($prefix, 2, 'P');
|
$prefix = str_pad($prefix, 2, 'P');
|
||||||
|
|
||||||
|
// 使用 10 位数字(100 亿组合)显著降低并发碰撞概率;即使极小概率撞上,
|
||||||
|
// 上层 Player::create 有 (site_code, site_player_id) 唯一索引兜底,
|
||||||
|
// 错误会在调用方被显式处理为 422/500,而非静默双写。
|
||||||
do {
|
do {
|
||||||
$candidate = sprintf('%s%06d', $prefix, random_int(0, 999999));
|
$candidate = sprintf('%s%010d', $prefix, random_int(0, 9999999999));
|
||||||
$exists = Player::query()
|
$exists = Player::query()
|
||||||
->where('site_code', $siteCode)
|
->where('site_code', $siteCode)
|
||||||
->where('site_player_id', $candidate)
|
->where('site_player_id', $candidate)
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ final class ReconcileItemIndexController extends Controller
|
|||||||
$capabilities = $order instanceof TransferOrder
|
$capabilities = $order instanceof TransferOrder
|
||||||
? AdminTransferOrderCapabilities::forOrder(
|
? AdminTransferOrderCapabilities::forOrder(
|
||||||
$order,
|
$order,
|
||||||
$admin instanceof AdminUser ? $admin : null,
|
$admin,
|
||||||
$this->transferService,
|
$this->transferService,
|
||||||
)
|
)
|
||||||
: [
|
: [
|
||||||
|
|||||||
@@ -127,11 +127,19 @@ final class AgentSiteProvisioningService
|
|||||||
AgentPlatformRole::assignPrimaryOperator($user, $node);
|
AgentPlatformRole::assignPrimaryOperator($user, $node);
|
||||||
|
|
||||||
$defaults = config('agent_line_defaults', []);
|
$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, [
|
$this->agentProfileService->upsertForNode($node, [
|
||||||
'total_share_rate' => (float) ($payload['total_share_rate'] ?? $defaults['total_share_rate'] ?? 100),
|
'total_share_rate' => (float) ($payload['total_share_rate'] ?? $defaults['total_share_rate'] ?? 100),
|
||||||
'credit_limit' => (int) ($payload['credit_limit'] ?? $defaults['credit_limit'] ?? 0),
|
'credit_limit' => (int) ($payload['credit_limit'] ?? $defaults['credit_limit'] ?? 0),
|
||||||
'rebate_limit' => (float) ($payload['rebate_limit'] ?? $defaults['rebate_limit'] ?? 0),
|
'rebate_limit' => array_key_exists('rebate_limit', $payload)
|
||||||
'default_player_rebate' => (float) ($payload['default_player_rebate'] ?? $defaults['default_player_rebate'] ?? 0),
|
? (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_grant_extra_rebate' => (bool) ($payload['can_grant_extra_rebate'] ?? true),
|
||||||
'can_create_child_agent' => (bool) ($payload['can_create_child_agent'] ?? true),
|
'can_create_child_agent' => (bool) ($payload['can_create_child_agent'] ?? true),
|
||||||
'can_create_player' => (bool) ($payload['can_create_player'] ?? true),
|
'can_create_player' => (bool) ($payload['can_create_player'] ?? true),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Models\Player;
|
|||||||
use App\Models\TicketItem;
|
use App\Models\TicketItem;
|
||||||
use App\Services\Player\PlayerCreditService;
|
use App\Services\Player\PlayerCreditService;
|
||||||
use App\Support\PlayerFundingMode;
|
use App\Support\PlayerFundingMode;
|
||||||
|
use Illuminate\Database\QueryException;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
final class GameSettlementReversalService
|
final class GameSettlementReversalService
|
||||||
@@ -24,21 +25,32 @@ final class GameSettlementReversalService
|
|||||||
$settledAt = now();
|
$settledAt = now();
|
||||||
|
|
||||||
DB::transaction(function () use ($item, $ledger, $settledAt): void {
|
DB::transaction(function () use ($item, $ledger, $settledAt): void {
|
||||||
DB::table('share_ledger')->insert([
|
// 幂等闸门:share_ledger.reversal_of_id 上有 partial unique 索引,
|
||||||
'ticket_item_id' => $item->id,
|
// 同一原账不允许插入多条反转记录。重复调用时唯一约束冲突即视为已反转、跳过。
|
||||||
'player_id' => $ledger->player_id,
|
// 一次只让 reversal_of_id 出现一次写入,确保所有后续副作用(rebate、credit 冲正)
|
||||||
'agent_node_id' => $ledger->agent_node_id,
|
// 只在唯一反转成功路径上执行。
|
||||||
'agent_path' => $ledger->agent_path,
|
try {
|
||||||
'share_snapshot' => $ledger->share_snapshot,
|
DB::table('share_ledger')->insert([
|
||||||
'game_win_loss' => -1 * (int) $ledger->game_win_loss,
|
'ticket_item_id' => $item->id,
|
||||||
'basic_rebate' => -1 * (int) $ledger->basic_rebate,
|
'player_id' => $ledger->player_id,
|
||||||
'shared_net_win_loss' => -1 * (int) $ledger->shared_net_win_loss,
|
'agent_node_id' => $ledger->agent_node_id,
|
||||||
'allocations_json' => $ledger->allocations_json,
|
'agent_path' => $ledger->agent_path,
|
||||||
'reversal_of_id' => $ledger->id,
|
'share_snapshot' => $ledger->share_snapshot,
|
||||||
'settled_at' => $settledAt,
|
'game_win_loss' => -1 * (int) $ledger->game_win_loss,
|
||||||
'created_at' => $settledAt,
|
'basic_rebate' => -1 * (int) $ledger->basic_rebate,
|
||||||
'updated_at' => $settledAt,
|
'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')
|
$rebates = DB::table('rebate_records')
|
||||||
->where('ticket_item_id', $item->id)
|
->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\AgentOverdueGuard;
|
||||||
use App\Support\CreditAmountScale;
|
use App\Support\CreditAmountScale;
|
||||||
use App\Support\PlayerFundingMode;
|
use App\Support\PlayerFundingMode;
|
||||||
|
use Illuminate\Database\QueryException;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
@@ -18,36 +19,43 @@ final class PlayerCreditService
|
|||||||
{
|
{
|
||||||
$limit = max(0, (int) ($payload['credit_limit'] ?? 0));
|
$limit = max(0, (int) ($payload['credit_limit'] ?? 0));
|
||||||
$now = now();
|
$now = now();
|
||||||
$row = DB::table('player_credit_accounts')
|
|
||||||
->where('player_id', $player->id)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($row !== null) {
|
// 整段包在事务中:并发下调额(先 SELECT used_credit 后 UPDATE)若不加锁,
|
||||||
$usedTotal = (int) $row->used_credit + (int) $row->frozen_credit;
|
// 两个请求都拿到旧 used_credit 并各自通过 below_player_used 校验,导致额度被收缩到比已用更小。
|
||||||
if ($limit < $usedTotal) {
|
// lockForUpdate 保证同时只有一个请求能修改该玩家授信记录。
|
||||||
throw ValidationException::withMessages([
|
DB::transaction(function () use ($player, $limit, $now): void {
|
||||||
'credit_limit' => ['below_player_used'],
|
$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')
|
DB::table('player_credit_accounts')->insert([
|
||||||
->where('player_id', $player->id)
|
'player_id' => $player->id,
|
||||||
->update([
|
'credit_limit' => $limit,
|
||||||
'credit_limit' => $limit,
|
'used_credit' => 0,
|
||||||
'updated_at' => $now,
|
'frozen_credit' => 0,
|
||||||
]);
|
'created_at' => $now,
|
||||||
|
'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,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 可用授信(主货币整数,与后台「授信额度」一致)。 */
|
/** 可用授信(主货币整数,与后台「授信额度」一致)。 */
|
||||||
@@ -163,23 +171,43 @@ final class PlayerCreditService
|
|||||||
$majorDelta = CreditAmountScale::minorToMajor($amountMinor, $currency);
|
$majorDelta = CreditAmountScale::minorToMajor($amountMinor, $currency);
|
||||||
$now = now();
|
$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')
|
DB::table('player_credit_accounts')
|
||||||
->where('player_id', $player->id)
|
->where('player_id', $player->id)
|
||||||
->update([
|
->update([
|
||||||
'used_credit' => DB::raw('used_credit + '.$majorDelta),
|
'used_credit' => (int) $row->used_credit + $majorDelta,
|
||||||
'updated_at' => $now,
|
'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
|
public function applySettledWin(Player $player, int $amountMinor, int $ticketItemId): void
|
||||||
@@ -337,18 +365,26 @@ final class PlayerCreditService
|
|||||||
return;
|
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([
|
$this->decreaseUsedCredit($player, $amountMinor);
|
||||||
'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(),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function applySettlementPayout(Player $player, int $amountMinor, int $billId): void
|
public function applySettlementPayout(Player $player, int $amountMinor, int $billId): void
|
||||||
@@ -361,16 +397,24 @@ final class PlayerCreditService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
DB::table('credit_ledger')->insert([
|
// 幂等闸门:credit_ledger (settlement_bill, settlement_payout) 唯一;重复入账直接返回。
|
||||||
'owner_type' => 'player',
|
try {
|
||||||
'owner_id' => $player->id,
|
DB::table('credit_ledger')->insert([
|
||||||
'amount' => $amountMinor,
|
'owner_type' => 'player',
|
||||||
'reason' => 'settlement_payout',
|
'owner_id' => $player->id,
|
||||||
'ref_type' => 'settlement_bill',
|
'amount' => $amountMinor,
|
||||||
'ref_id' => $billId,
|
'reason' => 'settlement_payout',
|
||||||
'created_at' => now(),
|
'ref_type' => 'settlement_bill',
|
||||||
'updated_at' => now(),
|
'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
|
private function decreaseUsedCredit(Player $player, int $amountMinor): void
|
||||||
@@ -398,4 +442,31 @@ final class PlayerCreditService
|
|||||||
'updated_at' => now(),
|
'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\Models\Player;
|
||||||
use App\Support\PlayerAuthSource;
|
use App\Support\PlayerAuthSource;
|
||||||
use Firebase\JWT\JWT;
|
use Firebase\JWT\JWT;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use App\Exceptions\PlayerAuthenticationException;
|
use App\Exceptions\PlayerAuthenticationException;
|
||||||
|
|
||||||
@@ -114,15 +115,31 @@ final class PlayerNativeAuthService
|
|||||||
{
|
{
|
||||||
$max = (int) config('lottery.player_auth.native.max_login_attempts', 8);
|
$max = (int) config('lottery.player_auth.native.max_login_attempts', 8);
|
||||||
$lockMinutes = (int) config('lottery.player_auth.native.lock_minutes', 15);
|
$lockMinutes = (int) config('lottery.player_auth.native.lock_minutes', 15);
|
||||||
$count = (int) $player->login_failed_count + 1;
|
|
||||||
|
|
||||||
$updates = ['login_failed_count' => $count];
|
// 并发场景下两个错误登录请求都拿到旧 login_failed_count 各自 +1 时,会出现 last-write-wins
|
||||||
if ($count >= $max) {
|
// 丢失一次失败计数(影响锁定判定准确性)。用 lockForUpdate 取最新计数后再写。
|
||||||
$updates['login_locked_until'] = now()->addMinutes($lockMinutes);
|
\DB::transaction(function () use ($player, $max, $lockMinutes): void {
|
||||||
$updates['login_failed_count'] = 0;
|
$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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -173,6 +173,10 @@ final class AdminAgentScope
|
|||||||
/**
|
/**
|
||||||
* 玩家必须落在当前代理子树(agent_node_id 必填,由迁移回填根代理)。
|
* 玩家必须落在当前代理子树(agent_node_id 必填,由迁移回填根代理)。
|
||||||
*
|
*
|
||||||
|
* 性能优化:原实现先 pluck('id') 全表扫代理树再 whereIn,N+1 + 中间数组大。
|
||||||
|
* 改用子查询(SELECT id FROM agent_nodes WHERE path LIKE ?),单次 SQL,
|
||||||
|
* 走 idx_agent_nodes_path 索引。
|
||||||
|
*
|
||||||
* @param Builder<Player> $query
|
* @param Builder<Player> $query
|
||||||
*/
|
*/
|
||||||
public static function applyToPlayerQuery(Builder $query, AdminUser $admin): void
|
public static function applyToPlayerQuery(Builder $query, AdminUser $admin): void
|
||||||
@@ -194,18 +198,8 @@ final class AdminAgentScope
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$subtreeIds = AgentNode::query()
|
// 委托给统一子树过滤:先 count 一次(带 LIMIT 1 优化)确认子树非空,再用子查询展开。
|
||||||
->where('path', 'like', $actor->path.'%')
|
self::applySubtreeFilter($query, 'agent_node_id', (string) $actor->path);
|
||||||
->pluck('id')
|
|
||||||
->all();
|
|
||||||
|
|
||||||
if ($subtreeIds === []) {
|
|
||||||
$query->whereRaw('0 = 1');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$query->whereIn('agent_node_id', $subtreeIds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -226,17 +220,28 @@ final class AdminAgentScope
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$subtreeIds = AgentNode::query()
|
self::applySubtreeFilter($query, 'agent_node_id', (string) $node->path);
|
||||||
->where('path', 'like', $node->path.'%')
|
}
|
||||||
->pluck('id')
|
|
||||||
->all();
|
|
||||||
|
|
||||||
if ($subtreeIds === []) {
|
/**
|
||||||
$query->whereRaw('0 = 1');
|
* 按 path 前缀对指定列应用代理子树过滤。
|
||||||
|
*
|
||||||
|
* 用子查询 `IN (SELECT id FROM agent_nodes WHERE path LIKE ?)` 替代
|
||||||
|
* 「全表 pluck 再 whereIn」:单次 SQL 走 idx_agent_nodes_path 索引,
|
||||||
|
* 避免 N+1 与中间大数组。子查询天然短路空子树(返回空集 → IN 永不命中)。
|
||||||
|
*
|
||||||
|
* 注意 $path / $column 由调用方控制且为已知安全值,不走用户输入;
|
||||||
|
* LIKE 绑定值仍用参数化。path 中 % / _ / \ 也已做转义,防止 LIKE 元字符注入。
|
||||||
|
*
|
||||||
|
* @param Builder<Player> $query
|
||||||
|
*/
|
||||||
|
private static function applySubtreeFilter(Builder $query, string $column, string $path): void
|
||||||
|
{
|
||||||
|
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $path);
|
||||||
|
$prefix = $escaped.'%';
|
||||||
|
|
||||||
return;
|
$query->whereIn($column, function ($subQuery) use ($prefix): void {
|
||||||
}
|
$subQuery->from('agent_nodes')->select('id')->whereRaw('path LIKE ?', [$prefix]);
|
||||||
|
});
|
||||||
$query->whereIn('agent_node_id', $subtreeIds);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,18 @@ final class CreditAmountScale
|
|||||||
|
|
||||||
public static function majorToMinor(int $major, string $currencyCode): int
|
public static function majorToMinor(int $major, string $currencyCode): int
|
||||||
{
|
{
|
||||||
$major = max(0, $major);
|
// 负值防御:所有调用方都应在传入前 max(0, ...) 兜底(available credit / balance / 等),
|
||||||
|
// 此处 double-check 是兜底而非业务逻辑。若发生负数通常意味着上游计算 bug,
|
||||||
|
// 记录 warning 让 production 暴露问题而不被静默吞掉。
|
||||||
|
if ($major < 0) {
|
||||||
|
\Illuminate\Support\Facades\Log::warning('CreditAmountScale::majorToMinor received negative value', [
|
||||||
|
'major' => $major,
|
||||||
|
'currency_code' => $currencyCode,
|
||||||
|
'trace' => debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 4),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
return $major * self::minorUnitFactor($currencyCode);
|
return $major * self::minorUnitFactor($currencyCode);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,31 +15,34 @@ return new class extends Migration
|
|||||||
$defaults = config('agent_line_defaults', []);
|
$defaults = config('agent_line_defaults', []);
|
||||||
$now = now();
|
$now = now();
|
||||||
|
|
||||||
$roots = DB::table('agent_nodes')->where('depth', 0)->get(['id']);
|
// 整批包在事务中:避免中途某条失败时部分根代理已补 profile、部分缺失的中间态。
|
||||||
|
DB::transaction(function () use ($defaults, $now): void {
|
||||||
|
$roots = DB::table('agent_nodes')->where('depth', 0)->get(['id']);
|
||||||
|
|
||||||
foreach ($roots as $root) {
|
foreach ($roots as $root) {
|
||||||
$nodeId = (int) $root->id;
|
$nodeId = (int) $root->id;
|
||||||
if (DB::table('agent_profiles')->where('agent_node_id', $nodeId)->exists()) {
|
if (DB::table('agent_profiles')->where('agent_node_id', $nodeId)->exists()) {
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('agent_profiles')->insert([
|
||||||
|
'agent_node_id' => $nodeId,
|
||||||
|
'total_share_rate' => (float) ($defaults['total_share_rate'] ?? 100),
|
||||||
|
'credit_limit' => (int) ($defaults['credit_limit'] ?? 0) > 0
|
||||||
|
? (int) $defaults['credit_limit']
|
||||||
|
: 1_000_000,
|
||||||
|
'allocated_credit' => 0,
|
||||||
|
'used_credit' => 0,
|
||||||
|
'rebate_limit' => (float) ($defaults['rebate_limit'] ?? 0.005),
|
||||||
|
'default_player_rebate' => (float) ($defaults['default_player_rebate'] ?? 0.005),
|
||||||
|
'can_grant_extra_rebate' => true,
|
||||||
|
'can_create_child_agent' => true,
|
||||||
|
'can_create_player' => true,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
DB::table('agent_profiles')->insert([
|
|
||||||
'agent_node_id' => $nodeId,
|
|
||||||
'total_share_rate' => (float) ($defaults['total_share_rate'] ?? 100),
|
|
||||||
'credit_limit' => (int) ($defaults['credit_limit'] ?? 0) > 0
|
|
||||||
? (int) $defaults['credit_limit']
|
|
||||||
: 1_000_000,
|
|
||||||
'allocated_credit' => 0,
|
|
||||||
'used_credit' => 0,
|
|
||||||
'rebate_limit' => (float) ($defaults['rebate_limit'] ?? 0.005),
|
|
||||||
'default_player_rebate' => (float) ($defaults['default_player_rebate'] ?? 0.005),
|
|
||||||
'can_grant_extra_rebate' => true,
|
|
||||||
'can_create_child_agent' => true,
|
|
||||||
'can_create_player' => true,
|
|
||||||
'created_at' => $now,
|
|
||||||
'updated_at' => $now,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function down(): void
|
public function down(): void
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// 业务幂等保护:
|
||||||
|
// - credit_ledger: 同一 (ref_type, ref_id, reason) 三元组只允许一条;同 ticket_item 可存在
|
||||||
|
// 多 reason 流水(bet_hold_release + game_settlement_loss),但同一笔业务动作(同一 reason)
|
||||||
|
// 重复插入会被拒。注:ref_id IS NULL 的行(如 bet 占位)不参与唯一约束。
|
||||||
|
// - share_ledger.reversal_of_id: 同一原账只允许一条反转(业务语义「补差冲正」每张原票仅一次)。
|
||||||
|
$driver = Schema::getConnection()->getDriverName();
|
||||||
|
|
||||||
|
if (Schema::hasTable('credit_ledger') && ! $this->hasIndex('credit_ledger', 'uk_credit_ledger_ref_reason')) {
|
||||||
|
if ($driver === 'pgsql') {
|
||||||
|
DB::statement('CREATE UNIQUE INDEX uk_credit_ledger_ref_reason ON credit_ledger (ref_type, ref_id, reason) WHERE ref_id IS NOT NULL');
|
||||||
|
} elseif ($driver === 'sqlite') {
|
||||||
|
DB::statement('CREATE UNIQUE INDEX uk_credit_ledger_ref_reason ON credit_ledger (ref_type, ref_id, reason) WHERE ref_id IS NOT NULL');
|
||||||
|
} else {
|
||||||
|
Schema::table('credit_ledger', function (Blueprint $table): void {
|
||||||
|
$table->unique(['ref_type', 'ref_id', 'reason'], 'uk_credit_ledger_ref_reason');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Schema::hasTable('share_ledger') && ! $this->hasIndex('share_ledger', 'uk_share_ledger_reversal_of')) {
|
||||||
|
if ($driver === 'pgsql') {
|
||||||
|
DB::statement('CREATE UNIQUE INDEX uk_share_ledger_reversal_of ON share_ledger (reversal_of_id) WHERE reversal_of_id IS NOT NULL');
|
||||||
|
} elseif ($driver === 'sqlite') {
|
||||||
|
DB::statement('CREATE UNIQUE INDEX uk_share_ledger_reversal_of ON share_ledger (reversal_of_id) WHERE reversal_of_id IS NOT NULL');
|
||||||
|
} else {
|
||||||
|
Schema::table('share_ledger', function (Blueprint $table): void {
|
||||||
|
$table->unique('reversal_of_id', 'uk_share_ledger_reversal_of');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
$driver = Schema::getConnection()->getDriverName();
|
||||||
|
|
||||||
|
if (Schema::hasTable('share_ledger') && $this->hasIndex('share_ledger', 'uk_share_ledger_reversal_of')) {
|
||||||
|
if (in_array($driver, ['pgsql', 'sqlite'], true)) {
|
||||||
|
DB::statement('DROP INDEX IF EXISTS uk_share_ledger_reversal_of');
|
||||||
|
} else {
|
||||||
|
Schema::table('share_ledger', function (Blueprint $table): void {
|
||||||
|
$table->dropUnique('uk_share_ledger_reversal_of');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Schema::hasTable('credit_ledger') && $this->hasIndex('credit_ledger', 'uk_credit_ledger_ref_reason')) {
|
||||||
|
if (in_array($driver, ['pgsql', 'sqlite'], true)) {
|
||||||
|
DB::statement('DROP INDEX IF EXISTS uk_credit_ledger_ref_reason');
|
||||||
|
} else {
|
||||||
|
Schema::table('credit_ledger', function (Blueprint $table): void {
|
||||||
|
$table->dropUnique('uk_credit_ledger_ref_reason');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasIndex(string $table, string $indexName): bool
|
||||||
|
{
|
||||||
|
return Schema::hasIndex($table, $indexName);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -351,7 +351,9 @@ test('jackpot contribution uses total bet amount instead of actual deduct amount
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('jackpot bursts by configured play combination trigger before threshold', function (): void {
|
test('jackpot bursts by configured play combination trigger before threshold', function (): void {
|
||||||
Event::fake([JackpotBurstBroadcast::class]);
|
// 该 case 切到 reverb driver,必须 fake 流程中所有可能触发的 Broadcast 事件,
|
||||||
|
// 否则 fake 列表外的广播会真实连接 Reverb/Pusher,在 CI 环境抛 500。
|
||||||
|
Event::fake(allBroadcastEvents());
|
||||||
config([
|
config([
|
||||||
'broadcasting.default' => 'reverb',
|
'broadcasting.default' => 'reverb',
|
||||||
'broadcasting.connections.reverb.driver' => 'reverb',
|
'broadcasting.connections.reverb.driver' => 'reverb',
|
||||||
|
|||||||
@@ -428,7 +428,9 @@ test('play type patch toggles active config and broadcasts instantly', function
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('§9 play_config publish broadcasts changed play toggles', function (): void {
|
test('§9 play_config publish broadcasts changed play toggles', function (): void {
|
||||||
Event::fake([PlayToggleBroadcast::class]);
|
// fake 流程中可能触发的全部 Broadcast(PlayToggle / PlayCatalogUpdated / DrawStatusChange 等),
|
||||||
|
// 否则未在 fake 列表中的事件会真实连接 Reverb/Pusher,CI 环境抛 500。
|
||||||
|
Event::fake(allBroadcastEvents());
|
||||||
config([
|
config([
|
||||||
'broadcasting.default' => 'reverb',
|
'broadcasting.default' => 'reverb',
|
||||||
'broadcasting.connections.reverb.driver' => 'reverb',
|
'broadcasting.connections.reverb.driver' => 'reverb',
|
||||||
@@ -459,7 +461,9 @@ test('§9 play_config publish broadcasts changed play toggles', function (): voi
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('§9 odds publish broadcasts odds update', function (): void {
|
test('§9 odds publish broadcasts odds update', function (): void {
|
||||||
Event::fake([OddsUpdateBroadcast::class]);
|
// fake 流程中可能触发的全部 Broadcast(OddsUpdate / PlayCatalogUpdated / DrawStatusChange 等),
|
||||||
|
// 否则未在 fake 列表中的事件会真实连接 Reverb/Pusher,CI 环境抛 500。
|
||||||
|
Event::fake(allBroadcastEvents());
|
||||||
config([
|
config([
|
||||||
'broadcasting.default' => 'reverb',
|
'broadcasting.default' => 'reverb',
|
||||||
'broadcasting.connections.reverb.driver' => 'reverb',
|
'broadcasting.connections.reverb.driver' => 'reverb',
|
||||||
|
|||||||
@@ -137,3 +137,32 @@ function ensureAdminActionCatalogSeeded(): void
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回项目内所有 Broadcast 事件类列表,供 Event::fake() 拦截广播使用。
|
||||||
|
*
|
||||||
|
* 业务切到 reverb driver 后,fake 列表不全会导致未拦截的 broadcast 真实派发、
|
||||||
|
* 真实连接 Reverb/Pusher,CI 环境无可用 server 时直接 500。本 helper 保证:
|
||||||
|
* Event::fake(allBroadcastEvents())
|
||||||
|
* 一行覆盖流程中可能触发的所有事件。
|
||||||
|
*
|
||||||
|
* 不可使用 Event::fake() 无参形式:会同时拦截 Illuminate\Database\Events\* 等框架事件,
|
||||||
|
* 干扰 RefreshDatabase / model events / 自定义 boot hooks。
|
||||||
|
*
|
||||||
|
* @return list<class-string>
|
||||||
|
*/
|
||||||
|
function allBroadcastEvents(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
\App\Events\BalanceUpdateBroadcast::class,
|
||||||
|
\App\Events\DrawCountdownBroadcast::class,
|
||||||
|
\App\Events\DrawResultPublishedBroadcast::class,
|
||||||
|
\App\Events\DrawStatusChangeBroadcast::class,
|
||||||
|
\App\Events\JackpotBurstBroadcast::class,
|
||||||
|
\App\Events\OddsUpdateBroadcast::class,
|
||||||
|
\App\Events\PlayCatalogUpdatedBroadcast::class,
|
||||||
|
\App\Events\PlayToggleBroadcast::class,
|
||||||
|
\App\Events\RiskSoldOutBroadcast::class,
|
||||||
|
\App\Events\RiskWarningBroadcast::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user