Files
lotteryLaravel/app/Services/Wallet/LotteryTransferService.php
kang 7ec8f4c5a2
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled
fix(funds): 加固转账冲正、结算收付幂等与坏账核销
- 转入 main_site_timeout 等不可冲正场景直接拒绝,避免假结案
- payment_records / settlement_adjustments 增加 partial unique 索引
- 坏账核销行锁 + meta 幂等回放;补差单记录 result_bill_id
- 新增 FundOperationsHardeningTest 覆盖关键路径
2026-06-26 15:19:17 +08:00

957 lines
34 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services\Wallet;
use App\Models\Player;
use App\Models\Currency;
use App\Models\WalletTxn;
use App\Lottery\ErrorCode;
use Illuminate\Support\Str;
use App\Models\PlayerWallet;
use App\Models\TransferOrder;
use App\Services\LotterySettings;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\QueryException;
use App\Exceptions\WalletOperationException;
use App\Support\PlayerFundingMode;
/**
* 主站 ↔ 彩票钱包:转入 / 转出(幂等键 + 流水 + 订单)。
*/
final class LotteryTransferService
{
private const WALLET_TYPE_LOTTERY = 'lottery';
private const DIR_IN = 'in';
private const DIR_OUT = 'out';
private const ST_PROCESSING = 'processing';
private const ST_SUCCESS = 'success';
private const ST_FAILED = 'failed';
/** PRD §6.2/6.7:主站超时待对账 */
private const ST_PENDING_RECONCILE = 'pending_reconcile';
/** PRD §12对账后冲正 */
private const ST_REVERSED = 'reversed';
/** PRD §12对账后标记结案仅改状态不动钱包 */
private const ST_MANUALLY_PROCESSED = 'manually_processed';
private const BIZ_TRANSFER_IN = 'transfer_in';
private const BIZ_TRANSFER_OUT = 'transfer_out';
private const BIZ_TRANSFER_OUT_REFUND = 'transfer_out_refund';
private const BIZ_REVERSAL = 'reversal';
private const TXN_POSTED = 'posted';
private const TXN_PENDING_RECONCILE = 'pending_reconcile';
private const TXN_DIR_IN = 1;
private const TXN_DIR_OUT = 2;
public function __construct(
private readonly MainSiteWalletGateway $mainSite,
private readonly WalletBalanceRealtimeNotifier $balanceRealtime,
) {}
/**
* 转入:主站扣款成功后增加彩票余额。
*
* @return array<string, mixed>
*/
public function transferIn(Player $player, string $currencyCode, int $amountMinor, string $idempotentKey): array
{
$this->assertWalletFundingMode($player);
$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<string, mixed>
*/
public function transferOut(Player $player, string $currencyCode, int $amountMinor, string $idempotentKey): array
{
$this->assertWalletFundingMode($player);
$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 (! $this->isEligibleForReverse($locked)) {
throw new WalletOperationException(
'reverse_not_eligible',
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);
}
/** 后台冲正:转出 pending_reconcile 或转入 lottery_credit_failed 且主站已扣款。 */
public function isEligibleForReverse(TransferOrder $order): bool
{
if ($order->status !== self::ST_PENDING_RECONCILE) {
return false;
}
if ($order->direction === self::DIR_OUT) {
return true;
}
return $this->isEligibleForTransferInReverse($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<string, mixed>
*/
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<string, mixed>
*/
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 assertWalletFundingMode(Player $player): void
{
if (PlayerFundingMode::usesCredit($player)) {
throw new WalletOperationException(
'credit_player_no_wallet_transfer',
ErrorCode::WalletCreditPlayerNoTransfer->value,
422,
);
}
}
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,
);
}
}