Files
lotteryLaravel/app/Services/Wallet/LotteryTransferService.php
kang a44679665d feat: 增强代理和玩家管理功能
- 在多个控制器中更新权限检查逻辑,确保管理员能够更灵活地管理代理和玩家。
- 在 AdminPlayerStoreController 中引入对玩家创建能力的验证,确保只有具备相应权限的管理员能够创建玩家。
- 更新请求验证逻辑,新增 credit_limit、rebate_rate 和 extra_rebate_rate 字段,以支持更细粒度的玩家管理。
- 在 AgentNodeProfileController 中添加对父代理能力授予的验证,确保子代理的权限在父代理范围内。
- 引入 AgentProfileFieldRules 以简化代理资料更新请求的规则定义,提升代码复用性。
2026-06-04 18:00:50 +08:00

935 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;
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 ($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 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,
);
}
}