feat: 增强奖池与钱包管理功能
更新 AdminJackpotPoolUpdateController 校验规则,禁止传入 current_amount。 优化 AdminRiskPoolManualStatusController:更新奖池状态后同步 Redis 状态。 在 TransferOrderReconcileController 中新增 completeCredit 方法,用于处理卡住的转账订单对账。 调整 TransferOrderListController:优化转账订单处理条件。 在 TicketItemsIndexController 中实现支持时区的日期筛选,提升日期处理准确性。 扩展 JackpotPool 模型,新增 adjustments 关联关系。 改进票据与钱包相关服务中的错误处理和事务管理。
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Jackpot;
|
||||
|
||||
use App\Models\JackpotPool;
|
||||
use App\Models\AdminUser;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Jackpot\JackpotPoolAdjustmentService;
|
||||
use App\Http\Requests\Admin\Jackpot\AdminJackpotPoolAdjustRequest;
|
||||
use App\Http\Controllers\Api\V1\Admin\Jackpot\Concerns\PresentsJackpotPoolAdjustment;
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/jackpot/pools/{pool}/adjustments — 奖池余额调整(须备注,写流水)。
|
||||
*/
|
||||
final class AdminJackpotPoolAdjustController extends Controller
|
||||
{
|
||||
use PresentsJackpotPoolAdjustment;
|
||||
|
||||
public function __construct(
|
||||
private readonly JackpotPoolAdjustmentService $adjustments,
|
||||
) {}
|
||||
|
||||
public function __invoke(AdminJackpotPoolAdjustRequest $request, JackpotPool $pool): JsonResponse
|
||||
{
|
||||
$admin = $request->user();
|
||||
if (! $admin instanceof AdminUser) {
|
||||
return ApiResponse::error(
|
||||
trans('admin.unauthenticated', [], $request->lotteryLocale()),
|
||||
ErrorCode::AdminUnauthenticated->value,
|
||||
null,
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
$row = $this->adjustments->apply(
|
||||
$pool,
|
||||
$admin,
|
||||
(int) $request->validated('amount_delta'),
|
||||
(string) $request->validated('reason'),
|
||||
$request,
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
$msg = match ($e->getMessage()) {
|
||||
'adjustment_delta_zero' => trans('jackpot.adjustment_delta_zero', [], $request->lotteryLocale()),
|
||||
'adjustment_reason_required' => trans('jackpot.adjustment_reason_required', [], $request->lotteryLocale()),
|
||||
'adjustment_would_make_balance_negative' => trans('jackpot.adjustment_negative_balance', [], $request->lotteryLocale()),
|
||||
default => trans('api.client_error', [], $request->lotteryLocale()),
|
||||
};
|
||||
|
||||
return ApiResponse::error($msg, ErrorCode::ClientHttpError->value, ['reason' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
$pool->refresh();
|
||||
|
||||
return ApiResponse::success([
|
||||
'adjustment' => $this->adjustmentRow($row),
|
||||
'pool' => [
|
||||
'id' => (int) $pool->id,
|
||||
'currency_code' => $pool->currency_code,
|
||||
'current_amount' => (int) $pool->current_amount,
|
||||
'updated_at' => $pool->updated_at?->toIso8601String(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Jackpot;
|
||||
|
||||
use App\Models\JackpotPool;
|
||||
use App\Support\ApiResponse;
|
||||
use App\Models\JackpotPoolAdjustment;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\PaginationTrait;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Http\Controllers\Api\V1\Admin\Jackpot\Concerns\PresentsJackpotPoolAdjustment;
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/jackpot/pools/{pool}/adjustments — 奖池余额调整流水。
|
||||
*/
|
||||
final class AdminJackpotPoolAdjustmentIndexController extends Controller
|
||||
{
|
||||
use PaginationTrait;
|
||||
use PresentsJackpotPoolAdjustment;
|
||||
|
||||
public function __invoke(Request $request, JackpotPool $pool): JsonResponse
|
||||
{
|
||||
$perPage = $this->perPage($request, 'per_page', 10, 100);
|
||||
$page = $this->page($request);
|
||||
|
||||
$paginator = JackpotPoolAdjustment::query()
|
||||
->where('jackpot_pool_id', $pool->id)
|
||||
->with('adminUser:id,username,name')
|
||||
->orderByDesc('id')
|
||||
->paginate($perPage, ['*'], 'page', $page);
|
||||
|
||||
return ApiResponse::success([
|
||||
'items' => $paginator->getCollection()
|
||||
->map(fn (JackpotPoolAdjustment $row) => $this->adjustmentRow($row))
|
||||
->values()
|
||||
->all(),
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ final class AdminJackpotPoolUpdateController extends Controller
|
||||
public function __invoke(Request $request, JackpotPool $pool): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'current_amount' => 'sometimes|integer|min:0',
|
||||
'current_amount' => 'prohibited',
|
||||
'contribution_rate' => 'sometimes|numeric|min:0|max:1',
|
||||
'trigger_threshold' => 'sometimes|integer|min:0',
|
||||
'payout_rate' => 'sometimes|numeric|min:0|max:1',
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Jackpot\Concerns;
|
||||
|
||||
use App\Models\JackpotPoolAdjustment;
|
||||
|
||||
trait PresentsJackpotPoolAdjustment
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
private function adjustmentRow(JackpotPoolAdjustment $row): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $row->id,
|
||||
'adjustment_no' => $row->adjustment_no,
|
||||
'jackpot_pool_id' => (int) $row->jackpot_pool_id,
|
||||
'admin_user_id' => (int) $row->admin_user_id,
|
||||
'admin_username' => $row->adminUser?->username,
|
||||
'amount_delta' => (int) $row->amount_delta,
|
||||
'balance_before' => (int) $row->balance_before,
|
||||
'balance_after' => (int) $row->balance_after,
|
||||
'reason' => $row->reason,
|
||||
'created_at' => $row->created_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,13 @@ use App\Models\RiskPoolLockLog;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Ticket\RiskPoolService;
|
||||
|
||||
final class AdminRiskPoolManualStatusController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RiskPoolService $riskPoolService,
|
||||
) {}
|
||||
public function close(Draw $draw, string $number_4d): JsonResponse
|
||||
{
|
||||
$pool = $this->updateStatus($draw, $number_4d, true, 'close', 'admin_manual_close');
|
||||
@@ -79,7 +83,12 @@ final class AdminRiskPoolManualStatusController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
return $pool->fresh();
|
||||
$fresh = $pool->fresh();
|
||||
if ($fresh !== null) {
|
||||
$this->riskPoolService->syncRedisStateFromPool($fresh);
|
||||
}
|
||||
|
||||
return $fresh;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -122,7 +122,14 @@ final class TransferOrderListController extends Controller
|
||||
'idempotent_key' => $o->idempotent_key,
|
||||
'status' => $o->status,
|
||||
'can_reverse' => $canWriteWallet && $o->status === 'pending_reconcile',
|
||||
'can_manually_process' => $canWriteWallet && in_array($o->status, ['processing', 'failed', 'pending_reconcile'], true),
|
||||
'can_complete_credit' => $canWriteWallet
|
||||
&& $o->direction === 'in'
|
||||
&& $o->status === 'pending_reconcile'
|
||||
&& $o->fail_reason === 'lottery_credit_failed'
|
||||
&& trim((string) $o->external_ref_no) !== '',
|
||||
'can_manually_process' => $canWriteWallet
|
||||
&& in_array($o->status, ['processing', 'failed', 'pending_reconcile'], true)
|
||||
&& ! ($o->direction === 'out' && $o->status === 'pending_reconcile'),
|
||||
'external_ref_no' => $o->external_ref_no,
|
||||
'external_request_payload' => $o->external_request_payload,
|
||||
'external_response_payload' => $o->external_response_payload,
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Services\Wallet\LotteryTransferService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\Wallet\TransferOrderReverseRequest;
|
||||
use App\Http\Requests\Admin\Wallet\TransferOrderManuallyProcessRequest;
|
||||
use App\Http\Requests\Admin\Wallet\TransferOrderCompleteCreditRequest;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
@@ -71,4 +72,29 @@ final class TransferOrderReconcileController extends Controller
|
||||
|
||||
return ApiResponse::success(['transfer_no' => $transferNo, 'status' => 'manually_processed']);
|
||||
}
|
||||
|
||||
public function completeCredit(TransferOrderCompleteCreditRequest $request, string $transferNo): JsonResponse
|
||||
{
|
||||
$order = TransferOrder::query()->where('transfer_no', $transferNo)->first();
|
||||
if ($order === null) {
|
||||
return ApiResponse::error(__('wallet.order_not_found'), 404);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->transferService->reconcileTransferOrder(
|
||||
$order,
|
||||
'complete_credit',
|
||||
(string) $request->validated('remark', ''),
|
||||
);
|
||||
} catch (WalletOperationException $e) {
|
||||
return ApiResponse::error(
|
||||
LotteryMessage::wallet($request, $e->lotteryCode),
|
||||
$e->lotteryCode,
|
||||
null,
|
||||
$e->httpStatus,
|
||||
);
|
||||
}
|
||||
|
||||
return ApiResponse::success(['transfer_no' => $transferNo, 'status' => 'success']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,14 +61,14 @@ final class TicketDrawMyMatchController extends Controller
|
||||
$itemIds = TicketItem::query()
|
||||
->where('draw_id', $draw->id)
|
||||
->where('player_id', $player->id)
|
||||
->whereIn('status', ['pending_draw', 'settled_win', 'settled_lose'])
|
||||
->whereIn('status', ['pending_draw', 'pending_payout', 'settled_win', 'settled_lose'])
|
||||
->pluck('id');
|
||||
|
||||
$hasBets = $itemIds->isNotEmpty();
|
||||
$winningItemIds = TicketItem::query()
|
||||
->where('draw_id', $draw->id)
|
||||
->where('player_id', $player->id)
|
||||
->where('status', 'settled_win')
|
||||
->whereIn('status', ['settled_win', 'pending_payout'])
|
||||
->where(function ($q): void {
|
||||
$q->where('win_amount', '>', 0)
|
||||
->orWhere('jackpot_win_amount', '>', 0);
|
||||
@@ -90,7 +90,7 @@ final class TicketDrawMyMatchController extends Controller
|
||||
$sums = TicketItem::query()
|
||||
->where('draw_id', $draw->id)
|
||||
->where('player_id', $player->id)
|
||||
->whereIn('status', ['settled_win', 'settled_lose'])
|
||||
->whereIn('status', ['settled_win', 'settled_lose', 'pending_payout'])
|
||||
->selectRaw('coalesce(sum(win_amount),0) as sum_win, coalesce(sum(jackpot_win_amount),0) as sum_jackpot')
|
||||
->first();
|
||||
|
||||
|
||||
@@ -62,12 +62,13 @@ final class TicketItemShowController extends Controller
|
||||
->where('biz_no', $order?->order_no)
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
$payoutTxn = WalletTxn::query()
|
||||
->where('player_id', $player->id)
|
||||
->where('biz_type', 'settle_payout')
|
||||
->where('biz_no', 'like', 'SB%')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
$payoutTxn = $settlementBatch !== null
|
||||
? WalletTxn::query()
|
||||
->where('player_id', $player->id)
|
||||
->where('biz_type', 'settle_payout')
|
||||
->where('biz_no', 'SB'.$settlementBatch->id)
|
||||
->first()
|
||||
: null;
|
||||
|
||||
$timeline = [];
|
||||
if ($order?->created_at !== null) {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Ticket;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use App\Models\Player;
|
||||
use App\Models\TicketItem;
|
||||
use App\Models\WalletTxn;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Support\PaginationTrait;
|
||||
@@ -65,11 +65,13 @@ final class TicketItemsIndexController extends Controller
|
||||
}
|
||||
|
||||
if ($startDate !== null) {
|
||||
$query->whereHas('order', fn ($q) => $q->whereDate('created_at', '>=', $startDate));
|
||||
$fromUtc = $this->scheduleDateStartUtc($startDate);
|
||||
$query->whereHas('order', fn ($q) => $q->where('created_at', '>=', $fromUtc));
|
||||
}
|
||||
|
||||
if ($endDate !== null) {
|
||||
$query->whereHas('order', fn ($q) => $q->whereDate('created_at', '<=', $endDate));
|
||||
$toUtc = $this->scheduleDateEndUtc($endDate);
|
||||
$query->whereHas('order', fn ($q) => $q->where('created_at', '<=', $toUtc));
|
||||
}
|
||||
|
||||
$paginator = $query->paginate(perPage: $perPage, page: $page);
|
||||
@@ -119,4 +121,23 @@ final class TicketItemsIndexController extends Controller
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function scheduleTimezone(): string
|
||||
{
|
||||
return (string) config('lottery.draw.timezone', 'UTC');
|
||||
}
|
||||
|
||||
private function scheduleDateStartUtc(string $ymd): Carbon
|
||||
{
|
||||
return Carbon::createFromFormat('Y-m-d', $ymd, $this->scheduleTimezone())
|
||||
->startOfDay()
|
||||
->utc();
|
||||
}
|
||||
|
||||
private function scheduleDateEndUtc(string $ymd): Carbon
|
||||
{
|
||||
return Carbon::createFromFormat('Y-m-d', $ymd, $this->scheduleTimezone())
|
||||
->endOfDay()
|
||||
->utc();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ final class WalletLogsController extends Controller
|
||||
'transfer_out' => ['transfer_out'],
|
||||
'refund' => ['transfer_out_refund'],
|
||||
'reversal' => ['reversal'],
|
||||
'bet' => ['bet_deduct', 'bet'],
|
||||
'prize' => ['settle_payout', 'prize'],
|
||||
'bet' => ['bet_deduct', 'bet', 'bet_reverse'],
|
||||
'prize' => ['settle_payout', 'prize', 'jackpot_manual_payout'],
|
||||
];
|
||||
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
@@ -153,8 +153,9 @@ final class WalletLogsController extends Controller
|
||||
{
|
||||
return match ($biz) {
|
||||
'transfer_out_refund' => 'refund',
|
||||
'bet_deduct' => 'bet',
|
||||
'settle_payout' => 'prize',
|
||||
'bet_deduct', 'bet' => 'bet',
|
||||
'bet_reverse' => 'reversal',
|
||||
'settle_payout', 'prize', 'jackpot_manual_payout' => 'prize',
|
||||
'reversal' => 'reversal',
|
||||
default => $biz,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Jackpot;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
final class AdminJackpotPoolAdjustRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'amount_delta' => ['required', 'integer', 'not_in:0'],
|
||||
'reason' => ['required', 'string', 'min:3', 'max:500'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Wallet;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
final class TransferOrderCompleteCreditRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'remark' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -46,4 +46,9 @@ final class JackpotPool extends Model
|
||||
{
|
||||
return $this->hasMany(JackpotContribution::class, 'jackpot_pool_id');
|
||||
}
|
||||
|
||||
public function adjustments(): HasMany
|
||||
{
|
||||
return $this->hasMany(JackpotPoolAdjustment::class, 'jackpot_pool_id');
|
||||
}
|
||||
}
|
||||
|
||||
41
app/Models/JackpotPoolAdjustment.php
Normal file
41
app/Models/JackpotPoolAdjustment.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/** 奖池余额人工调整流水 {@see jackpot_pool_adjustments} */
|
||||
final class JackpotPoolAdjustment extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'adjustment_no',
|
||||
'jackpot_pool_id',
|
||||
'admin_user_id',
|
||||
'amount_delta',
|
||||
'balance_before',
|
||||
'balance_after',
|
||||
'reason',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'amount_delta' => 'integer',
|
||||
'balance_before' => 'integer',
|
||||
'balance_after' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<JackpotPool, JackpotPoolAdjustment> */
|
||||
public function pool(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(JackpotPool::class, 'jackpot_pool_id');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<AdminUser, JackpotPoolAdjustment> */
|
||||
public function adminUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AdminUser::class, 'admin_user_id');
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,10 @@ use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class DrawAdminActionService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DrawCancelBetRefundService $cancelBetRefund,
|
||||
) {}
|
||||
|
||||
public function manualClose(Draw $draw): Draw
|
||||
{
|
||||
return DB::transaction(function () use ($draw): Draw {
|
||||
@@ -43,6 +47,8 @@ final class DrawAdminActionService
|
||||
throw new \RuntimeException('draw_result_exists');
|
||||
}
|
||||
|
||||
$this->cancelBetRefund->refundOpenBetsForDraw($locked);
|
||||
|
||||
$locked->forceFill(['status' => DrawStatus::Cancelled->value])->save();
|
||||
|
||||
return $locked->refresh();
|
||||
|
||||
91
app/Services/Draw/DrawCancelBetRefundService.php
Normal file
91
app/Services/Draw/DrawCancelBetRefundService.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Draw;
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\TicketItem;
|
||||
use App\Models\TicketOrder;
|
||||
use App\Models\WalletTxn;
|
||||
use App\Services\Ticket\RiskPoolService;
|
||||
use App\Services\Ticket\TicketWalletService;
|
||||
|
||||
/**
|
||||
* 取消期号前:退本、释池,避免已扣款注单悬空。
|
||||
*/
|
||||
final class DrawCancelBetRefundService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RiskPoolService $riskPool,
|
||||
private readonly TicketWalletService $ticketWallet,
|
||||
) {}
|
||||
|
||||
public function refundOpenBetsForDraw(Draw $draw): void
|
||||
{
|
||||
$hasBlockedItems = TicketItem::query()
|
||||
->where('draw_id', $draw->id)
|
||||
->whereIn('status', ['settled_win', 'settled_lose', 'pending_payout'])
|
||||
->exists();
|
||||
|
||||
if ($hasBlockedItems) {
|
||||
throw new \RuntimeException('draw_has_settled_tickets');
|
||||
}
|
||||
|
||||
$orders = TicketOrder::query()
|
||||
->where('draw_id', $draw->id)
|
||||
->whereNotIn('status', ['refunded', 'failed'])
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
foreach ($orders as $order) {
|
||||
$this->refundOrder($draw, $order);
|
||||
}
|
||||
}
|
||||
|
||||
private function refundOrder(Draw $draw, TicketOrder $order): void
|
||||
{
|
||||
$lockedOrder = TicketOrder::query()->whereKey($order->id)->lockForUpdate()->first();
|
||||
if ($lockedOrder === null || in_array($lockedOrder->status, ['refunded', 'failed'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$items = TicketItem::query()
|
||||
->where('order_id', $lockedOrder->id)
|
||||
->whereIn('status', ['pending_confirm', 'pending_draw', 'pending_payout'])
|
||||
->with('combinations')
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
|
||||
foreach ($items as $item) {
|
||||
$locks = [];
|
||||
foreach ($item->combinations as $combo) {
|
||||
$locks[] = [
|
||||
'number_4d' => (string) $combo->number_4d,
|
||||
'amount' => (int) $combo->estimated_payout,
|
||||
];
|
||||
}
|
||||
|
||||
if ($locks !== []) {
|
||||
$this->riskPool->release((int) $draw->id, $item, $locks);
|
||||
}
|
||||
|
||||
$item->forceFill([
|
||||
'status' => 'refunded',
|
||||
'fail_reason_code' => 'draw_cancelled',
|
||||
'fail_reason_text' => 'draw_cancelled_refund',
|
||||
'risk_locked_amount' => 0,
|
||||
])->save();
|
||||
}
|
||||
|
||||
$hasPostedDeduct = WalletTxn::query()
|
||||
->where('biz_type', 'bet_deduct')
|
||||
->where('biz_no', $lockedOrder->order_no)
|
||||
->where('status', 'posted')
|
||||
->exists();
|
||||
|
||||
if ($hasPostedDeduct) {
|
||||
$this->ticketWallet->reverseBetDeduct($lockedOrder);
|
||||
}
|
||||
|
||||
$lockedOrder->forceFill(['status' => 'refunded'])->save();
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,14 @@ final class DrawHallSnapshotBuilder
|
||||
return DrawStatus::Closing->value;
|
||||
}
|
||||
|
||||
/** 与大厅 {@see effectiveHallDisplayStatus} 一致:是否仍接受 preview/place。 */
|
||||
public function isBettingOpen(Draw $draw, ?Carbon $nowUtc = null): bool
|
||||
{
|
||||
$nowUtc ??= now()->utc();
|
||||
|
||||
return $this->effectiveHallDisplayStatus($draw, $nowUtc) === DrawStatus::Open->value;
|
||||
}
|
||||
|
||||
private function showsPublishedResults(string $drawStatus): bool
|
||||
{
|
||||
return in_array($drawStatus, [
|
||||
|
||||
@@ -28,6 +28,13 @@ final class DrawManualResultService
|
||||
throw new \RuntimeException('draw_already_settled');
|
||||
}
|
||||
|
||||
if (DrawResultBatch::query()
|
||||
->where('draw_id', $locked->id)
|
||||
->where('status', DrawResultBatchStatus::PendingReview->value)
|
||||
->exists()) {
|
||||
throw new \RuntimeException('draw_pending_result_batch_exists');
|
||||
}
|
||||
|
||||
$nextVersion = max(1, (int) $locked->current_result_version + 1);
|
||||
$batch = DrawResultBatch::query()->create([
|
||||
'draw_id' => $locked->id,
|
||||
|
||||
@@ -6,9 +6,11 @@ use App\Models\Draw;
|
||||
use App\Models\AdminUser;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\DrawResultBatch;
|
||||
use App\Models\SettlementBatch;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Services\LotterySettings;
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use App\Lottery\SettlementBatchStatus;
|
||||
|
||||
/**
|
||||
* 人工审核通过后发布结果;或 RNG 自动生成路径内联调用同一事务字段更新。
|
||||
@@ -114,10 +116,28 @@ final class DrawPublishService
|
||||
DrawStatus::Closed->value,
|
||||
DrawStatus::Review->value,
|
||||
DrawStatus::Cooldown->value,
|
||||
DrawStatus::Settling->value,
|
||||
];
|
||||
if (! in_array($draw->status, $allowed, true)) {
|
||||
throw new \RuntimeException('draw_not_ready_to_publish');
|
||||
}
|
||||
|
||||
$this->assertNoActiveSettlementWorkflow($draw);
|
||||
}
|
||||
|
||||
/** 存在未完结结算批次时不允许改发布结果,避免派彩与大厅展示号码不一致。 */
|
||||
private function assertNoActiveSettlementWorkflow(Draw $draw): void
|
||||
{
|
||||
$active = SettlementBatch::query()
|
||||
->where('draw_id', $draw->id)
|
||||
->whereIn('status', [
|
||||
SettlementBatchStatus::Running->value,
|
||||
SettlementBatchStatus::PendingReview->value,
|
||||
SettlementBatchStatus::Approved->value,
|
||||
])
|
||||
->exists();
|
||||
|
||||
if ($active) {
|
||||
throw new \RuntimeException('draw_settlement_in_progress');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,14 @@ final class JackpotContributionService
|
||||
return;
|
||||
}
|
||||
|
||||
$existing = JackpotContribution::query()
|
||||
->where('ticket_item_id', $item->id)
|
||||
->first();
|
||||
|
||||
if ($existing !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
JackpotContribution::query()->create([
|
||||
'jackpot_pool_id' => $pool->id,
|
||||
'draw_id' => $draw->id,
|
||||
|
||||
95
app/Services/Jackpot/JackpotPoolAdjustmentService.php
Normal file
95
app/Services/Jackpot/JackpotPoolAdjustmentService.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Jackpot;
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\JackpotPool;
|
||||
use App\Models\JackpotPoolAdjustment;
|
||||
use App\Services\AuditLogger;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class JackpotPoolAdjustmentService
|
||||
{
|
||||
public function apply(
|
||||
JackpotPool $pool,
|
||||
AdminUser $admin,
|
||||
int $amountDelta,
|
||||
string $reason,
|
||||
?Request $request = null,
|
||||
): JackpotPoolAdjustment {
|
||||
if ($amountDelta === 0) {
|
||||
throw new \RuntimeException('adjustment_delta_zero');
|
||||
}
|
||||
|
||||
$reason = trim($reason);
|
||||
if ($reason === '') {
|
||||
throw new \RuntimeException('adjustment_reason_required');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($pool, $admin, $amountDelta, $reason, $request): JackpotPoolAdjustment {
|
||||
/** @var JackpotPool $locked */
|
||||
$locked = JackpotPool::query()->whereKey($pool->id)->lockForUpdate()->firstOrFail();
|
||||
|
||||
$before = (int) $locked->current_amount;
|
||||
$after = $before + $amountDelta;
|
||||
if ($after < 0) {
|
||||
throw new \RuntimeException('adjustment_would_make_balance_negative');
|
||||
}
|
||||
|
||||
$adjustment = JackpotPoolAdjustment::query()->create([
|
||||
'adjustment_no' => $this->newAdjustmentNo(),
|
||||
'jackpot_pool_id' => $locked->id,
|
||||
'admin_user_id' => $admin->id,
|
||||
'amount_delta' => $amountDelta,
|
||||
'balance_before' => $before,
|
||||
'balance_after' => $after,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
|
||||
$locked->forceFill(['current_amount' => $after])->save();
|
||||
|
||||
$snapshot = [
|
||||
'currency_code' => $locked->currency_code,
|
||||
'amount_delta' => $amountDelta,
|
||||
'balance_before' => $before,
|
||||
'balance_after' => $after,
|
||||
'reason' => $reason,
|
||||
'adjustment_no' => $adjustment->adjustment_no,
|
||||
];
|
||||
|
||||
if ($request !== null) {
|
||||
AuditLogger::recordFromRequest(
|
||||
$request,
|
||||
AuditLogger::OPERATOR_ADMIN,
|
||||
$admin->id,
|
||||
'jackpot',
|
||||
'adjust_balance',
|
||||
'jackpot_pool',
|
||||
(string) $locked->id,
|
||||
['current_amount' => $before],
|
||||
$snapshot,
|
||||
);
|
||||
} else {
|
||||
AuditLogger::record(
|
||||
AuditLogger::OPERATOR_ADMIN,
|
||||
$admin->id,
|
||||
'jackpot',
|
||||
'adjust_balance',
|
||||
'jackpot_pool',
|
||||
(string) $locked->id,
|
||||
['current_amount' => $before],
|
||||
$snapshot,
|
||||
);
|
||||
}
|
||||
|
||||
return $adjustment->fresh(['adminUser']);
|
||||
});
|
||||
}
|
||||
|
||||
private function newAdjustmentNo(): string
|
||||
{
|
||||
return 'JA'.now()->format('YmdHis').Str::upper(Str::random(6));
|
||||
}
|
||||
}
|
||||
@@ -97,11 +97,15 @@ final class SettlementBatchWorkflowService
|
||||
|
||||
$batchItemIds = $locked->details()->pluck('ticket_item_id')->map(fn ($id) => (int) $id)->all();
|
||||
|
||||
$hasPendingDraw = TicketItem::query()
|
||||
$hasUnsettled = TicketItem::query()
|
||||
->where('draw_id', $locked->draw_id)
|
||||
->where('status', 'pending_draw')
|
||||
->whereIn('status', [
|
||||
'pending_confirm',
|
||||
'partial_pending_confirm',
|
||||
'pending_draw',
|
||||
])
|
||||
->exists();
|
||||
if ($hasPendingDraw) {
|
||||
if ($hasUnsettled) {
|
||||
throw new \RuntimeException('draw_has_unsettled_tickets');
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ final class SettlementOrchestrator
|
||||
->where('draw_id', $locked->id)
|
||||
->where('result_batch_id', $publishedBatch->id)
|
||||
->whereIn('status', [
|
||||
SettlementBatchStatus::Running->value,
|
||||
SettlementBatchStatus::PendingReview->value,
|
||||
SettlementBatchStatus::Approved->value,
|
||||
SettlementBatchStatus::Paid->value,
|
||||
|
||||
@@ -172,6 +172,28 @@ final class RiskPoolService
|
||||
}
|
||||
}
|
||||
|
||||
/** 后台改池或释池后,将 Redis 风控快照与 DB 对齐。 */
|
||||
public function syncRedisStateFromPool(RiskPool $pool): void
|
||||
{
|
||||
if (! $this->shouldUseRedisAtomicLocks()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$total = (int) $pool->total_cap_amount;
|
||||
$locked = (int) $pool->locked_amount;
|
||||
$remaining = max(0, $total - $locked);
|
||||
|
||||
Redis::eval(
|
||||
$this->overwriteStateLua(),
|
||||
1,
|
||||
$this->redisPoolKey((int) $pool->draw_id, (string) $pool->normalized_number),
|
||||
$total,
|
||||
$locked,
|
||||
$remaining,
|
||||
(int) $pool->version,
|
||||
);
|
||||
}
|
||||
|
||||
private function shouldUseRedisAtomicLocks(): bool
|
||||
{
|
||||
if (App::environment('testing')) {
|
||||
@@ -196,6 +218,14 @@ return 1
|
||||
LUA;
|
||||
}
|
||||
|
||||
private function overwriteStateLua(): string
|
||||
{
|
||||
return <<<'LUA'
|
||||
redis.call('HMSET', KEYS[1], 'total', ARGV[1], 'locked', ARGV[2], 'remaining', ARGV[3], 'version', ARGV[4])
|
||||
return 1
|
||||
LUA;
|
||||
}
|
||||
|
||||
private function acquireLua(): string
|
||||
{
|
||||
return <<<'LUA'
|
||||
|
||||
@@ -2,15 +2,21 @@
|
||||
|
||||
namespace App\Services\Ticket;
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\WalletTxn;
|
||||
use App\Models\TicketItem;
|
||||
use App\Models\TicketOrder;
|
||||
use App\Services\Draw\DrawHallSnapshotBuilder;
|
||||
use App\Services\Jackpot\JackpotContributionService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class TicketPendingConfirmReconcileService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RiskPoolService $riskPool,
|
||||
private readonly JackpotContributionService $jackpotContribution,
|
||||
private readonly DrawHallSnapshotBuilder $drawHallSnapshot,
|
||||
private readonly TicketWalletService $ticketWallet,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -20,7 +26,7 @@ final class TicketPendingConfirmReconcileService
|
||||
{
|
||||
$cutoff = now()->subMinutes($staleMinutes);
|
||||
$orders = TicketOrder::query()
|
||||
->where('status', 'pending_confirm')
|
||||
->whereIn('status', ['pending_confirm', 'partial_pending_confirm'])
|
||||
->where('updated_at', '<=', $cutoff)
|
||||
->orderBy('id')
|
||||
->limit($limit)
|
||||
@@ -35,7 +41,7 @@ final class TicketPendingConfirmReconcileService
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($lockedOrder === null || $lockedOrder->status !== 'pending_confirm') {
|
||||
if ($lockedOrder === null || ! in_array($lockedOrder->status, ['pending_confirm', 'partial_pending_confirm'], true)) {
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
@@ -46,52 +52,10 @@ final class TicketPendingConfirmReconcileService
|
||||
->exists();
|
||||
|
||||
if ($hasPostedDeduct) {
|
||||
TicketItem::query()
|
||||
->where('order_id', $lockedOrder->id)
|
||||
->where('status', 'pending_confirm')
|
||||
->update([
|
||||
'status' => 'pending_draw',
|
||||
'fail_reason_code' => null,
|
||||
'fail_reason_text' => null,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$lockedOrder->forceFill(['status' => 'placed'])->save();
|
||||
|
||||
return 'confirmed';
|
||||
return $this->confirmOrder($lockedOrder);
|
||||
}
|
||||
|
||||
$items = TicketItem::query()
|
||||
->where('order_id', $lockedOrder->id)
|
||||
->where('status', 'pending_confirm')
|
||||
->with('combinations')
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
|
||||
foreach ($items as $item) {
|
||||
$locks = [];
|
||||
foreach ($item->combinations as $combo) {
|
||||
$locks[] = [
|
||||
'number_4d' => (string) $combo->number_4d,
|
||||
'amount' => (int) $combo->estimated_payout,
|
||||
];
|
||||
}
|
||||
|
||||
if ($locks !== []) {
|
||||
$this->riskPool->release((int) $lockedOrder->draw_id, $item, $locks);
|
||||
}
|
||||
|
||||
$item->forceFill([
|
||||
'status' => 'refunded',
|
||||
'fail_reason_code' => 'pending_confirm_timeout',
|
||||
'fail_reason_text' => 'pending_confirm_timeout_refund',
|
||||
'risk_locked_amount' => 0,
|
||||
])->save();
|
||||
}
|
||||
|
||||
$lockedOrder->forceFill(['status' => 'refunded'])->save();
|
||||
|
||||
return 'refunded';
|
||||
return $this->refundOrderWithoutDeduct($lockedOrder);
|
||||
});
|
||||
|
||||
if ($result === 'skipped') {
|
||||
@@ -109,4 +73,104 @@ final class TicketPendingConfirmReconcileService
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
private function confirmOrder(TicketOrder $lockedOrder): string
|
||||
{
|
||||
$draw = Draw::query()->whereKey($lockedOrder->draw_id)->first();
|
||||
if ($draw === null || ! $this->drawHallSnapshot->isBettingOpen($draw)) {
|
||||
return $this->refundStalePendingOrder(
|
||||
$lockedOrder,
|
||||
$draw === null ? 'draw_missing' : 'draw_no_longer_open',
|
||||
);
|
||||
}
|
||||
|
||||
$items = TicketItem::query()
|
||||
->where('order_id', $lockedOrder->id)
|
||||
->where('status', 'pending_confirm')
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
|
||||
foreach ($items as $item) {
|
||||
$item->forceFill([
|
||||
'status' => 'pending_draw',
|
||||
'fail_reason_code' => null,
|
||||
'fail_reason_text' => null,
|
||||
])->save();
|
||||
|
||||
$this->jackpotContribution->recordFromPlacedTicketItem($item, $draw, (string) $lockedOrder->currency_code);
|
||||
}
|
||||
|
||||
$hasFailures = TicketItem::query()
|
||||
->where('order_id', $lockedOrder->id)
|
||||
->where('status', 'failed')
|
||||
->exists();
|
||||
|
||||
$lockedOrder->forceFill([
|
||||
'status' => $hasFailures ? 'partial_failed' : 'placed',
|
||||
])->save();
|
||||
|
||||
return 'confirmed';
|
||||
}
|
||||
|
||||
private function refundStalePendingOrder(TicketOrder $lockedOrder, string $reasonCode): string
|
||||
{
|
||||
$hasPostedDeduct = WalletTxn::query()
|
||||
->where('biz_type', 'bet_deduct')
|
||||
->where('biz_no', $lockedOrder->order_no)
|
||||
->where('status', 'posted')
|
||||
->exists();
|
||||
|
||||
if ($hasPostedDeduct) {
|
||||
$this->ticketWallet->reverseBetDeduct($lockedOrder);
|
||||
}
|
||||
|
||||
return $this->refundPendingConfirmItems($lockedOrder, $reasonCode);
|
||||
}
|
||||
|
||||
private function refundOrderWithoutDeduct(TicketOrder $lockedOrder): string
|
||||
{
|
||||
return $this->refundPendingConfirmItems($lockedOrder, 'pending_confirm_timeout');
|
||||
}
|
||||
|
||||
private function refundPendingConfirmItems(TicketOrder $lockedOrder, string $reasonCode): string
|
||||
{
|
||||
$items = TicketItem::query()
|
||||
->where('order_id', $lockedOrder->id)
|
||||
->where('status', 'pending_confirm')
|
||||
->with('combinations')
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
|
||||
foreach ($items as $item) {
|
||||
$locks = [];
|
||||
foreach ($item->combinations as $combo) {
|
||||
$locks[] = [
|
||||
'number_4d' => (string) $combo->number_4d,
|
||||
'amount' => (int) $combo->estimated_payout,
|
||||
];
|
||||
}
|
||||
|
||||
if ($locks !== []) {
|
||||
$this->riskPool->release((int) $lockedOrder->draw_id, $item, $locks);
|
||||
}
|
||||
|
||||
$item->forceFill([
|
||||
'status' => 'refunded',
|
||||
'fail_reason_code' => $reasonCode,
|
||||
'fail_reason_text' => $reasonCode.'_refund',
|
||||
'risk_locked_amount' => 0,
|
||||
])->save();
|
||||
}
|
||||
|
||||
$hasFailures = TicketItem::query()
|
||||
->where('order_id', $lockedOrder->id)
|
||||
->where('status', 'failed')
|
||||
->exists();
|
||||
|
||||
$lockedOrder->forceFill([
|
||||
'status' => $hasFailures ? 'partial_failed' : 'refunded',
|
||||
])->save();
|
||||
|
||||
return 'refunded';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,13 @@ use App\Models\Player;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Models\TicketItem;
|
||||
use App\Models\WalletTxn;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\TicketOrder;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Models\TicketCombination;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Exceptions\TicketOperationException;
|
||||
use App\Services\Jackpot\JackpotContributionService;
|
||||
use App\Services\Draw\DrawHallSnapshotBuilder;
|
||||
|
||||
final class TicketPlacementService
|
||||
{
|
||||
@@ -23,6 +23,7 @@ final class TicketPlacementService
|
||||
private readonly RiskPoolService $riskPoolService,
|
||||
private readonly TicketWalletService $ticketWalletService,
|
||||
private readonly JackpotContributionService $jackpotContribution,
|
||||
private readonly DrawHallSnapshotBuilder $drawHallSnapshot,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -36,11 +37,14 @@ final class TicketPlacementService
|
||||
? (string) $payload['client_trace_id']
|
||||
: null;
|
||||
|
||||
if ($clientTraceId !== null) {
|
||||
$drawNo = (string) $payload['draw_id'];
|
||||
$drawIdForIdempotency = Draw::query()->where('draw_no', $drawNo)->value('id');
|
||||
|
||||
if ($clientTraceId !== null && $drawIdForIdempotency !== null) {
|
||||
$existing = TicketOrder::query()
|
||||
->where('player_id', $player->id)
|
||||
->where('draw_id', $drawIdForIdempotency)
|
||||
->where('client_trace_id', $clientTraceId)
|
||||
->whereIn('status', ['placed', 'partial_failed'])
|
||||
->first();
|
||||
|
||||
if ($existing !== null) {
|
||||
@@ -72,7 +76,7 @@ final class TicketPlacementService
|
||||
if ($draw === null) {
|
||||
throw new TicketOperationException('draw_not_found', ErrorCode::BetInvalidDraw->value);
|
||||
}
|
||||
if ($draw->status !== DrawStatus::Open->value || ($draw->close_time !== null && now()->greaterThanOrEqualTo($draw->close_time))) {
|
||||
if (! $this->drawHallSnapshot->isBettingOpen($draw)) {
|
||||
throw new TicketOperationException('draw_closed', ErrorCode::DrawClosed->value);
|
||||
}
|
||||
|
||||
@@ -132,13 +136,15 @@ final class TicketPlacementService
|
||||
);
|
||||
}
|
||||
|
||||
$walletBalance = (int) (PlayerWallet::query()
|
||||
$wallet = PlayerWallet::query()
|
||||
->where('player_id', $player->id)
|
||||
->where('wallet_type', 'lottery')
|
||||
->where('currency_code', $currencyCode)
|
||||
->lockForUpdate()
|
||||
->value('balance') ?? 0);
|
||||
if ($walletBalance < $totalActualDeduct) {
|
||||
->first();
|
||||
$walletBalance = $wallet !== null ? (int) $wallet->balance : 0;
|
||||
$walletAvailable = $walletBalance - ($wallet !== null ? (int) $wallet->frozen_balance : 0);
|
||||
if ($walletAvailable < $totalActualDeduct) {
|
||||
throw new TicketOperationException('bet_insufficient_balance', ErrorCode::BetInsufficientBalance->value);
|
||||
}
|
||||
|
||||
@@ -333,6 +339,10 @@ final class TicketPlacementService
|
||||
$order = TicketOrder::query()->whereKey($order->id)->firstOrFail();
|
||||
$draw = Draw::query()->whereKey((int) $order->draw_id)->firstOrFail();
|
||||
$successCount = TicketItem::query()->where('order_id', $order->id)->where('status', 'pending_draw')->count();
|
||||
$pendingConfirmCount = TicketItem::query()
|
||||
->where('order_id', $order->id)
|
||||
->whereIn('status', ['pending_confirm', 'partial_pending_confirm'])
|
||||
->count();
|
||||
$failureCount = TicketItem::query()->where('order_id', $order->id)->where('status', 'failed')->count();
|
||||
if ($balanceAfter === null) {
|
||||
$walletTxn = WalletTxn::query()
|
||||
@@ -350,11 +360,13 @@ final class TicketPlacementService
|
||||
'status' => $draw->status,
|
||||
],
|
||||
'summary' => [
|
||||
'order_status' => $order->status,
|
||||
'total_bet_amount' => (int) $order->total_bet_amount,
|
||||
'total_rebate_amount' => (int) $order->total_rebate_amount,
|
||||
'total_actual_deduct' => (int) $order->total_actual_deduct,
|
||||
'total_estimated_payout' => (int) $order->total_estimated_payout,
|
||||
'success_count' => $successCount,
|
||||
'pending_confirm_count' => $pendingConfirmCount,
|
||||
'failure_count' => $failureCount,
|
||||
],
|
||||
'balance_after' => $balanceAfter,
|
||||
|
||||
@@ -4,8 +4,8 @@ namespace App\Services\Ticket;
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Exceptions\TicketOperationException;
|
||||
use App\Services\Draw\DrawHallSnapshotBuilder;
|
||||
|
||||
final class TicketPreviewService
|
||||
{
|
||||
@@ -13,6 +13,7 @@ final class TicketPreviewService
|
||||
private readonly PlayCatalogResolver $catalogResolver,
|
||||
private readonly PlayRuleEngine $ruleEngine,
|
||||
private readonly RiskPoolService $riskPoolService,
|
||||
private readonly DrawHallSnapshotBuilder $drawHallSnapshot,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -25,7 +26,7 @@ final class TicketPreviewService
|
||||
if ($draw === null) {
|
||||
throw new TicketOperationException('draw_not_found', ErrorCode::BetInvalidDraw->value);
|
||||
}
|
||||
if ($draw->status !== DrawStatus::Open->value || ($draw->close_time !== null && now()->greaterThanOrEqualTo($draw->close_time))) {
|
||||
if (! $this->drawHallSnapshot->isBettingOpen($draw)) {
|
||||
throw new TicketOperationException('draw_closed', ErrorCode::DrawClosed->value);
|
||||
}
|
||||
|
||||
@@ -108,10 +109,13 @@ final class TicketPreviewService
|
||||
);
|
||||
}
|
||||
|
||||
$nowUtc = now()->utc();
|
||||
|
||||
return [
|
||||
'draw' => [
|
||||
'draw_id' => $draw->draw_no,
|
||||
'status' => $draw->status,
|
||||
'status' => $this->drawHallSnapshot->effectiveHallDisplayStatus($draw, $nowUtc),
|
||||
'db_status' => $draw->status,
|
||||
],
|
||||
'config_versions' => $this->catalogResolver->currentActiveVersionStamp(),
|
||||
'summary' => [
|
||||
|
||||
@@ -39,8 +39,13 @@ final class TicketWalletService
|
||||
$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;
|
||||
if ($before < $amountMinor) {
|
||||
$available = $before - (int) $wallet->frozen_balance;
|
||||
if ($available < $amountMinor) {
|
||||
throw new TicketOperationException('bet_insufficient_balance', ErrorCode::BetInsufficientBalance->value);
|
||||
}
|
||||
|
||||
@@ -62,7 +67,7 @@ final class TicketWalletService
|
||||
'balance_after' => $after,
|
||||
'status' => self::TXN_POSTED,
|
||||
'external_ref_no' => null,
|
||||
'idempotent_key' => $order->client_trace_id,
|
||||
'idempotent_key' => 'bet_deduct:'.$order->order_no,
|
||||
'remark' => null,
|
||||
]);
|
||||
|
||||
@@ -191,6 +196,10 @@ final class TicketWalletService
|
||||
}
|
||||
|
||||
$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)
|
||||
@@ -231,7 +240,7 @@ final class TicketWalletService
|
||||
'balance_after' => $after,
|
||||
'status' => self::TXN_POSTED,
|
||||
'external_ref_no' => null,
|
||||
'idempotent_key' => 'settle-payout:'.$settlementBatchId.':'.$player->id,
|
||||
'idempotent_key' => $idempotentKey,
|
||||
'remark' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -135,28 +135,44 @@ final class LotteryTransferService
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
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();
|
||||
});
|
||||
$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(),
|
||||
@@ -328,6 +344,12 @@ final class LotteryTransferService
|
||||
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));
|
||||
|
||||
@@ -388,6 +410,79 @@ final class LotteryTransferService
|
||||
])->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 */
|
||||
@@ -406,6 +501,14 @@ final class LotteryTransferService
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
$locked->forceFill([
|
||||
'status' => self::ST_MANUALLY_PROCESSED,
|
||||
'fail_reason' => 'manually_processed: '.($remark ?: 'admin_manual'),
|
||||
@@ -413,6 +516,13 @@ final class LotteryTransferService
|
||||
])->save();
|
||||
}
|
||||
|
||||
/** 仅主站已扣款(有 external_ref_no)且彩票入账失败时可补完成转入。 */
|
||||
private function isEligibleForCompleteCredit(TransferOrder $order): bool
|
||||
{
|
||||
return $order->fail_reason === 'lottery_credit_failed'
|
||||
&& trim((string) $order->external_ref_no) !== '';
|
||||
}
|
||||
|
||||
private function lockLotteryWalletById(int $playerId, string $currencyCode): PlayerWallet
|
||||
{
|
||||
$wallet = PlayerWallet::query()
|
||||
@@ -699,7 +809,8 @@ final class LotteryTransferService
|
||||
bool $requireBalance = false,
|
||||
): array {
|
||||
$before = (int) $wallet->balance;
|
||||
if ($requireBalance && $deltaSign < 0 && $before < $amountMinor) {
|
||||
$available = $before - (int) $wallet->frozen_balance;
|
||||
if ($requireBalance && $deltaSign < 0 && $available < $amountMinor) {
|
||||
throw new WalletOperationException(
|
||||
'insufficient_balance',
|
||||
ErrorCode::WalletInsufficientBalance->value,
|
||||
|
||||
@@ -421,6 +421,8 @@ final class AdminAuthorizationRegistry
|
||||
['code' => 'admin.jackpot.payout-logs.index', 'module_code' => 'jackpot', 'name' => '奖池派彩日志', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/jackpot/payout-logs', 'route_name' => 'api.v1.admin.jackpot.payout-logs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.jackpot.manage', 'prd.jackpot.view']],
|
||||
['code' => 'admin.jackpot.contributions.index', 'module_code' => 'jackpot', 'name' => '奖池注入记录', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/jackpot/contributions', 'route_name' => 'api.v1.admin.jackpot.contributions.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.jackpot.manage', 'prd.jackpot.view']],
|
||||
['code' => 'admin.jackpot.pools.update', 'module_code' => 'jackpot', 'name' => '更新奖池', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/jackpot/pools/{pool}', 'route_name' => 'api.v1.admin.jackpot.pools.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.jackpot.manage']],
|
||||
['code' => 'admin.jackpot.pools.adjustments.index', 'module_code' => 'jackpot', 'name' => '奖池调整流水', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/jackpot/pools/{pool}/adjustments', 'route_name' => 'api.v1.admin.jackpot.pools.adjustments.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.jackpot.manage', 'prd.jackpot.view']],
|
||||
['code' => 'admin.jackpot.pools.adjustments.store', 'module_code' => 'jackpot', 'name' => '奖池余额调整', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/jackpot/pools/{pool}/adjustments', 'route_name' => 'api.v1.admin.jackpot.pools.adjustments.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.jackpot.manage']],
|
||||
['code' => 'admin.jackpot.pools.manual-burst', 'module_code' => 'jackpot', 'name' => '手动爆池', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/jackpot/pools/{pool}/manual-burst', 'route_name' => 'api.v1.admin.jackpot.pools.manual-burst', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.jackpot.manual_burst']],
|
||||
|
||||
['code' => 'admin.players.index', 'module_code' => 'player_service', 'name' => '玩家列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players', 'route_name' => 'api.v1.admin.players.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.players.manage', 'service.players.view']],
|
||||
@@ -438,6 +440,7 @@ final class AdminAuthorizationRegistry
|
||||
['code' => 'admin.wallet.transactions', 'module_code' => 'wallet', 'name' => '钱包流水查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/wallet/transactions', 'route_name' => 'api.v1.admin.wallet.transactions', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']],
|
||||
['code' => 'admin.wallet.transfer-orders.reverse', 'module_code' => 'wallet', 'name' => '冲正转账单', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/wallet/transfer-orders/{transfer_no}/reverse', 'route_name' => 'api.v1.admin.wallet.transfer-orders.reverse', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_adjust.manage', 'prd.wallet_reconcile.manage']],
|
||||
['code' => 'admin.wallet.transfer-orders.manually-process', 'module_code' => 'wallet', 'name' => '手工处理转账单', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/wallet/transfer-orders/{transfer_no}/manually-process', 'route_name' => 'api.v1.admin.wallet.transfer-orders.manually-process', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_adjust.manage', 'prd.wallet_reconcile.manage']],
|
||||
['code' => 'admin.wallet.transfer-orders.complete-credit', 'module_code' => 'wallet', 'name' => '补完成转入入账', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/wallet/transfer-orders/{transfer_no}/complete-credit', 'route_name' => 'api.v1.admin.wallet.transfer-orders.complete-credit', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_adjust.manage', 'prd.wallet_reconcile.manage']],
|
||||
['code' => 'admin.reconcile-jobs.index', 'module_code' => 'reconcile', 'name' => '对账任务列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reconcile-jobs', 'route_name' => 'api.v1.admin.reconcile-jobs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']],
|
||||
['code' => 'admin.reconcile-jobs.show', 'module_code' => 'reconcile', 'name' => '对账任务详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reconcile-jobs/{reconcile_job}', 'route_name' => 'api.v1.admin.reconcile-jobs.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']],
|
||||
['code' => 'admin.reconcile-jobs.items.index', 'module_code' => 'reconcile', 'name' => '对账任务明细', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reconcile-jobs/{reconcile_job}/items', 'route_name' => 'api.v1.admin.reconcile-jobs.items.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']],
|
||||
|
||||
Reference in New Issue
Block a user