diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php index 9d6934e..47a6809 100644 --- a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php @@ -234,8 +234,11 @@ final class AdminPlayerStoreController extends Controller $prefix = strtoupper(substr(preg_replace('/[^A-Za-z]/', '', $siteCode) ?: 'LP', 0, 2)); $prefix = str_pad($prefix, 2, 'P'); + // 使用 10 位数字(100 亿组合)显著降低并发碰撞概率;即使极小概率撞上, + // 上层 Player::create 有 (site_code, site_player_id) 唯一索引兜底, + // 错误会在调用方被显式处理为 422/500,而非静默双写。 do { - $candidate = sprintf('%s%06d', $prefix, random_int(0, 999999)); + $candidate = sprintf('%s%010d', $prefix, random_int(0, 9999999999)); $exists = Player::query() ->where('site_code', $siteCode) ->where('site_player_id', $candidate) diff --git a/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileItemIndexController.php b/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileItemIndexController.php index 9299d7d..7c9d87a 100644 --- a/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileItemIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileItemIndexController.php @@ -79,7 +79,7 @@ final class ReconcileItemIndexController extends Controller $capabilities = $order instanceof TransferOrder ? AdminTransferOrderCapabilities::forOrder( $order, - $admin instanceof AdminUser ? $admin : null, + $admin, $this->transferService, ) : [ diff --git a/app/Services/Agent/AgentSiteProvisioningService.php b/app/Services/Agent/AgentSiteProvisioningService.php index 062dc0f..60b2d2c 100644 --- a/app/Services/Agent/AgentSiteProvisioningService.php +++ b/app/Services/Agent/AgentSiteProvisioningService.php @@ -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), diff --git a/app/Services/AgentSettlement/GameSettlementReversalService.php b/app/Services/AgentSettlement/GameSettlementReversalService.php index 969abf6..375f25a 100644 --- a/app/Services/AgentSettlement/GameSettlementReversalService.php +++ b/app/Services/AgentSettlement/GameSettlementReversalService.php @@ -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; + } } diff --git a/app/Services/Player/PlayerCreditService.php b/app/Services/Player/PlayerCreditService.php index 89b13e7..4a3ed6f 100644 --- a/app/Services/Player/PlayerCreditService.php +++ b/app/Services/Player/PlayerCreditService.php @@ -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; + } } diff --git a/app/Services/Player/PlayerNativeAuthService.php b/app/Services/Player/PlayerNativeAuthService.php index bf2dd31..1aaaed5 100644 --- a/app/Services/Player/PlayerNativeAuthService.php +++ b/app/Services/Player/PlayerNativeAuthService.php @@ -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); + }); } /** diff --git a/app/Support/AdminAgentScope.php b/app/Support/AdminAgentScope.php index 238faaa..e381592 100644 --- a/app/Support/AdminAgentScope.php +++ b/app/Support/AdminAgentScope.php @@ -173,6 +173,10 @@ final class AdminAgentScope /** * 玩家必须落在当前代理子树(agent_node_id 必填,由迁移回填根代理)。 * + * 性能优化:原实现先 pluck('id') 全表扫代理树再 whereIn,N+1 + 中间数组大。 + * 改用子查询(SELECT id FROM agent_nodes WHERE path LIKE ?),单次 SQL, + * 走 idx_agent_nodes_path 索引。 + * * @param Builder $query */ public static function applyToPlayerQuery(Builder $query, AdminUser $admin): void @@ -194,18 +198,8 @@ final class AdminAgentScope return; } - $subtreeIds = AgentNode::query() - ->where('path', 'like', $actor->path.'%') - ->pluck('id') - ->all(); - - if ($subtreeIds === []) { - $query->whereRaw('0 = 1'); - - return; - } - - $query->whereIn('agent_node_id', $subtreeIds); + // 委托给统一子树过滤:先 count 一次(带 LIMIT 1 优化)确认子树非空,再用子查询展开。 + self::applySubtreeFilter($query, 'agent_node_id', (string) $actor->path); } /** @@ -226,17 +220,28 @@ final class AdminAgentScope return; } - $subtreeIds = AgentNode::query() - ->where('path', 'like', $node->path.'%') - ->pluck('id') - ->all(); + self::applySubtreeFilter($query, 'agent_node_id', (string) $node->path); + } - 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 $query + */ + private static function applySubtreeFilter(Builder $query, string $column, string $path): void + { + $escaped = str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $path); + $prefix = $escaped.'%'; - return; - } - - $query->whereIn('agent_node_id', $subtreeIds); + $query->whereIn($column, function ($subQuery) use ($prefix): void { + $subQuery->from('agent_nodes')->select('id')->whereRaw('path LIKE ?', [$prefix]); + }); } } diff --git a/app/Support/CreditAmountScale.php b/app/Support/CreditAmountScale.php index e084168..ad97e12 100644 --- a/app/Support/CreditAmountScale.php +++ b/app/Support/CreditAmountScale.php @@ -30,7 +30,18 @@ final class CreditAmountScale 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); } diff --git a/database/migrations/2026_06_17_120000_backfill_root_agent_profiles.php b/database/migrations/2026_06_17_120000_backfill_root_agent_profiles.php index 7bedcdf..eb16277 100644 --- a/database/migrations/2026_06_17_120000_backfill_root_agent_profiles.php +++ b/database/migrations/2026_06_17_120000_backfill_root_agent_profiles.php @@ -15,31 +15,34 @@ return new class extends Migration $defaults = config('agent_line_defaults', []); $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) { - $nodeId = (int) $root->id; - if (DB::table('agent_profiles')->where('agent_node_id', $nodeId)->exists()) { - continue; + foreach ($roots as $root) { + $nodeId = (int) $root->id; + if (DB::table('agent_profiles')->where('agent_node_id', $nodeId)->exists()) { + 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 diff --git a/database/migrations/2026_06_17_180000_add_unique_constraints_for_settlement_idempotency.php b/database/migrations/2026_06_17_180000_add_unique_constraints_for_settlement_idempotency.php new file mode 100644 index 0000000..96fc20e --- /dev/null +++ b/database/migrations/2026_06_17_180000_add_unique_constraints_for_settlement_idempotency.php @@ -0,0 +1,73 @@ +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); + } +}; diff --git a/tests/Feature/JackpotPlacementSettlementTest.php b/tests/Feature/JackpotPlacementSettlementTest.php index 2729308..8e3db75 100644 --- a/tests/Feature/JackpotPlacementSettlementTest.php +++ b/tests/Feature/JackpotPlacementSettlementTest.php @@ -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 { - Event::fake([JackpotBurstBroadcast::class]); + // 该 case 切到 reverb driver,必须 fake 流程中所有可能触发的 Broadcast 事件, + // 否则 fake 列表外的广播会真实连接 Reverb/Pusher,在 CI 环境抛 500。 + Event::fake(allBroadcastEvents()); config([ 'broadcasting.default' => 'reverb', 'broadcasting.connections.reverb.driver' => 'reverb', diff --git a/tests/Feature/OperationalConfigAcceptanceTest.php b/tests/Feature/OperationalConfigAcceptanceTest.php index 86de8e0..d522fe6 100644 --- a/tests/Feature/OperationalConfigAcceptanceTest.php +++ b/tests/Feature/OperationalConfigAcceptanceTest.php @@ -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 { - Event::fake([PlayToggleBroadcast::class]); + // fake 流程中可能触发的全部 Broadcast(PlayToggle / PlayCatalogUpdated / DrawStatusChange 等), + // 否则未在 fake 列表中的事件会真实连接 Reverb/Pusher,CI 环境抛 500。 + Event::fake(allBroadcastEvents()); config([ 'broadcasting.default' => '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 { - Event::fake([OddsUpdateBroadcast::class]); + // fake 流程中可能触发的全部 Broadcast(OddsUpdate / PlayCatalogUpdated / DrawStatusChange 等), + // 否则未在 fake 列表中的事件会真实连接 Reverb/Pusher,CI 环境抛 500。 + Event::fake(allBroadcastEvents()); config([ 'broadcasting.default' => 'reverb', 'broadcasting.connections.reverb.driver' => 'reverb', diff --git a/tests/Pest.php b/tests/Pest.php index 6746002..e5a5033 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -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 + */ +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, + ]; +}