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']],
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$duplicateIds = DB::table('jackpot_contributions')
|
||||
->select('ticket_item_id')
|
||||
->whereNotNull('ticket_item_id')
|
||||
->groupBy('ticket_item_id')
|
||||
->havingRaw('count(*) > 1')
|
||||
->pluck('ticket_item_id');
|
||||
|
||||
foreach ($duplicateIds as $ticketItemId) {
|
||||
$rows = DB::table('jackpot_contributions')
|
||||
->where('ticket_item_id', $ticketItemId)
|
||||
->orderByDesc('id')
|
||||
->pluck('id');
|
||||
$keep = $rows->shift();
|
||||
if ($keep !== null && $rows->isNotEmpty()) {
|
||||
DB::table('jackpot_contributions')->whereIn('id', $rows->all())->delete();
|
||||
}
|
||||
}
|
||||
|
||||
Schema::table('jackpot_contributions', function (Blueprint $table): void {
|
||||
$table->unique('ticket_item_id', 'uk_jackpot_contributions_ticket_item');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('jackpot_contributions', function (Blueprint $table): void {
|
||||
$table->dropUnique('uk_jackpot_contributions_ticket_item');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('jackpot_pool_adjustments', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('adjustment_no', 32)->unique();
|
||||
$table->foreignId('jackpot_pool_id')->constrained('jackpot_pools')->cascadeOnDelete();
|
||||
$table->foreignId('admin_user_id')->constrained('admin_users')->cascadeOnDelete();
|
||||
$table->bigInteger('amount_delta')->comment('signed minor units; + increase pool');
|
||||
$table->bigInteger('balance_before');
|
||||
$table->bigInteger('balance_after');
|
||||
$table->string('reason', 500);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['jackpot_pool_id', 'created_at'], 'idx_jackpot_pool_adjustments_pool_created');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('jackpot_pool_adjustments');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Support\AdminAuthorizationRegistry;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/** @var list<string> */
|
||||
private const RESOURCE_CODES = [
|
||||
'admin.jackpot.pools.adjustments.index',
|
||||
'admin.jackpot.pools.adjustments.store',
|
||||
];
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$now = Carbon::now();
|
||||
$menuActionIds = DB::table('admin_menu_actions')->pluck('id', 'permission_code');
|
||||
$resources = collect(AdminAuthorizationRegistry::resources())
|
||||
->filter(fn (array $item): bool => in_array($item['code'], self::RESOURCE_CODES, true))
|
||||
->values();
|
||||
|
||||
foreach ($resources as $resource) {
|
||||
$resourceId = DB::table('admin_api_resources')
|
||||
->where('code', $resource['code'])
|
||||
->value('id');
|
||||
|
||||
$payload = [
|
||||
'module_code' => $resource['module_code'],
|
||||
'name' => $resource['name'],
|
||||
'http_method' => $resource['http_method'],
|
||||
'uri_pattern' => $resource['uri_pattern'],
|
||||
'route_name' => $resource['route_name'],
|
||||
'auth_mode' => $resource['auth_mode'],
|
||||
'is_audit_required' => $resource['is_audit_required'],
|
||||
'status' => 1,
|
||||
'meta_json' => null,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($resourceId === null) {
|
||||
$resourceId = DB::table('admin_api_resources')->insertGetId($payload + [
|
||||
'code' => $resource['code'],
|
||||
'created_at' => $now,
|
||||
]);
|
||||
} else {
|
||||
DB::table('admin_api_resources')
|
||||
->where('id', (int) $resourceId)
|
||||
->update($payload);
|
||||
}
|
||||
|
||||
DB::table('admin_api_resource_bindings')
|
||||
->where('api_resource_id', (int) $resourceId)
|
||||
->delete();
|
||||
|
||||
foreach ($resource['permission_codes'] as $permissionCode) {
|
||||
$menuActionId = $menuActionIds[$permissionCode] ?? null;
|
||||
if ($menuActionId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('admin_api_resource_bindings')->insert([
|
||||
'api_resource_id' => (int) $resourceId,
|
||||
'menu_action_id' => (int) $menuActionId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
$roleResourceRows = DB::table('admin_role_menu_actions as rma')
|
||||
->join('admin_api_resource_bindings as arb', 'arb.menu_action_id', '=', 'rma.menu_action_id')
|
||||
->where('arb.api_resource_id', (int) $resourceId)
|
||||
->select('rma.role_id')
|
||||
->distinct()
|
||||
->get();
|
||||
|
||||
if (Schema::hasTable('admin_role_api_resources')) {
|
||||
foreach ($roleResourceRows as $row) {
|
||||
DB::table('admin_role_api_resources')->updateOrInsert([
|
||||
'role_id' => (int) $row->role_id,
|
||||
'api_resource_id' => (int) $resourceId,
|
||||
], []);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
foreach (self::RESOURCE_CODES as $code) {
|
||||
$resourceId = DB::table('admin_api_resources')->where('code', $code)->value('id');
|
||||
if ($resourceId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Schema::hasTable('admin_role_api_resources')) {
|
||||
DB::table('admin_role_api_resources')->where('api_resource_id', (int) $resourceId)->delete();
|
||||
}
|
||||
DB::table('admin_api_resource_bindings')->where('api_resource_id', (int) $resourceId)->delete();
|
||||
DB::table('admin_api_resources')->where('id', (int) $resourceId)->delete();
|
||||
}
|
||||
}
|
||||
};
|
||||
7
lang/en/jackpot.php
Normal file
7
lang/en/jackpot.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'adjustment_delta_zero' => 'Adjustment amount cannot be zero',
|
||||
'adjustment_reason_required' => 'Adjustment reason is required',
|
||||
'adjustment_negative_balance' => 'Pool balance cannot be negative after adjustment',
|
||||
];
|
||||
7
lang/ne/jackpot.php
Normal file
7
lang/ne/jackpot.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'adjustment_delta_zero' => 'समायोजन रकम शून्य हुन सक्दैन',
|
||||
'adjustment_reason_required' => 'समायोजन कारण अनिवार्य छ',
|
||||
'adjustment_negative_balance' => 'समायोजन पछि पूल ब्यालेन्स ऋणात्मक हुन सक्दैन',
|
||||
];
|
||||
7
lang/zh/jackpot.php
Normal file
7
lang/zh/jackpot.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'adjustment_delta_zero' => '调整金额不能为 0',
|
||||
'adjustment_reason_required' => '请填写调整原因',
|
||||
'adjustment_negative_balance' => '调整后奖池余额不能为负数',
|
||||
];
|
||||
@@ -3,9 +3,11 @@
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotPoolIndexController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotPoolUpdateController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotPoolAdjustController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotPoolManualBurstController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotPayoutLogIndexController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotContributionIndexController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotPoolAdjustmentIndexController;
|
||||
|
||||
/**
|
||||
* 管理员奖池管理路由。
|
||||
@@ -20,6 +22,8 @@ Route::middleware('admin.api-resource')
|
||||
->name('api.v1.admin.jackpot.payout-logs.index');
|
||||
Route::get('jackpot/contributions', AdminJackpotContributionIndexController::class)
|
||||
->name('api.v1.admin.jackpot.contributions.index');
|
||||
Route::get('jackpot/pools/{pool}/adjustments', AdminJackpotPoolAdjustmentIndexController::class)
|
||||
->name('api.v1.admin.jackpot.pools.adjustments.index');
|
||||
});
|
||||
|
||||
// 奖池修改(仅管理权限)
|
||||
@@ -27,6 +31,8 @@ Route::middleware('admin.api-resource')
|
||||
->group(function (): void {
|
||||
Route::put('jackpot/pools/{pool}', AdminJackpotPoolUpdateController::class)
|
||||
->name('api.v1.admin.jackpot.pools.update');
|
||||
Route::post('jackpot/pools/{pool}/adjustments', AdminJackpotPoolAdjustController::class)
|
||||
->name('api.v1.admin.jackpot.pools.adjustments.store');
|
||||
Route::post('jackpot/pools/{pool}/manual-burst', AdminJackpotPoolManualBurstController::class)
|
||||
->name('api.v1.admin.jackpot.pools.manual-burst');
|
||||
});
|
||||
|
||||
@@ -36,6 +36,8 @@ Route::middleware('admin.api-resource')
|
||||
->name('api.v1.admin.wallet.transfer-orders.reverse');
|
||||
Route::post('wallet/transfer-orders/{transfer_no}/manually-process', [TransferOrderReconcileController::class, 'manuallyProcess'])
|
||||
->name('api.v1.admin.wallet.transfer-orders.manually-process');
|
||||
Route::post('wallet/transfer-orders/{transfer_no}/complete-credit', [TransferOrderReconcileController::class, 'completeCredit'])
|
||||
->name('api.v1.admin.wallet.transfer-orders.complete-credit');
|
||||
});
|
||||
|
||||
// 对账任务创建(仅管理权限)
|
||||
|
||||
243
tests/Feature/AdminBusinessLogicGuardsTest.php
Normal file
243
tests/Feature/AdminBusinessLogicGuardsTest.php
Normal file
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\Player;
|
||||
use App\Models\AdminUser;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Models\TransferOrder;
|
||||
use App\Models\DrawResultBatch;
|
||||
use App\Models\DrawResultItem;
|
||||
use App\Models\SettlementBatch;
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use App\Lottery\SettlementBatchStatus;
|
||||
use App\Services\Draw\DrawPrizeLayout;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function guardsAdminToken(): string
|
||||
{
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'guards_admin_'.uniqid(),
|
||||
'name' => 'Guards Admin',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
|
||||
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
}
|
||||
|
||||
test('cannot publish result batch while settlement batch is pending review', function (): void {
|
||||
$token = guardsAdminToken();
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260526-guard-1',
|
||||
'business_date' => '2026-05-26',
|
||||
'sequence_no' => 1,
|
||||
'status' => DrawStatus::Settling->value,
|
||||
'start_time' => now()->subHour(),
|
||||
'close_time' => now()->subMinutes(30),
|
||||
'draw_time' => now()->subMinutes(20),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => 'manual',
|
||||
'current_result_version' => 1,
|
||||
'settle_version' => 1,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$published = DrawResultBatch::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'result_version' => 1,
|
||||
'source_type' => 'manual',
|
||||
'rng_seed_hash' => null,
|
||||
'raw_seed_encrypted' => null,
|
||||
'status' => DrawResultBatchStatus::Published->value,
|
||||
'created_by' => null,
|
||||
'confirmed_by' => null,
|
||||
'confirmed_at' => now(),
|
||||
]);
|
||||
|
||||
$pending = DrawResultBatch::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'result_version' => 2,
|
||||
'source_type' => 'manual',
|
||||
'rng_seed_hash' => null,
|
||||
'raw_seed_encrypted' => null,
|
||||
'status' => DrawResultBatchStatus::PendingReview->value,
|
||||
'created_by' => null,
|
||||
'confirmed_by' => null,
|
||||
'confirmed_at' => null,
|
||||
]);
|
||||
|
||||
SettlementBatch::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'result_batch_id' => $published->id,
|
||||
'settle_version' => 1,
|
||||
'status' => SettlementBatchStatus::PendingReview->value,
|
||||
'review_status' => 'pending',
|
||||
'started_at' => now(),
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/result-batches/{$pending->id}/publish")
|
||||
->assertStatus(409);
|
||||
});
|
||||
|
||||
test('cannot create second pending result batch for same draw', function (): void {
|
||||
$token = guardsAdminToken();
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260526-guard-2',
|
||||
'business_date' => '2026-05-26',
|
||||
'sequence_no' => 2,
|
||||
'status' => DrawStatus::Review->value,
|
||||
'start_time' => now()->subHour(),
|
||||
'close_time' => now()->subMinutes(30),
|
||||
'draw_time' => now()->subMinutes(20),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => 'manual',
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$items = [];
|
||||
foreach (DrawPrizeLayout::slots() as $i => $slot) {
|
||||
$items[] = [
|
||||
'prize_type' => $slot['prize_type'],
|
||||
'prize_index' => $slot['prize_index'],
|
||||
'number_4d' => str_pad((string) ($i + 1), 4, '0', STR_PAD_LEFT),
|
||||
];
|
||||
}
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/result-batches", ['items' => $items])
|
||||
->assertOk();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/result-batches", ['items' => $items])
|
||||
->assertStatus(409);
|
||||
});
|
||||
|
||||
test('admin cannot manually process transfer out pending reconcile order', function (): void {
|
||||
$token = guardsAdminToken();
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'main',
|
||||
'site_player_id' => 'out-manual-block',
|
||||
'username' => null,
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
TransferOrder::query()->create([
|
||||
'transfer_no' => 'TO_manual_block',
|
||||
'player_id' => $player->id,
|
||||
'direction' => 'out',
|
||||
'currency_code' => 'NPR',
|
||||
'amount' => 300,
|
||||
'idempotent_key' => 'out-manual-block-key',
|
||||
'status' => 'pending_reconcile',
|
||||
'external_request_payload' => null,
|
||||
'external_response_payload' => null,
|
||||
'external_ref_no' => null,
|
||||
'fail_reason' => 'main_site_timeout',
|
||||
'finished_at' => null,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/wallet/transfer-orders/TO_manual_block/manually-process')
|
||||
->assertStatus(422);
|
||||
|
||||
expect(TransferOrder::query()->where('transfer_no', 'TO_manual_block')->value('status'))
|
||||
->toBe('pending_reconcile');
|
||||
});
|
||||
|
||||
test('admin cannot complete credit for main site timeout transfer in order', function (): void {
|
||||
$token = guardsAdminToken();
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'main',
|
||||
'site_player_id' => 'in-complete-block',
|
||||
'username' => null,
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
PlayerWallet::query()->create([
|
||||
'player_id' => $player->id,
|
||||
'wallet_type' => 'lottery',
|
||||
'currency_code' => 'NPR',
|
||||
'balance' => 100,
|
||||
'frozen_balance' => 0,
|
||||
'status' => 0,
|
||||
'version' => 0,
|
||||
]);
|
||||
|
||||
TransferOrder::query()->create([
|
||||
'transfer_no' => 'TI_complete_block',
|
||||
'player_id' => $player->id,
|
||||
'direction' => 'in',
|
||||
'currency_code' => 'NPR',
|
||||
'amount' => 500,
|
||||
'idempotent_key' => 'in-complete-block-key',
|
||||
'status' => 'pending_reconcile',
|
||||
'external_request_payload' => null,
|
||||
'external_response_payload' => null,
|
||||
'external_ref_no' => null,
|
||||
'fail_reason' => 'main_site_timeout',
|
||||
'finished_at' => null,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/wallet/transfer-orders/TI_complete_block/complete-credit')
|
||||
->assertStatus(422);
|
||||
|
||||
expect((int) PlayerWallet::query()->where('player_id', $player->id)->value('balance'))->toBe(100);
|
||||
});
|
||||
|
||||
test('transfer order list hides manual process for out pending reconcile', function (): void {
|
||||
$token = guardsAdminToken();
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'main',
|
||||
'site_player_id' => 'list-flags',
|
||||
'username' => null,
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
TransferOrder::query()->create([
|
||||
'transfer_no' => 'TO_list_flags',
|
||||
'player_id' => $player->id,
|
||||
'direction' => 'out',
|
||||
'currency_code' => 'NPR',
|
||||
'amount' => 100,
|
||||
'idempotent_key' => 'list-flags-key',
|
||||
'status' => 'pending_reconcile',
|
||||
'external_request_payload' => null,
|
||||
'external_response_payload' => null,
|
||||
'external_ref_no' => null,
|
||||
'fail_reason' => 'main_site_timeout',
|
||||
'finished_at' => null,
|
||||
]);
|
||||
|
||||
$items = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/wallet/transfer-orders?player_id='.$player->id)
|
||||
->assertOk()
|
||||
->json('data.items');
|
||||
|
||||
$item = collect($items)->firstWhere('transfer_no', 'TO_list_flags');
|
||||
expect($item)->not->toBeNull();
|
||||
|
||||
expect($item['can_reverse'])->toBeTrue()
|
||||
->and($item['can_manually_process'])->toBeFalse()
|
||||
->and($item['can_complete_credit'])->toBeFalse();
|
||||
});
|
||||
110
tests/Feature/AdminJackpotPoolAdjustmentTest.php
Normal file
110
tests/Feature/AdminJackpotPoolAdjustmentTest.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\JackpotPool;
|
||||
use App\Models\JackpotPoolAdjustment;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->seed(CurrencySeeder::class);
|
||||
});
|
||||
|
||||
function jackpotAdjustAdminToken(): string
|
||||
{
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'jackpot_adjust_admin',
|
||||
'name' => 'Jackpot Adjust',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
|
||||
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
}
|
||||
|
||||
test('admin cannot set current_amount via pool update', function (): void {
|
||||
$pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
|
||||
$token = jackpotAdjustAdminToken();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/jackpot/pools/'.$pool->id, [
|
||||
'current_amount' => 9_999_999,
|
||||
])
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
test('admin can apply jackpot pool balance adjustment with ledger row', function (): void {
|
||||
$pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
|
||||
$pool->forceFill(['current_amount' => 1_000])->save();
|
||||
$token = jackpotAdjustAdminToken();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/jackpot/pools/'.$pool->id.'/adjustments', [
|
||||
'amount_delta' => 500,
|
||||
'reason' => 'manual top-up after reconciliation',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.pool.current_amount', 1_500)
|
||||
->assertJsonPath('data.adjustment.amount_delta', 500)
|
||||
->assertJsonPath('data.adjustment.balance_before', 1_000)
|
||||
->assertJsonPath('data.adjustment.balance_after', 1_500);
|
||||
|
||||
expect(JackpotPoolAdjustment::query()->where('jackpot_pool_id', $pool->id)->count())->toBe(1);
|
||||
|
||||
expect(
|
||||
AuditLog::query()
|
||||
->where('module_code', 'jackpot')
|
||||
->where('action_code', 'adjust_balance')
|
||||
->exists(),
|
||||
)->toBeTrue();
|
||||
});
|
||||
|
||||
test('admin jackpot adjustment rejects negative balance', function (): void {
|
||||
$pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
|
||||
$pool->forceFill(['current_amount' => 100])->save();
|
||||
$token = jackpotAdjustAdminToken();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/jackpot/pools/'.$pool->id.'/adjustments', [
|
||||
'amount_delta' => -200,
|
||||
'reason' => 'correction',
|
||||
])
|
||||
->assertStatus(422);
|
||||
|
||||
expect((int) $pool->fresh()->current_amount)->toBe(100);
|
||||
});
|
||||
|
||||
test('admin can list jackpot pool adjustments', function (): void {
|
||||
$pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'adj_list_admin',
|
||||
'name' => 'List',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
JackpotPoolAdjustment::query()->create([
|
||||
'adjustment_no' => 'JA_TEST_1',
|
||||
'jackpot_pool_id' => $pool->id,
|
||||
'admin_user_id' => $admin->id,
|
||||
'amount_delta' => 50,
|
||||
'balance_before' => 0,
|
||||
'balance_after' => 50,
|
||||
'reason' => 'seed',
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/jackpot/pools/'.$pool->id.'/adjustments')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.total', 1)
|
||||
->assertJsonPath('data.items.0.adjustment_no', 'JA_TEST_1');
|
||||
});
|
||||
@@ -159,6 +159,7 @@ test('admin transfer order list exposes available reconcile actions by status',
|
||||
->and($byNo['TI_failed']['can_manually_process'])->toBeTrue()
|
||||
->and($byNo['TI_wait']['can_reverse'])->toBeTrue()
|
||||
->and($byNo['TI_wait']['can_manually_process'])->toBeTrue()
|
||||
->and($byNo['TI_wait']['can_complete_credit'])->toBeFalse()
|
||||
->and($byNo['TI_done']['can_reverse'])->toBeFalse()
|
||||
->and($byNo['TI_done']['can_manually_process'])->toBeFalse();
|
||||
});
|
||||
@@ -326,6 +327,56 @@ test('admin transfer reverse is idempotent under concurrent reconcile', function
|
||||
->and(WalletTxn::query()->where('biz_type', 'reversal')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('admin can complete stuck transfer in credit for pending reconcile order', function (): void {
|
||||
$token = makeAdminToken();
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'main',
|
||||
'site_player_id' => 'complete-credit-player',
|
||||
'username' => null,
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$wallet = PlayerWallet::query()->create([
|
||||
'player_id' => $player->id,
|
||||
'wallet_type' => 'lottery',
|
||||
'currency_code' => 'NPR',
|
||||
'balance' => 500,
|
||||
'frozen_balance' => 0,
|
||||
'status' => 0,
|
||||
'version' => 0,
|
||||
]);
|
||||
|
||||
TransferOrder::query()->create([
|
||||
'transfer_no' => 'TI_complete_credit',
|
||||
'player_id' => $player->id,
|
||||
'direction' => 'in',
|
||||
'currency_code' => 'NPR',
|
||||
'amount' => 2_000,
|
||||
'idempotent_key' => 'complete-credit-key',
|
||||
'status' => 'pending_reconcile',
|
||||
'external_request_payload' => ['ok' => true],
|
||||
'external_response_payload' => ['ok' => true],
|
||||
'external_ref_no' => 'main-ref-1',
|
||||
'fail_reason' => 'lottery_credit_failed',
|
||||
'finished_at' => null,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/wallet/transfer-orders/TI_complete_credit/complete-credit', [
|
||||
'remark' => 'manual complete',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', 'success');
|
||||
|
||||
$wallet->refresh();
|
||||
expect((int) $wallet->balance)->toBe(2_500)
|
||||
->and(TransferOrder::query()->where('transfer_no', 'TI_complete_credit')->value('status'))->toBe('success')
|
||||
->and(WalletTxn::query()->where('biz_type', 'transfer_in')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('admin shows player wallets', function (): void {
|
||||
$token = makeAdminToken();
|
||||
|
||||
|
||||
@@ -2,8 +2,14 @@
|
||||
|
||||
use Carbon\Carbon;
|
||||
use App\Models\Draw;
|
||||
use App\Models\Player;
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\TicketItem;
|
||||
use App\Models\TicketOrder;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Models\WalletTxn;
|
||||
use App\Models\TicketCombination;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\DrawResultItem;
|
||||
use App\Models\DrawResultBatch;
|
||||
@@ -286,6 +292,132 @@ test('admin can cancel draw before results exist', function (): void {
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('admin cancel draw refunds open bets and releases risk', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 12:16:00', 'UTC'));
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260509-121b',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 121,
|
||||
'status' => DrawStatus::Open->value,
|
||||
'start_time' => now()->copy()->subMinute(),
|
||||
'close_time' => now()->copy()->addMinutes(10),
|
||||
'draw_time' => now()->copy()->addMinutes(15),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'test',
|
||||
'site_player_id' => 'cancel-refund-player',
|
||||
'username' => 'cancel_refund',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$wallet = PlayerWallet::query()->create([
|
||||
'player_id' => $player->id,
|
||||
'wallet_type' => 'lottery',
|
||||
'currency_code' => 'NPR',
|
||||
'balance' => 49_900,
|
||||
'frozen_balance' => 0,
|
||||
'status' => 0,
|
||||
'version' => 0,
|
||||
]);
|
||||
|
||||
$order = TicketOrder::query()->create([
|
||||
'order_no' => 'TO-CANCEL-REFUND',
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $draw->id,
|
||||
'currency_code' => 'NPR',
|
||||
'total_bet_amount' => 100,
|
||||
'total_rebate_amount' => 0,
|
||||
'total_actual_deduct' => 100,
|
||||
'total_estimated_payout' => 3000,
|
||||
'status' => 'placed',
|
||||
'submit_source' => 'h5',
|
||||
'client_trace_id' => 'cancel-refund-trace',
|
||||
]);
|
||||
|
||||
$item = TicketItem::query()->create([
|
||||
'ticket_no' => 'TK-CANCEL-REFUND',
|
||||
'order_id' => $order->id,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $draw->id,
|
||||
'original_number' => '1234',
|
||||
'normalized_number' => '1234',
|
||||
'play_code' => 'big',
|
||||
'dimension' => 4,
|
||||
'digit_slot' => null,
|
||||
'bet_mode' => 'straight',
|
||||
'unit_bet_amount' => 100,
|
||||
'total_bet_amount' => 100,
|
||||
'rebate_rate_snapshot' => 0,
|
||||
'commission_rate_snapshot' => 0,
|
||||
'actual_deduct_amount' => 100,
|
||||
'odds_snapshot_json' => [],
|
||||
'rule_snapshot_json' => [],
|
||||
'combination_count' => 1,
|
||||
'estimated_max_payout' => 3000,
|
||||
'risk_locked_amount' => 3000,
|
||||
'status' => 'pending_draw',
|
||||
'fail_reason_code' => null,
|
||||
'fail_reason_text' => null,
|
||||
'win_amount' => 0,
|
||||
'jackpot_win_amount' => 0,
|
||||
'settled_at' => null,
|
||||
]);
|
||||
|
||||
TicketCombination::query()->create([
|
||||
'ticket_item_id' => $item->id,
|
||||
'combination_no' => 1,
|
||||
'number_4d' => '1234',
|
||||
'bet_amount' => 100,
|
||||
'estimated_payout' => 3000,
|
||||
]);
|
||||
|
||||
WalletTxn::query()->create([
|
||||
'txn_no' => 'WT-CANCEL-REFUND',
|
||||
'player_id' => $player->id,
|
||||
'wallet_id' => $wallet->id,
|
||||
'biz_type' => 'bet_deduct',
|
||||
'biz_no' => $order->order_no,
|
||||
'direction' => 2,
|
||||
'amount' => 100,
|
||||
'balance_before' => 50_000,
|
||||
'balance_after' => 49_900,
|
||||
'status' => 'posted',
|
||||
'external_ref_no' => null,
|
||||
'idempotent_key' => 'bet:'.$order->order_no,
|
||||
'remark' => null,
|
||||
]);
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'draw_cancel_refund_admin',
|
||||
'name' => 'Draw Cancel Refund Admin',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/cancel")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', DrawStatus::Cancelled->value);
|
||||
|
||||
expect($order->fresh()->status)->toBe('refunded')
|
||||
->and($item->fresh()->status)->toBe('refunded')
|
||||
->and((int) PlayerWallet::query()->where('player_id', $player->id)->value('balance'))->toBe(50_000);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('admin can manually trigger rng for closed draw', function (): void {
|
||||
config(['lottery.draw.require_manual_review' => true]);
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 12:20:00', 'UTC'));
|
||||
|
||||
@@ -333,6 +333,93 @@ test('ticket place is idempotent by player draw and client trace id', function (
|
||||
expect((int) $wallet->balance)->toBe(200_000 - 12_400);
|
||||
});
|
||||
|
||||
test('ticket place idempotency is scoped per draw not global trace', function (): void {
|
||||
$player = ticketPlayerWithWallet();
|
||||
ticketOpenDraw('20260511-001');
|
||||
|
||||
Draw::query()->create([
|
||||
'draw_no' => '20260511-002',
|
||||
'business_date' => '2026-05-11',
|
||||
'sequence_no' => 2,
|
||||
'status' => DrawStatus::Open->value,
|
||||
'start_time' => now()->subMinutes(2),
|
||||
'close_time' => now()->addMinutes(5),
|
||||
'draw_time' => now()->addMinutes(6),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$trace = 'shared-trace-two-draws';
|
||||
$payloadA = array_merge(ticketPreviewPayload('20260511-001'), ['client_trace_id' => $trace]);
|
||||
$payloadB = array_merge(ticketPreviewPayload('20260511-002'), ['client_trace_id' => $trace]);
|
||||
|
||||
$first = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', $payloadA)
|
||||
->assertOk()
|
||||
->json('data.order_no');
|
||||
|
||||
$second = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', $payloadB)
|
||||
->assertOk()
|
||||
->json('data.order_no');
|
||||
|
||||
expect($second)->not->toBe($first)
|
||||
->and(TicketOrder::query()->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('ticket place accepts draw pending in db when hall rules show open', function (): void {
|
||||
$player = ticketPlayerWithWallet();
|
||||
Draw::query()->create([
|
||||
'draw_no' => '20260511-pending-hall',
|
||||
'business_date' => '2026-05-11',
|
||||
'sequence_no' => 99,
|
||||
'status' => DrawStatus::Pending->value,
|
||||
'start_time' => now()->subMinute(),
|
||||
'close_time' => now()->addMinutes(5),
|
||||
'draw_time' => now()->addMinutes(6),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', [
|
||||
'draw_id' => '20260511-pending-hall',
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'pending-hall-open-place',
|
||||
'lines' => [
|
||||
['number' => '1234', 'play_code' => 'big', 'amount' => 10_000],
|
||||
],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('code', ErrorCode::Success->value);
|
||||
|
||||
expect(TicketOrder::query()->where('client_trace_id', 'pending-hall-open-place')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('ticket place rejects bet when only frozen balance would cover stake', function (): void {
|
||||
$player = ticketPlayerWithWallet(20_000);
|
||||
ticketOpenDraw();
|
||||
|
||||
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
|
||||
$wallet->forceFill(['balance' => 20_000, 'frozen_balance' => 19_000])->save();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', array_merge(ticketPreviewPayload(), [
|
||||
'client_trace_id' => 'frozen-balance-guard',
|
||||
'lines' => [
|
||||
['number' => '1234', 'play_code' => 'big', 'amount' => 10_000],
|
||||
],
|
||||
]))
|
||||
->assertStatus(400)
|
||||
->assertJsonPath('code', ErrorCode::BetInsufficientBalance->value);
|
||||
});
|
||||
|
||||
test('box family estimated max payout is the sum of every expanded combination payout', function (): void {
|
||||
$player = ticketPlayerWithWallet(500_000);
|
||||
ticketOpenDraw();
|
||||
@@ -1043,3 +1130,143 @@ test('ticket place reverses wallet and releases risk when post deduction confirm
|
||||
->and(WalletTxn::query()->where('biz_no', $order->order_no)->where('biz_type', 'bet_reverse')->count())->toBe(1)
|
||||
->and((int) RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->value('locked_amount'))->toBe(0);
|
||||
});
|
||||
|
||||
test('ticket place idempotency replays refunded order for same trace', function (): void {
|
||||
$player = ticketPlayerWithWallet();
|
||||
$draw = ticketOpenDraw();
|
||||
$trace = 'trace-refunded-replay';
|
||||
|
||||
$order = TicketOrder::query()->create([
|
||||
'order_no' => 'TO-REFUNDED-IDEM',
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $draw->id,
|
||||
'currency_code' => 'NPR',
|
||||
'total_bet_amount' => 100,
|
||||
'total_rebate_amount' => 0,
|
||||
'total_actual_deduct' => 100,
|
||||
'total_estimated_payout' => 3000,
|
||||
'status' => 'refunded',
|
||||
'submit_source' => 'h5',
|
||||
'client_trace_id' => $trace,
|
||||
]);
|
||||
|
||||
$payload = array_merge(ticketPreviewPayload(), ['client_trace_id' => $trace]);
|
||||
|
||||
$replay = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', $payload)
|
||||
->assertOk()
|
||||
->assertJsonPath('code', ErrorCode::Success->value)
|
||||
->json('data');
|
||||
|
||||
expect($replay['order_no'])->toBe($order->order_no)
|
||||
->and(TicketOrder::query()->count())->toBe(1)
|
||||
->and(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('ticket pending confirmation reconcile refunds when draw no longer accepts bets', function (): void {
|
||||
$draw = ticketOpenDraw();
|
||||
$draw->forceFill([
|
||||
'status' => DrawStatus::Closed->value,
|
||||
'close_time' => now()->subMinute(),
|
||||
'draw_time' => now()->subMinute(),
|
||||
])->save();
|
||||
|
||||
$player = ticketPlayerWithWallet(10_000);
|
||||
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
|
||||
|
||||
$order = TicketOrder::query()->create([
|
||||
'order_no' => 'TO-PENDING-CLOSED',
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $draw->id,
|
||||
'currency_code' => 'NPR',
|
||||
'total_bet_amount' => 100,
|
||||
'total_rebate_amount' => 0,
|
||||
'total_actual_deduct' => 100,
|
||||
'total_estimated_payout' => 3000,
|
||||
'status' => 'pending_confirm',
|
||||
'submit_source' => 'h5',
|
||||
'client_trace_id' => 'pending-on-closed-draw',
|
||||
'created_at' => now()->subMinutes(20),
|
||||
'updated_at' => now()->subMinutes(20),
|
||||
]);
|
||||
TicketOrder::query()->whereKey($order->id)->update(['updated_at' => now()->subMinutes(20)]);
|
||||
|
||||
$item = TicketItem::query()->create([
|
||||
'ticket_no' => 'TK-PENDING-CLOSED',
|
||||
'order_id' => $order->id,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $draw->id,
|
||||
'original_number' => '1234',
|
||||
'normalized_number' => '1234',
|
||||
'play_code' => 'big',
|
||||
'dimension' => 4,
|
||||
'digit_slot' => null,
|
||||
'bet_mode' => 'straight',
|
||||
'unit_bet_amount' => 100,
|
||||
'total_bet_amount' => 100,
|
||||
'rebate_rate_snapshot' => 0,
|
||||
'commission_rate_snapshot' => 0,
|
||||
'actual_deduct_amount' => 100,
|
||||
'odds_snapshot_json' => [],
|
||||
'rule_snapshot_json' => [],
|
||||
'combination_count' => 1,
|
||||
'estimated_max_payout' => 3000,
|
||||
'risk_locked_amount' => 3000,
|
||||
'status' => 'pending_confirm',
|
||||
'fail_reason_code' => null,
|
||||
'fail_reason_text' => null,
|
||||
'win_amount' => 0,
|
||||
'jackpot_win_amount' => 0,
|
||||
'settled_at' => null,
|
||||
'created_at' => now()->subMinutes(20),
|
||||
'updated_at' => now()->subMinutes(20),
|
||||
]);
|
||||
|
||||
TicketCombination::query()->create([
|
||||
'ticket_item_id' => $item->id,
|
||||
'combination_no' => 1,
|
||||
'number_4d' => '1234',
|
||||
'bet_amount' => 100,
|
||||
'estimated_payout' => 3000,
|
||||
'created_at' => now()->subMinutes(20),
|
||||
]);
|
||||
|
||||
RiskPool::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'normalized_number' => '1234',
|
||||
'total_cap_amount' => 5000,
|
||||
'locked_amount' => 3000,
|
||||
'remaining_amount' => 2000,
|
||||
'sold_out_status' => 0,
|
||||
'version' => 1,
|
||||
]);
|
||||
|
||||
$wallet->forceFill(['balance' => 9_900])->save();
|
||||
|
||||
WalletTxn::query()->create([
|
||||
'txn_no' => 'WL-PENDING-CLOSED',
|
||||
'player_id' => $player->id,
|
||||
'wallet_id' => $wallet->id,
|
||||
'biz_type' => 'bet_deduct',
|
||||
'biz_no' => 'TO-PENDING-CLOSED',
|
||||
'direction' => 2,
|
||||
'amount' => 100,
|
||||
'balance_before' => 10_000,
|
||||
'balance_after' => 9_900,
|
||||
'status' => 'posted',
|
||||
'external_ref_no' => null,
|
||||
'idempotent_key' => 'bet_deduct:TO-PENDING-CLOSED',
|
||||
'remark' => null,
|
||||
]);
|
||||
|
||||
$this->artisan('lottery:ticket-pending-confirm-reconcile --stale-minutes=15 --limit=100')
|
||||
->expectsOutputToContain('refunded: 1')
|
||||
->assertExitCode(0);
|
||||
|
||||
expect($order->fresh()->status)->toBe('refunded')
|
||||
->and($item->fresh()->status)->toBe('refunded')
|
||||
->and($item->fresh()->fail_reason_code)->toBe('draw_no_longer_open')
|
||||
->and((int) PlayerWallet::query()->where('player_id', $player->id)->value('balance'))->toBe(10_000)
|
||||
->and(WalletTxn::query()->where('biz_type', 'bet_reverse')->where('biz_no', 'TO-PENDING-CLOSED')->count())->toBe(1)
|
||||
->and((int) RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->value('locked_amount'))->toBe(0);
|
||||
});
|
||||
|
||||
@@ -122,6 +122,48 @@ test('transfer out debits lottery and matches stub credit', function () {
|
||||
expect((int) PlayerWallet::query()->where('player_id', $player->id)->first()?->balance)->toBe(600);
|
||||
});
|
||||
|
||||
test('transfer out respects frozen balance when checking available funds', function () {
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'main',
|
||||
'site_player_id' => 'u-frozen',
|
||||
'username' => null,
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
PlayerWallet::query()->create([
|
||||
'player_id' => $player->id,
|
||||
'wallet_type' => 'lottery',
|
||||
'currency_code' => 'NPR',
|
||||
'balance' => 10_000,
|
||||
'frozen_balance' => 9_500,
|
||||
'status' => 0,
|
||||
'version' => 0,
|
||||
]);
|
||||
|
||||
$code = ErrorCode::WalletInsufficientBalance->value;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/wallet/transfer-out', [
|
||||
'amount' => 1_000,
|
||||
'idempotent_key' => 'idem-out-frozen-too-much',
|
||||
])
|
||||
->assertStatus(400)
|
||||
->assertJsonPath('code', $code);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/wallet/transfer-out', [
|
||||
'amount' => 400,
|
||||
'idempotent_key' => 'idem-out-frozen-ok',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.lottery_balance_after', 9_600);
|
||||
|
||||
expect((int) PlayerWallet::query()->where('player_id', $player->id)->value('balance'))->toBe(9_600)
|
||||
->and((int) PlayerWallet::query()->where('player_id', $player->id)->value('frozen_balance'))->toBe(9_500);
|
||||
});
|
||||
|
||||
test('transfer out insufficient balance fails with 1001', function () {
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'main',
|
||||
|
||||
Reference in New Issue
Block a user