725 lines
26 KiB
PHP
725 lines
26 KiB
PHP
<?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;
|
||
|
||
/**
|
||
* 主站 ↔ 彩票钱包:转入 / 转出(幂等键 + 流水 + 订单)。
|
||
*/
|
||
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,
|
||
) {}
|
||
|
||
/**
|
||
* 转入:主站扣款成功后增加彩票余额。
|
||
*
|
||
* @return array<string, mixed>
|
||
*/
|
||
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);
|
||
$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();
|
||
});
|
||
|
||
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->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 ($order->status !== self::ST_PENDING_RECONCILE) {
|
||
throw new WalletOperationException(
|
||
'order_not_pending_reconcile',
|
||
ErrorCode::WalletExternalRejected->value,
|
||
422,
|
||
);
|
||
}
|
||
|
||
if ($action === 'reverse') {
|
||
$this->doReverse($order, $remark);
|
||
} elseif ($action === 'manually_process') {
|
||
$this->doManuallyProcess($order, $remark);
|
||
} else {
|
||
throw new WalletOperationException(
|
||
'invalid_reconcile_action',
|
||
ErrorCode::WalletExternalRejected->value,
|
||
422,
|
||
);
|
||
}
|
||
}
|
||
|
||
private function doReverse(TransferOrder $order, string $remark): void
|
||
{
|
||
if ($order->direction === self::DIR_OUT) {
|
||
DB::transaction(function () use ($order, $remark): void {
|
||
$wallet = $this->lockLotteryWalletById($order->player_id, $order->currency_code);
|
||
$this->postLotteryWalletMovement(
|
||
wallet: $wallet,
|
||
bizType: self::BIZ_REVERSAL,
|
||
direction: self::TXN_DIR_IN,
|
||
amountMinor: (int) $order->amount,
|
||
bizNo: $order->transfer_no,
|
||
externalRefNo: null,
|
||
idempotentKey: null,
|
||
remark: $remark ?: 'reversal_pending_reconcile',
|
||
deltaSign: 1,
|
||
);
|
||
|
||
$order->forceFill([
|
||
'status' => self::ST_REVERSED,
|
||
'fail_reason' => 'reversed: '.($remark ?: 'admin_reversal'),
|
||
'finished_at' => now(),
|
||
])->save();
|
||
});
|
||
} else {
|
||
$order->forceFill([
|
||
'status' => self::ST_REVERSED,
|
||
'fail_reason' => 'reversed: '.($remark ?: 'admin_reversal'),
|
||
'finished_at' => now(),
|
||
])->save();
|
||
}
|
||
}
|
||
|
||
private function doManuallyProcess(TransferOrder $order, string $remark): void
|
||
{
|
||
$order->forceFill([
|
||
'status' => self::ST_MANUALLY_PROCESSED,
|
||
'fail_reason' => 'manually_processed: '.($remark ?: 'admin_manual'),
|
||
'finished_at' => now(),
|
||
])->save();
|
||
}
|
||
|
||
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 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;
|
||
if ($requireBalance && $deltaSign < 0 && $before < $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,
|
||
]);
|
||
|
||
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,
|
||
);
|
||
}
|
||
}
|