where('player_id', $player->id) ->where('wallet_type', 'lottery') ->where('currency_code', strtoupper($currencyCode)) ->lockForUpdate() ->first(); if ($wallet === null) { $wallet = PlayerWallet::query()->create([ 'player_id' => $player->id, 'wallet_type' => 'lottery', 'currency_code' => strtoupper($currencyCode), 'balance' => 0, 'frozen_balance' => 0, 'status' => 0, 'version' => 0, ]); $wallet = PlayerWallet::query()->whereKey($wallet->id)->lockForUpdate()->firstOrFail(); } if ((int) $wallet->status !== 0) { throw new TicketOperationException('wallet_frozen', ErrorCode::WalletLotteryFrozen->value); } $before = (int) $wallet->balance; $available = $before - (int) $wallet->frozen_balance; if ($available < $amountMinor) { throw new TicketOperationException('bet_insufficient_balance', ErrorCode::BetInsufficientBalance->value); } $after = $before - $amountMinor; $wallet->forceFill([ 'balance' => $after, 'version' => (int) $wallet->version + 1, ])->save(); WalletTxn::query()->create([ 'txn_no' => $this->newTxnNo(), 'player_id' => $player->id, 'wallet_id' => $wallet->id, 'biz_type' => 'bet_deduct', 'biz_no' => $order->order_no, 'direction' => self::TXN_DIR_OUT, 'amount' => $amountMinor, 'balance_before' => $before, 'balance_after' => $after, 'status' => self::TXN_POSTED, 'external_ref_no' => null, 'idempotent_key' => 'bet_deduct:'.$order->order_no, 'remark' => null, ]); $wallet->refresh(); $this->balanceRealtime->notifyAfterMovement($wallet, -$amountMinor, 'bet_deduct'); return $after; } public function reverseBetDeduct(TicketOrder $order): void { $deductTxn = WalletTxn::query() ->where('biz_type', 'bet_deduct') ->where('biz_no', $order->order_no) ->where('status', self::TXN_POSTED) ->first(); if ($deductTxn === null) { return; } $idempotentKey = 'bet-reverse:'.$order->order_no; if (WalletTxn::query()->where('biz_type', 'bet_reverse')->where('idempotent_key', $idempotentKey)->exists()) { return; } $wallet = PlayerWallet::query() ->whereKey($deductTxn->wallet_id) ->lockForUpdate() ->firstOrFail(); $amount = (int) $deductTxn->amount; $before = (int) $wallet->balance; $after = $before + $amount; $wallet->forceFill([ 'balance' => $after, 'version' => (int) $wallet->version + 1, ])->save(); WalletTxn::query()->create([ 'txn_no' => $this->newTxnNo(), 'player_id' => (int) $deductTxn->player_id, 'wallet_id' => (int) $deductTxn->wallet_id, 'biz_type' => 'bet_reverse', 'biz_no' => $order->order_no, 'direction' => self::TXN_DIR_IN, 'amount' => $amount, 'balance_before' => $before, 'balance_after' => $after, 'status' => self::TXN_POSTED, 'external_ref_no' => null, 'idempotent_key' => $idempotentKey, 'remark' => 'post_deduct_confirmation_failed', ]); $wallet->refresh(); $this->balanceRealtime->notifyAfterMovement($wallet, $amount, 'bet_reverse'); } /** * 结算派彩入账(产品文档:派彩写入钱包流水;幂等键按结算批次 + 玩家)。 */ /** * 手动爆池补发:结算批次已派彩后,将 Jackpot 份额追加入账(幂等键按批次 + 玩家 + 爆池日志)。 */ public function creditJackpotManualPayout( Player $player, string $currencyCode, int $amountMinor, int $settlementBatchId, int $jackpotPayoutLogId, ): void { if ($amountMinor <= 0) { return; } $idempotentKey = 'jackpot-manual:'.$settlementBatchId.':'.$player->id.':'.$jackpotPayoutLogId; if (WalletTxn::query()->where('idempotent_key', $idempotentKey)->exists()) { return; } $currency = strtoupper($currencyCode); $wallet = PlayerWallet::query() ->where('player_id', $player->id) ->where('wallet_type', 'lottery') ->where('currency_code', $currency) ->lockForUpdate() ->first(); if ($wallet === null) { $wallet = PlayerWallet::query()->create([ 'player_id' => $player->id, 'wallet_type' => 'lottery', 'currency_code' => $currency, 'balance' => 0, 'frozen_balance' => 0, 'status' => 0, 'version' => 0, ]); $wallet = PlayerWallet::query()->whereKey($wallet->id)->lockForUpdate()->firstOrFail(); } $before = (int) $wallet->balance; $after = $before + $amountMinor; $wallet->forceFill([ 'balance' => $after, 'version' => (int) $wallet->version + 1, ])->save(); WalletTxn::query()->create([ 'txn_no' => $this->newTxnNo(), 'player_id' => $player->id, 'wallet_id' => $wallet->id, 'biz_type' => 'jackpot_manual_payout', 'biz_no' => 'JP'.$jackpotPayoutLogId, 'direction' => self::TXN_DIR_IN, 'amount' => $amountMinor, 'balance_before' => $before, 'balance_after' => $after, 'status' => self::TXN_POSTED, 'external_ref_no' => null, 'idempotent_key' => $idempotentKey, 'remark' => 'manual_jackpot_burst', ]); $wallet->refresh(); $this->balanceRealtime->notifyAfterMovement($wallet, $amountMinor, 'jackpot_manual_payout'); } public function creditSettlementPayout(Player $player, string $currencyCode, int $amountMinor, int $settlementBatchId): void { if ($amountMinor <= 0) { return; } $currency = strtoupper($currencyCode); $idempotentKey = 'settle-payout:'.$settlementBatchId.':'.$player->id.':'.$currency; if (WalletTxn::query()->where('idempotent_key', $idempotentKey)->where('biz_type', 'settle_payout')->exists()) { return; } $wallet = PlayerWallet::query() ->where('player_id', $player->id) ->where('wallet_type', 'lottery') ->where('currency_code', $currency) ->lockForUpdate() ->first(); if ($wallet === null) { $wallet = PlayerWallet::query()->create([ 'player_id' => $player->id, 'wallet_type' => 'lottery', 'currency_code' => $currency, 'balance' => 0, 'frozen_balance' => 0, 'status' => 0, 'version' => 0, ]); $wallet = PlayerWallet::query()->whereKey($wallet->id)->lockForUpdate()->firstOrFail(); } $before = (int) $wallet->balance; $after = $before + $amountMinor; $wallet->forceFill([ 'balance' => $after, 'version' => (int) $wallet->version + 1, ])->save(); WalletTxn::query()->create([ 'txn_no' => $this->newTxnNo(), 'player_id' => $player->id, 'wallet_id' => $wallet->id, 'biz_type' => 'settle_payout', 'biz_no' => 'SB'.$settlementBatchId, 'direction' => self::TXN_DIR_IN, 'amount' => $amountMinor, 'balance_before' => $before, 'balance_after' => $after, 'status' => self::TXN_POSTED, 'external_ref_no' => null, 'idempotent_key' => $idempotentKey, 'remark' => null, ]); $wallet->refresh(); $this->balanceRealtime->notifyAfterMovement($wallet, $amountMinor, 'settle_payout'); } private function newTxnNo(): string { return 'WL'.now()->format('YmdHis').str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); } }