*/ public function transferIn(Player $player, string $currencyCode, int $amountMinor, string $idempotentKey): array { $this->assertPositiveAmount($amountMinor); $currencyCode = $this->normalizeCurrency($currencyCode); $this->assertCurrencyEnabled($currencyCode); $this->assertTransferInEnabled(); $this->assertLotteryWalletNotFrozen($player, $currencyCode); $this->assertTransferAmountLimits(self::DIR_IN, $currencyCode, $amountMinor); $existing = TransferOrder::query()->where('idempotent_key', $idempotentKey)->first(); if ($existing !== null) { return $this->existingOrderResponse($existing, $player, self::DIR_IN, $currencyCode, $amountMinor); } $transferNo = $this->newTransferNo('TI'); try { TransferOrder::query()->create([ 'transfer_no' => $transferNo, 'player_id' => $player->id, 'direction' => self::DIR_IN, 'currency_code' => $currencyCode, 'amount' => $amountMinor, 'idempotent_key' => $idempotentKey, 'status' => self::ST_PROCESSING, ]); } catch (QueryException $e) { $existing = TransferOrder::query()->where('idempotent_key', $idempotentKey)->first(); if ($existing !== null) { return $this->existingOrderResponse($existing, $player, self::DIR_IN, $currencyCode, $amountMinor); } throw $e; } /** @var TransferOrder $order */ $order = TransferOrder::query()->where('transfer_no', $transferNo)->firstOrFail(); $main = $this->mainSite->debitMainForLotteryDeposit($player, $currencyCode, $amountMinor, $idempotentKey); if (! $main->ok) { if ($main->uncertain) { $order->forceFill([ 'status' => self::ST_PENDING_RECONCILE, 'external_request_payload' => $main->requestPayload, 'external_response_payload' => $main->responsePayload, 'fail_reason' => 'main_site_timeout', 'finished_at' => null, ])->save(); throw new WalletOperationException( 'pending_reconcile', ErrorCode::WalletTransferPending->value, 409, ); } $order->forceFill([ 'status' => self::ST_FAILED, 'external_request_payload' => $main->requestPayload, 'external_response_payload' => $main->responsePayload, 'fail_reason' => $main->errorMessage ?? 'main_site_failed', 'finished_at' => now(), ])->save(); throw new WalletOperationException( $main->errorMessage ?? 'main_site_failed', ErrorCode::WalletExternalRejected->value, ); } DB::transaction(function () use ($player, $currencyCode, $amountMinor, $order, $main, $transferNo, $idempotentKey): void { $wallet = $this->lockLotteryWallet($player, $currencyCode); $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' => self::BIZ_TRANSFER_IN, 'biz_no' => $transferNo, 'direction' => self::TXN_DIR_IN, 'amount' => $amountMinor, 'balance_before' => $before, 'balance_after' => $after, 'status' => self::TXN_POSTED, 'external_ref_no' => $main->externalRefNo, 'idempotent_key' => $idempotentKey, 'remark' => null, ]); $order->forceFill([ 'status' => self::ST_SUCCESS, 'external_ref_no' => $main->externalRefNo, 'external_request_payload' => $main->requestPayload, 'external_response_payload' => $main->responsePayload, 'finished_at' => now(), ])->save(); }); return $this->successPayload( TransferOrder::query()->where('transfer_no', $transferNo)->firstOrFail(), $player, $currencyCode, self::BIZ_TRANSFER_IN, ); } /** * 转出:先扣彩票余额,再调用主站加款;失败则冲正彩票余额。 * * @return array */ public function transferOut(Player $player, string $currencyCode, int $amountMinor, string $idempotentKey): array { $this->assertPositiveAmount($amountMinor); $currencyCode = $this->normalizeCurrency($currencyCode); $this->assertCurrencyEnabled($currencyCode); $this->assertTransferOutEnabled(); $this->assertLotteryWalletNotFrozen($player, $currencyCode); $this->assertTransferAmountLimits(self::DIR_OUT, $currencyCode, $amountMinor); $existing = TransferOrder::query()->where('idempotent_key', $idempotentKey)->first(); if ($existing !== null) { return $this->existingOrderResponse($existing, $player, self::DIR_OUT, $currencyCode, $amountMinor); } $transferNo = $this->newTransferNo('TO'); try { TransferOrder::query()->create([ 'transfer_no' => $transferNo, 'player_id' => $player->id, 'direction' => self::DIR_OUT, 'currency_code' => $currencyCode, 'amount' => $amountMinor, 'idempotent_key' => $idempotentKey, 'status' => self::ST_PROCESSING, ]); } catch (QueryException $e) { $existing = TransferOrder::query()->where('idempotent_key', $idempotentKey)->first(); if ($existing !== null) { return $this->existingOrderResponse($existing, $player, self::DIR_OUT, $currencyCode, $amountMinor); } throw $e; } /** @var TransferOrder $order */ $order = TransferOrder::query()->where('transfer_no', $transferNo)->firstOrFail(); try { DB::transaction(function () use ($player, $currencyCode, $amountMinor, $transferNo, $idempotentKey): void { $wallet = $this->lockLotteryWallet($player, $currencyCode); $before = (int) $wallet->balance; if ($before < $amountMinor) { throw new WalletOperationException( 'insufficient_balance', ErrorCode::WalletInsufficientBalance->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' => self::BIZ_TRANSFER_OUT, 'biz_no' => $transferNo, 'direction' => self::TXN_DIR_OUT, 'amount' => $amountMinor, 'balance_before' => $before, 'balance_after' => $after, 'status' => self::TXN_POSTED, 'external_ref_no' => null, 'idempotent_key' => $idempotentKey, 'remark' => null, ]); }); } catch (WalletOperationException $e) { if ($e->lotteryCode === ErrorCode::WalletInsufficientBalance->value) { $order->forceFill([ 'status' => self::ST_FAILED, 'fail_reason' => 'insufficient_balance', 'finished_at' => now(), ])->save(); } throw $e; } $main = $this->mainSite->creditMainForLotteryWithdraw($player, $currencyCode, $amountMinor, $idempotentKey); if (! $main->ok) { if ($main->uncertain) { DB::transaction(function () use ($player, $transferNo, $order, $main): void { WalletTxn::query() ->where('player_id', $player->id) ->where('biz_no', $transferNo) ->where('biz_type', self::BIZ_TRANSFER_OUT) ->update(['status' => self::TXN_PENDING_RECONCILE]); $order->forceFill([ 'status' => self::ST_PENDING_RECONCILE, 'external_request_payload' => $main->requestPayload, 'external_response_payload' => $main->responsePayload, 'fail_reason' => 'main_site_timeout', 'finished_at' => null, ])->save(); }); throw new WalletOperationException( 'pending_reconcile', ErrorCode::WalletTransferPending->value, 409, ); } DB::transaction(function () use ($player, $currencyCode, $amountMinor, $transferNo, $idempotentKey, $order, $main): void { $wallet = $this->lockLotteryWallet($player, $currencyCode); $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' => self::BIZ_TRANSFER_OUT_REFUND, 'biz_no' => $transferNo, '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' => 'withdraw_failed_refund', ]); $order->forceFill([ 'status' => self::ST_FAILED, 'external_request_payload' => $main->requestPayload, 'external_response_payload' => $main->responsePayload, 'fail_reason' => $main->errorMessage ?? 'main_site_failed', 'finished_at' => now(), ])->save(); }); throw new WalletOperationException( $main->errorMessage ?? 'main_site_failed', ErrorCode::WalletExternalRejected->value, ); } $order->forceFill([ 'status' => self::ST_SUCCESS, 'external_ref_no' => $main->externalRefNo, 'external_request_payload' => $main->requestPayload, 'external_response_payload' => $main->responsePayload, 'finished_at' => now(), ])->save(); return $this->successPayload( TransferOrder::query()->where('transfer_no', $transferNo)->firstOrFail(), $player, $currencyCode, self::BIZ_TRANSFER_OUT, ); } /** * @return array */ private function existingOrderResponse( TransferOrder $order, Player $player, string $expectedDirection, string $currencyCode, int $amountMinor, ): array { if ((int) $order->player_id !== (int) $player->id || $order->direction !== $expectedDirection || strtoupper($order->currency_code) !== $currencyCode || (int) $order->amount !== $amountMinor ) { throw new WalletOperationException( 'idempotent_conflict', ErrorCode::WalletIdempotentConflict->value, ); } return match ($order->status) { self::ST_SUCCESS => $this->successPayload( $order, $player, $currencyCode, $expectedDirection === self::DIR_IN ? self::BIZ_TRANSFER_IN : self::BIZ_TRANSFER_OUT, ), self::ST_PROCESSING => throw new WalletOperationException( 'pending', ErrorCode::WalletTransferPending->value, 409, ), self::ST_PENDING_RECONCILE => throw new WalletOperationException( 'pending_reconcile', ErrorCode::WalletTransferPending->value, 409, ), self::ST_FAILED => throw $this->failedOrderToException($order), default => throw new WalletOperationException( 'unknown_order_status', ErrorCode::WalletExternalRejected->value, ), }; } /** * @return array */ private function successPayload(TransferOrder $order, Player $player, string $currencyCode, string $primaryBizType): array { $wallet = PlayerWallet::query()->where([ 'player_id' => $player->id, 'wallet_type' => self::WALLET_TYPE_LOTTERY, 'currency_code' => $currencyCode, ])->first(); $balance = $wallet !== null ? (int) $wallet->balance : 0; $frozen = $wallet !== null ? (int) $wallet->frozen_balance : 0; $logId = WalletTxn::query() ->where('player_id', $player->id) ->where('biz_no', $order->transfer_no) ->where('biz_type', $primaryBizType) ->value('txn_no'); return [ 'transfer_no' => $order->transfer_no, 'direction' => $order->direction, 'currency_code' => $order->currency_code, 'amount' => (int) $order->amount, 'status' => $order->status, 'external_ref_no' => $order->external_ref_no, /** PRD §10.1.1 示例字段名 */ 'balance' => $balance, 'log_id' => $logId, 'lottery_balance_after' => $balance, 'lottery_available_after' => max(0, $balance - $frozen), 'finished_at' => $order->finished_at?->toIso8601String(), ]; } private function lockLotteryWallet(Player $player, string $currencyCode): PlayerWallet { $wallet = PlayerWallet::query() ->where([ 'player_id' => $player->id, 'wallet_type' => self::WALLET_TYPE_LOTTERY, 'currency_code' => $currencyCode, ]) ->lockForUpdate() ->first(); if ($wallet === null) { return PlayerWallet::query()->create([ 'player_id' => $player->id, 'wallet_type' => self::WALLET_TYPE_LOTTERY, 'currency_code' => $currencyCode, 'balance' => 0, 'frozen_balance' => 0, 'status' => 0, 'version' => 0, ]); } if ((int) $wallet->status !== 0) { throw new WalletOperationException( 'lottery_wallet_frozen', ErrorCode::WalletLotteryFrozen->value, ); } return $wallet; } /** * 转出扣款前钱包必须存在且余额足够;若不存在则余额视为 0。 */ private function assertLotteryWalletNotFrozen(Player $player, string $currencyCode): void { $wallet = PlayerWallet::query()->where([ 'player_id' => $player->id, 'wallet_type' => self::WALLET_TYPE_LOTTERY, 'currency_code' => $currencyCode, ])->first(); if ($wallet !== null && (int) $wallet->status !== 0) { throw new WalletOperationException( 'lottery_wallet_frozen', ErrorCode::WalletLotteryFrozen->value, ); } } private function assertPositiveAmount(int $amountMinor): void { if ($amountMinor < 1) { throw new WalletOperationException( 'invalid_amount', ErrorCode::WalletInvalidAmount->value, ); } } private function normalizeCurrency(string $code): string { return strtoupper(substr(trim($code), 0, 16)); } private function assertCurrencyEnabled(string $currencyCode): void { if (! preg_match('/^[A-Z0-9]{1,16}$/', $currencyCode)) { throw new WalletOperationException( 'invalid_currency', ErrorCode::WalletInvalidCurrency->value, ); } $ok = Currency::query()->where('code', $currencyCode)->where('is_enabled', true)->exists(); if (! $ok) { throw new WalletOperationException( 'invalid_currency', ErrorCode::WalletInvalidCurrency->value, ); } } private function assertTransferInEnabled(): void { if (! (bool) LotterySettings::get('wallet.transfer_in_enabled', true)) { throw new WalletOperationException( 'transfer_in_disabled', ErrorCode::WalletTransferInDisabled->value, ); } } private function assertTransferOutEnabled(): void { if (! (bool) LotterySettings::get('wallet.transfer_out_enabled', true)) { throw new WalletOperationException( 'transfer_out_disabled', ErrorCode::WalletTransferOutDisabled->value, ); } } /** * PRD §6.2:最小/最大金额(最小货币单位);可按币种覆盖 JSON。 * * @see LotterySettingsSeeder 默认键;可选 `wallet.transfer_limits_by_currency` = {"NPR":{"in_min":100,...}} */ private function assertTransferAmountLimits(string $direction, string $currencyCode, int $amountMinor): void { $limits = $this->transferLimitsForCurrency($currencyCode); $min = $direction === self::DIR_IN ? $limits['in_min'] : $limits['out_min']; $max = $direction === self::DIR_IN ? $limits['in_max'] : $limits['out_max']; if ($amountMinor < $min || $amountMinor > $max) { throw new WalletOperationException( 'amount_out_of_limits', ErrorCode::WalletAmountExceedsLimit->value, ); } } /** * @return array{in_min: int, in_max: int, out_min: int, out_max: int} */ private function transferLimitsForCurrency(string $currencyCode): array { $defaults = [ 'in_min' => max(1, (int) LotterySettings::get('wallet.transfer_in_min_minor', 100)), 'in_max' => (int) LotterySettings::get('wallet.transfer_in_max_minor', 9_999_999_999_999_999), 'out_min' => max(1, (int) LotterySettings::get('wallet.transfer_out_min_minor', 100)), 'out_max' => (int) LotterySettings::get('wallet.transfer_out_max_minor', 9_999_999_999_999_999), ]; $raw = LotterySettings::get('wallet.transfer_limits_by_currency'); if (is_string($raw)) { $raw = json_decode($raw, true); } if (! is_array($raw) || ! isset($raw[$currencyCode]) || ! is_array($raw[$currencyCode])) { return $defaults; } $patch = $raw[$currencyCode]; return [ 'in_min' => isset($patch['in_min']) ? max(1, (int) $patch['in_min']) : $defaults['in_min'], 'in_max' => isset($patch['in_max']) ? max(1, (int) $patch['in_max']) : $defaults['in_max'], 'out_min' => isset($patch['out_min']) ? max(1, (int) $patch['out_min']) : $defaults['out_min'], 'out_max' => isset($patch['out_max']) ? max(1, (int) $patch['out_max']) : $defaults['out_max'], ]; } private function newTransferNo(string $prefix): string { return $prefix.'_'.Str::lower(str_replace('-', '', Str::uuid()->toString())); } private function newTxnNo(): string { return 'WX_'.Str::lower(str_replace('-', '', Str::uuid()->toString())); } private function failedOrderToException(TransferOrder $order): WalletOperationException { if (($order->fail_reason ?? '') === 'insufficient_balance') { return new WalletOperationException( 'insufficient_balance', ErrorCode::WalletInsufficientBalance->value, ); } return new WalletOperationException( (string) ($order->fail_reason ?? 'failed'), ErrorCode::WalletExternalRejected->value, ); } }