feat: 增强奖池与钱包管理功能

更新 AdminJackpotPoolUpdateController 校验规则,禁止传入 current_amount。
优化 AdminRiskPoolManualStatusController:更新奖池状态后同步 Redis 状态。
在 TransferOrderReconcileController 中新增 completeCredit 方法,用于处理卡住的转账订单对账。
调整 TransferOrderListController:优化转账订单处理条件。
在 TicketItemsIndexController 中实现支持时区的日期筛选,提升日期处理准确性。
扩展 JackpotPool 模型,新增 adjustments 关联关系。
改进票据与钱包相关服务中的错误处理和事务管理。
This commit is contained in:
2026-05-26 14:58:41 +08:00
parent 48349e3302
commit c8c90e3e94
45 changed files with 1877 additions and 104 deletions

View File

@@ -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(),
],
]);
}
}

View File

@@ -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(),
]);
}
}

View File

@@ -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',

View File

@@ -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(),
];
}
}

View File

@@ -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;
});
}

View File

@@ -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,

View File

@@ -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']);
}
}

View File

@@ -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();

View File

@@ -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) {

View File

@@ -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();
}
}

View File

@@ -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,
};

View File

@@ -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'],
];
}
}

View File

@@ -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'],
];
}
}

View File

@@ -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');
}
}

View 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');
}
}

View File

@@ -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();

View 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();
}
}

View File

@@ -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, [

View File

@@ -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,

View File

@@ -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');
}
}
}

View File

@@ -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,

View 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));
}
}

View File

@@ -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');
}

View File

@@ -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,

View File

@@ -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'

View File

@@ -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';
}
}

View File

@@ -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,

View File

@@ -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' => [

View File

@@ -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,
]);
}

View File

@@ -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,

View File

@@ -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']],