Files
lotteryLaravel/app/Services/Wallet/LotteryTransferService.php
kang 1dcd4716c5 refactor: 更新权限管理与请求验证逻辑
- 在多个控制器中将权限检查从 hasAdminPermission 更新为 hasPermissionCode,以增强权限管理的灵活性。
- 引入 AdminScopePolicy,优化基于代理节点的权限和数据过滤逻辑,确保管理员能够更精确地控制访问权限。
- 在请求验证中添加 agent_node_id 字段,确保 API 接口支持代理节点的相关操作。
- 更新 AdminUser 模型,新增 hasPermissionCode 方法,以支持更细粒度的权限检查。
- 优化审计日志记录逻辑,确保在处理请求时能够准确记录管理员的操作。
2026-06-03 10:07:38 +08:00

921 lines
33 KiB
PHP
Raw 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;
/**
* 主站 ↔ 彩票钱包:转入 / 转出(幂等键 + 流水 + 订单)。
*/
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->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->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<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;
$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,
);
}
}