Files
lotteryLaravel/app/Services/Ticket/TicketWalletService.php
kang c8c90e3e94 feat: 增强奖池与钱包管理功能
更新 AdminJackpotPoolUpdateController 校验规则,禁止传入 current_amount。
优化 AdminRiskPoolManualStatusController:更新奖池状态后同步 Redis 状态。
在 TransferOrderReconcileController 中新增 completeCredit 方法,用于处理卡住的转账订单对账。
调整 TransferOrderListController:优化转账订单处理条件。
在 TicketItemsIndexController 中实现支持时区的日期筛选,提升日期处理准确性。
扩展 JackpotPool 模型,新增 adjustments 关联关系。
改进票据与钱包相关服务中的错误处理和事务管理。
2026-05-26 14:58:41 +08:00

253 lines
8.4 KiB
PHP

<?php
namespace App\Services\Ticket;
use App\Models\Player;
use App\Models\WalletTxn;
use App\Lottery\ErrorCode;
use App\Models\TicketOrder;
use App\Models\PlayerWallet;
use App\Exceptions\TicketOperationException;
final class TicketWalletService
{
private const TXN_POSTED = 'posted';
private const TXN_DIR_OUT = 2;
private const TXN_DIR_IN = 1;
public function deduct(Player $player, string $currencyCode, int $amountMinor, TicketOrder $order): int
{
$wallet = PlayerWallet::query()
->where('player_id', $player->id)
->where('wallet_type', 'lottery')
->where('currency_code', strtoupper($currencyCode))
->lockForUpdate()
->first();
if ($wallet === null) {
$wallet = PlayerWallet::query()->create([
'player_id' => $player->id,
'wallet_type' => 'lottery',
'currency_code' => strtoupper($currencyCode),
'balance' => 0,
'frozen_balance' => 0,
'status' => 0,
'version' => 0,
]);
$wallet = PlayerWallet::query()->whereKey($wallet->id)->lockForUpdate()->firstOrFail();
}
if ((int) $wallet->status !== 0) {
throw new TicketOperationException('wallet_frozen', ErrorCode::WalletExternalRejected->value);
}
$before = (int) $wallet->balance;
$available = $before - (int) $wallet->frozen_balance;
if ($available < $amountMinor) {
throw new TicketOperationException('bet_insufficient_balance', ErrorCode::BetInsufficientBalance->value);
}
$after = $before - $amountMinor;
$wallet->forceFill([
'balance' => $after,
'version' => (int) $wallet->version + 1,
])->save();
WalletTxn::query()->create([
'txn_no' => $this->newTxnNo(),
'player_id' => $player->id,
'wallet_id' => $wallet->id,
'biz_type' => 'bet_deduct',
'biz_no' => $order->order_no,
'direction' => self::TXN_DIR_OUT,
'amount' => $amountMinor,
'balance_before' => $before,
'balance_after' => $after,
'status' => self::TXN_POSTED,
'external_ref_no' => null,
'idempotent_key' => 'bet_deduct:'.$order->order_no,
'remark' => null,
]);
return $after;
}
public function reverseBetDeduct(TicketOrder $order): void
{
$deductTxn = WalletTxn::query()
->where('biz_type', 'bet_deduct')
->where('biz_no', $order->order_no)
->where('status', self::TXN_POSTED)
->first();
if ($deductTxn === null) {
return;
}
$idempotentKey = 'bet-reverse:'.$order->order_no;
if (WalletTxn::query()->where('biz_type', 'bet_reverse')->where('idempotent_key', $idempotentKey)->exists()) {
return;
}
$wallet = PlayerWallet::query()
->whereKey($deductTxn->wallet_id)
->lockForUpdate()
->firstOrFail();
$amount = (int) $deductTxn->amount;
$before = (int) $wallet->balance;
$after = $before + $amount;
$wallet->forceFill([
'balance' => $after,
'version' => (int) $wallet->version + 1,
])->save();
WalletTxn::query()->create([
'txn_no' => $this->newTxnNo(),
'player_id' => (int) $deductTxn->player_id,
'wallet_id' => (int) $deductTxn->wallet_id,
'biz_type' => 'bet_reverse',
'biz_no' => $order->order_no,
'direction' => self::TXN_DIR_IN,
'amount' => $amount,
'balance_before' => $before,
'balance_after' => $after,
'status' => self::TXN_POSTED,
'external_ref_no' => null,
'idempotent_key' => $idempotentKey,
'remark' => 'post_deduct_confirmation_failed',
]);
}
/**
* 结算派彩入账(产品文档:派彩写入钱包流水;幂等键按结算批次 + 玩家)。
*/
/**
* 手动爆池补发:结算批次已派彩后,将 Jackpot 份额追加入账(幂等键按批次 + 玩家 + 爆池日志)。
*/
public function creditJackpotManualPayout(
Player $player,
string $currencyCode,
int $amountMinor,
int $settlementBatchId,
int $jackpotPayoutLogId,
): void {
if ($amountMinor <= 0) {
return;
}
$idempotentKey = 'jackpot-manual:'.$settlementBatchId.':'.$player->id.':'.$jackpotPayoutLogId;
if (WalletTxn::query()->where('idempotent_key', $idempotentKey)->exists()) {
return;
}
$currency = strtoupper($currencyCode);
$wallet = PlayerWallet::query()
->where('player_id', $player->id)
->where('wallet_type', 'lottery')
->where('currency_code', $currency)
->lockForUpdate()
->first();
if ($wallet === null) {
$wallet = PlayerWallet::query()->create([
'player_id' => $player->id,
'wallet_type' => 'lottery',
'currency_code' => $currency,
'balance' => 0,
'frozen_balance' => 0,
'status' => 0,
'version' => 0,
]);
$wallet = PlayerWallet::query()->whereKey($wallet->id)->lockForUpdate()->firstOrFail();
}
$before = (int) $wallet->balance;
$after = $before + $amountMinor;
$wallet->forceFill([
'balance' => $after,
'version' => (int) $wallet->version + 1,
])->save();
WalletTxn::query()->create([
'txn_no' => $this->newTxnNo(),
'player_id' => $player->id,
'wallet_id' => $wallet->id,
'biz_type' => 'jackpot_manual_payout',
'biz_no' => 'JP'.$jackpotPayoutLogId,
'direction' => self::TXN_DIR_IN,
'amount' => $amountMinor,
'balance_before' => $before,
'balance_after' => $after,
'status' => self::TXN_POSTED,
'external_ref_no' => null,
'idempotent_key' => $idempotentKey,
'remark' => 'manual_jackpot_burst',
]);
}
public function creditSettlementPayout(Player $player, string $currencyCode, int $amountMinor, int $settlementBatchId): void
{
if ($amountMinor <= 0) {
return;
}
$currency = strtoupper($currencyCode);
$idempotentKey = 'settle-payout:'.$settlementBatchId.':'.$player->id;
if (WalletTxn::query()->where('idempotent_key', $idempotentKey)->where('biz_type', 'settle_payout')->exists()) {
return;
}
$wallet = PlayerWallet::query()
->where('player_id', $player->id)
->where('wallet_type', 'lottery')
->where('currency_code', $currency)
->lockForUpdate()
->first();
if ($wallet === null) {
$wallet = PlayerWallet::query()->create([
'player_id' => $player->id,
'wallet_type' => 'lottery',
'currency_code' => $currency,
'balance' => 0,
'frozen_balance' => 0,
'status' => 0,
'version' => 0,
]);
$wallet = PlayerWallet::query()->whereKey($wallet->id)->lockForUpdate()->firstOrFail();
}
$before = (int) $wallet->balance;
$after = $before + $amountMinor;
$wallet->forceFill([
'balance' => $after,
'version' => (int) $wallet->version + 1,
])->save();
WalletTxn::query()->create([
'txn_no' => $this->newTxnNo(),
'player_id' => $player->id,
'wallet_id' => $wallet->id,
'biz_type' => 'settle_payout',
'biz_no' => 'SB'.$settlementBatchId,
'direction' => self::TXN_DIR_IN,
'amount' => $amountMinor,
'balance_before' => $before,
'balance_after' => $after,
'status' => self::TXN_POSTED,
'external_ref_no' => null,
'idempotent_key' => $idempotentKey,
'remark' => null,
]);
}
private function newTxnNo(): string
{
return 'WL'.now()->format('YmdHis').str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
}
}