*/ 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, ); } try { DB::transaction(function () use ($player, $currencyCode, $amountMinor, $order, $main, $transferNo, $idempotentKey): void { $wallet = $this->lockLotteryWallet($player, $currencyCode); $this->postLotteryWalletMovement( wallet: $wallet, bizType: self::BIZ_TRANSFER_IN, direction: self::TXN_DIR_IN, amountMinor: $amountMinor, bizNo: $transferNo, externalRefNo: $main->externalRefNo, idempotentKey: $idempotentKey, remark: null, deltaSign: 1, ); $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(); }); } catch (\Throwable $e) { $order->refresh(); if ($order->status === self::ST_PROCESSING) { $order->forceFill([ 'status' => self::ST_PENDING_RECONCILE, 'external_ref_no' => $main->externalRefNo, 'external_request_payload' => $main->requestPayload, 'external_response_payload' => $main->responsePayload, 'fail_reason' => 'lottery_credit_failed', 'finished_at' => null, ])->save(); } throw $e; } 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); $this->postLotteryWalletMovement( wallet: $wallet, bizType: self::BIZ_TRANSFER_OUT, direction: self::TXN_DIR_OUT, amountMinor: $amountMinor, bizNo: $transferNo, externalRefNo: null, idempotentKey: $idempotentKey, remark: null, deltaSign: -1, requireBalance: true, ); }); } 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); $this->postLotteryWalletMovement( wallet: $wallet, bizType: self::BIZ_TRANSFER_OUT_REFUND, direction: self::TXN_DIR_IN, amountMinor: $amountMinor, bizNo: $transferNo, externalRefNo: null, idempotentKey: $idempotentKey, remark: 'withdraw_failed_refund', deltaSign: 1, ); $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, ); } /** * 对账操作:冲正 / 补入账 / 标记结案。 * * 冲正(reverse):主站确认未成功,对已扣彩票余额的转出单做反向操作(加回余额),标记为已冲正。 * 标记结案(manually_process):确认已在系统外处理完毕,仅改订单状态,不动钱包。 * * @param 'reverse'|'manually_process' $action * @throws WalletOperationException */ public function reconcileTransferOrder( TransferOrder $order, string $action, string $remark = '', ): void { if ($action === 'reverse') { DB::transaction(fn (): mixed => $this->doReverse($order, $remark)); return; } if ($action === 'complete_credit') { DB::transaction(fn (): mixed => $this->completeStuckTransferInCredit($order, $remark)); return; } if ($action === 'manually_process') { DB::transaction(fn (): mixed => $this->doManuallyProcess($order, $remark)); return; } throw new WalletOperationException( 'invalid_reconcile_action', ErrorCode::WalletExternalRejected->value, 422, ); } private function doReverse(TransferOrder $order, string $remark): void { /** @var TransferOrder $locked */ $locked = TransferOrder::query()->whereKey($order->id)->lockForUpdate()->firstOrFail(); if ($locked->status === self::ST_REVERSED) { return; } if ($locked->status !== self::ST_PENDING_RECONCILE) { throw new WalletOperationException( 'order_not_pending_reconcile', ErrorCode::WalletExternalRejected->value, 422, ); } if ($locked->direction === self::DIR_OUT) { $idempotentKey = 'reversal:'.$locked->transfer_no; $alreadyCredited = WalletTxn::query() ->where('idempotent_key', $idempotentKey) ->where('biz_type', self::BIZ_REVERSAL) ->exists(); if (! $alreadyCredited) { $wallet = $this->lockLotteryWalletById($locked->player_id, $locked->currency_code); $this->postLotteryWalletMovement( wallet: $wallet, bizType: self::BIZ_REVERSAL, direction: self::TXN_DIR_IN, amountMinor: (int) $locked->amount, bizNo: $locked->transfer_no, externalRefNo: null, idempotentKey: $idempotentKey, remark: $remark ?: 'reversal_pending_reconcile', deltaSign: 1, ); } } elseif ($this->isEligibleForTransferInReverse($locked)) { $player = Player::query()->whereKey($locked->player_id)->firstOrFail(); $refund = $this->mainSite->refundMainForFailedLotteryDeposit( $player, (string) $locked->currency_code, (int) $locked->amount, 'refund:'.$locked->transfer_no, ); if (! $refund->ok) { throw new WalletOperationException( $refund->errorMessage ?? 'main_site_refund_failed', $refund->uncertain ? ErrorCode::WalletTransferPending->value : ErrorCode::WalletExternalRejected->value, $refund->uncertain ? 409 : 422, ); } $locked->forceFill([ 'external_request_payload' => $refund->requestPayload, 'external_response_payload' => $refund->responsePayload, 'external_ref_no' => $refund->externalRefNo ?? $locked->external_ref_no, ])->save(); } $locked->forceFill([ 'status' => self::ST_REVERSED, 'fail_reason' => 'reversed: '.($remark ?: 'admin_reversal'), 'finished_at' => now(), ])->save(); } /** * 主站已扣款但彩票侧入账失败时,人工/对账补完成转入。 */ private function completeStuckTransferInCredit(TransferOrder $order, string $remark): void { /** @var TransferOrder $locked */ $locked = TransferOrder::query()->whereKey($order->id)->lockForUpdate()->firstOrFail(); if ($locked->direction !== self::DIR_IN) { throw new WalletOperationException( 'invalid_reconcile_action', ErrorCode::WalletExternalRejected->value, 422, ); } if ($locked->status === self::ST_SUCCESS) { return; } if ($locked->status !== self::ST_PENDING_RECONCILE) { throw new WalletOperationException( 'order_not_pending_reconcile', ErrorCode::WalletExternalRejected->value, 422, ); } if (! $this->isEligibleForCompleteCredit($locked)) { throw new WalletOperationException( 'complete_credit_not_eligible', ErrorCode::WalletExternalRejected->value, 422, ); } $idempotentKey = (string) $locked->idempotent_key; if (WalletTxn::query() ->where('idempotent_key', $idempotentKey) ->where('biz_type', self::BIZ_TRANSFER_IN) ->where('status', self::TXN_POSTED) ->exists()) { $locked->forceFill([ 'status' => self::ST_SUCCESS, 'finished_at' => now(), ])->save(); return; } $player = Player::query()->whereKey($locked->player_id)->firstOrFail(); $currencyCode = (string) $locked->currency_code; $wallet = $this->lockLotteryWallet($player, $currencyCode); $this->postLotteryWalletMovement( wallet: $wallet, bizType: self::BIZ_TRANSFER_IN, direction: self::TXN_DIR_IN, amountMinor: (int) $locked->amount, bizNo: $locked->transfer_no, externalRefNo: $locked->external_ref_no, idempotentKey: $idempotentKey, remark: $remark ?: 'complete_stuck_transfer_in', deltaSign: 1, ); $locked->forceFill([ 'status' => self::ST_SUCCESS, 'fail_reason' => null, 'finished_at' => now(), ])->save(); } private function doManuallyProcess(TransferOrder $order, string $remark): void { /** @var TransferOrder $locked */ $locked = TransferOrder::query()->whereKey($order->id)->lockForUpdate()->firstOrFail(); $allowedStatuses = [self::ST_PROCESSING, self::ST_FAILED, self::ST_PENDING_RECONCILE]; if (! in_array($locked->status, $allowedStatuses, true)) { throw new WalletOperationException( 'order_not_pending_reconcile', ErrorCode::WalletExternalRejected->value, 422, ); } if ($locked->status === self::ST_MANUALLY_PROCESSED) { return; } if ($locked->direction === self::DIR_OUT && $locked->status === self::ST_PENDING_RECONCILE) { throw new WalletOperationException( 'manually_process_requires_reverse_for_transfer_out', ErrorCode::WalletExternalRejected->value, 422, ); } if (! $this->isEligibleForManualProcess($locked)) { throw new WalletOperationException( 'manually_process_not_eligible', ErrorCode::WalletExternalRejected->value, 422, ); } $locked->forceFill([ 'status' => self::ST_MANUALLY_PROCESSED, 'fail_reason' => 'manually_processed: '.($remark ?: 'admin_manual'), 'finished_at' => now(), ])->save(); } /** 仅主站已扣款(有 external_ref_no)且彩票入账失败时可补完成转入。 */ public function isEligibleForCompleteCredit(TransferOrder $order): bool { return $order->fail_reason === 'lottery_credit_failed' && trim((string) $order->external_ref_no) !== ''; } public function isEligibleForTransferInReverse(TransferOrder $order): bool { return $order->direction === self::DIR_IN && $order->status === self::ST_PENDING_RECONCILE && $this->isEligibleForCompleteCredit($order); } public function isEligibleForManualProcess(TransferOrder $order): bool { if (! in_array($order->status, [self::ST_PROCESSING, self::ST_FAILED, self::ST_PENDING_RECONCILE], true)) { return false; } if ($order->direction === self::DIR_OUT && $order->status === self::ST_PENDING_RECONCILE) { return false; } return $order->fail_reason !== 'lottery_credit_failed'; } private function lockLotteryWalletById(int $playerId, string $currencyCode): PlayerWallet { $wallet = PlayerWallet::query() ->where([ 'player_id' => $playerId, 'wallet_type' => self::WALLET_TYPE_LOTTERY, 'currency_code' => $currencyCode, ]) ->lockForUpdate() ->first(); if ($wallet === null) { throw new WalletOperationException( 'wallet_not_found', ErrorCode::WalletInvalidCurrency->value, 422, ); } return $wallet; } /** * @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())); } /** * 统一执行彩票钱包余额变更并记录流水。 * * @return array{before: int, after: int} */ private function postLotteryWalletMovement( PlayerWallet $wallet, string $bizType, int $direction, int $amountMinor, string $bizNo, ?string $externalRefNo, ?string $idempotentKey, ?string $remark, int $deltaSign, bool $requireBalance = false, ): array { $before = (int) $wallet->balance; $available = $before - (int) $wallet->frozen_balance; if ($requireBalance && $deltaSign < 0 && $available < $amountMinor) { throw new WalletOperationException( 'insufficient_balance', ErrorCode::WalletInsufficientBalance->value, ); } $delta = $amountMinor * $deltaSign; $after = $before + $delta; $wallet->forceFill([ 'balance' => $after, 'version' => (int) $wallet->version + 1, ])->save(); WalletTxn::query()->create([ 'txn_no' => $this->newTxnNo(), 'player_id' => $wallet->player_id, 'wallet_id' => $wallet->id, 'biz_type' => $bizType, 'biz_no' => $bizNo, 'direction' => $direction, 'amount' => $amountMinor, 'balance_before' => $before, 'balance_after' => $after, 'status' => self::TXN_POSTED, 'external_ref_no' => $externalRefNo, 'idempotent_key' => $idempotentKey, 'remark' => $remark, ]); $wallet->refresh(); $this->balanceRealtime->notifyAfterMovement($wallet, $delta, $bizType); return [ 'before' => $before, 'after' => $after, ]; } 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, ); } }