diff --git a/app/Console/Commands/LotteryPerfDrawScheduleAuditCommand.php b/app/Console/Commands/LotteryPerfDrawScheduleAuditCommand.php new file mode 100644 index 0000000..801fe0b --- /dev/null +++ b/app/Console/Commands/LotteryPerfDrawScheduleAuditCommand.php @@ -0,0 +1,96 @@ +option('samples')); + $tolerance = max(0, (int) $this->option('tolerance-seconds')); + $intervalMinutes = (int) config('lottery.draw.interval_minutes', 5); + $expectedSeconds = $intervalMinutes * 60; + + $planner->ensureBuffer(Carbon::now('UTC')); + + $nowUtc = Carbon::now('UTC'); + $horizon = $nowUtc->copy()->addDays(14); + + $businessDate = Draw::query() + ->whereNotNull('draw_time') + ->where('draw_time', '>', $nowUtc) + ->where('draw_time', '<=', $horizon) + ->where('business_date', '<', '2090-01-01') + ->orderByDesc('business_date') + ->value('business_date'); + + if ($businessDate === null) { + $this->error('No upcoming draws found.'); + + return self::FAILURE; + } + + $times = Draw::query() + ->where('business_date', $businessDate) + ->whereNotNull('draw_time') + ->orderBy('sequence_no') + ->limit($samples + 1) + ->pluck('draw_time') + ->map(fn ($t) => Carbon::parse($t)->utc()) + ->values() + ->all(); + + $this->line('business_date='.$businessDate); + + if (count($times) < 2) { + $this->error('Not enough draws to audit.'); + + return self::FAILURE; + } + + $violations = []; + for ($i = 1; $i < count($times); $i++) { + $delta = $times[$i]->diffInSeconds($times[$i - 1], absolute: true); + if (abs($delta - $expectedSeconds) > $tolerance) { + $violations[] = [ + 'pair' => ($i - 1).'→'.$i, + 'delta_seconds' => $delta, + 'expected_seconds' => $expectedSeconds, + ]; + } + } + + $this->info(sprintf( + 'interval_minutes=%d expected_gap=%ds tolerance=±%ds pairs_checked=%d', + $intervalMinutes, + $expectedSeconds, + $tolerance, + count($times) - 1, + )); + + if ($violations === []) { + $this->info('PASS — all checked gaps within tolerance.'); + + return self::SUCCESS; + } + + $this->error('FAIL — spacing violations:'); + $this->table(['pair', 'delta_seconds', 'expected_seconds'], $violations); + + return self::FAILURE; + } +} diff --git a/app/Console/Commands/LotteryPerfSettlementBenchmarkCommand.php b/app/Console/Commands/LotteryPerfSettlementBenchmarkCommand.php new file mode 100644 index 0000000..a34a4e5 --- /dev/null +++ b/app/Console/Commands/LotteryPerfSettlementBenchmarkCommand.php @@ -0,0 +1,243 @@ +byteMemoryLimit(ini_get('memory_limit')) < 512 * 1024 * 1024) { + ini_set('memory_limit', '512M'); + } + + return $this->runBenchmark($orchestrator); + } + + private function runBenchmark(SettlementOrchestrator $orchestrator): int + { + $itemCount = max(1, (int) $this->option('items')); + $maxSeconds = max(1, (int) $this->option('max-seconds')); + $maxMs = $maxSeconds * 1000; + + $this->info(sprintf('Seeding %d ticket items…', $itemCount)); + + $fixture = $this->seedFixture($itemCount); + $draw = $fixture['draw']; + + $this->info('Running trySettleDraw…'); + $started = hrtime(true); + $ran = $orchestrator->trySettleDraw($draw->fresh()); + $elapsedMs = (int) ((hrtime(true) - $started) / 1_000_000); + + if (! $ran) { + $this->error('Settlement did not run (check draw status / published result batch).'); + + return self::FAILURE; + } + + $settled = TicketItem::query() + ->where('draw_id', $draw->id) + ->whereIn('status', ['pending_payout', 'settled_lose', 'settled_win']) + ->count(); + + $pass = $elapsedMs <= $maxMs && $settled === $itemCount; + + $this->table( + ['metric', 'value'], + [ + ['items', (string) $itemCount], + ['settled_rows', (string) $settled], + ['elapsed_ms', (string) $elapsedMs], + ['threshold_ms', (string) $maxMs], + ['result', $pass ? 'PASS' : 'FAIL'], + ], + ); + + return $pass ? self::SUCCESS : self::FAILURE; + } + + private function byteMemoryLimit(string $value): int + { + $value = trim($value); + if ($value === '' || $value === '-1') { + return PHP_INT_MAX; + } + + $unit = strtolower(substr($value, -1)); + $number = (int) $value; + + return match ($unit) { + 'g' => $number * 1024 * 1024 * 1024, + 'm' => $number * 1024 * 1024, + 'k' => $number * 1024, + default => $number, + }; + } + + /** + * @return array{draw: Draw} + */ + private function seedFixture(int $itemCount): array + { + return DB::transaction(function () use ($itemCount): array { + $uniq = bin2hex(random_bytes(4)); + $player = Player::query()->create([ + 'site_code' => 'perf', + 'site_player_id' => 'perf-settle-'.$uniq, + 'username' => 'perf_'.$uniq, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'NPR', + 'balance' => 0, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + + $draw = Draw::query()->create([ + 'draw_no' => '20990101-'.str_pad((string) random_int(1, 999), 3, '0', STR_PAD_LEFT), + 'business_date' => '2099-01-01', + 'sequence_no' => 1, + 'status' => DrawStatus::Settling->value, + 'start_time' => now()->subHour(), + 'close_time' => now()->subMinutes(30), + 'draw_time' => now()->subMinutes(20), + 'cooling_end_time' => now()->subMinutes(5), + 'result_source' => 'rng', + 'current_result_version' => 1, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + $batch = DrawResultBatch::query()->create([ + 'draw_id' => $draw->id, + 'result_version' => 1, + 'source_type' => 'rng', + 'rng_seed_hash' => 'perf-bench', + 'raw_seed_encrypted' => null, + 'status' => DrawResultBatchStatus::Published->value, + 'created_by' => null, + 'confirmed_by' => null, + 'confirmed_at' => now(), + ]); + + foreach (DrawPrizeLayout::slots() as $slot) { + DrawResultItem::query()->create([ + 'draw_id' => $draw->id, + 'result_batch_id' => $batch->id, + 'prize_type' => $slot['prize_type'], + 'prize_index' => $slot['prize_index'], + 'number_4d' => '0001', + 'suffix_3d' => '001', + 'suffix_2d' => '01', + 'head_digit' => 0, + 'tail_digit' => 1, + ]); + } + + $order = TicketOrder::query()->create([ + 'order_no' => 'TO-PERF-'.$uniq, + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'currency_code' => 'NPR', + 'total_bet_amount' => $itemCount * 10, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => $itemCount * 10, + 'total_estimated_payout' => 0, + 'status' => 'placed', + 'submit_source' => 'perf', + 'client_trace_id' => 'perf-settle-'.$uniq, + ]); + + $now = now(); + $oddsJson = json_encode(['1st' => 250000], JSON_THROW_ON_ERROR); + $ruleJson = json_encode([], JSON_THROW_ON_ERROR); + $chunk = 500; + + for ($offset = 0; $offset < $itemCount; $offset += $chunk) { + $size = min($chunk, $itemCount - $offset); + $itemRows = []; + $ticketNos = []; + + for ($i = 0; $i < $size; $i++) { + $seq = $offset + $i + 1; + $num = str_pad((string) ($seq % 10000), 4, '0', STR_PAD_LEFT); + $ticketNo = sprintf('TK-PERF-%s-%06d', $uniq, $seq); + $ticketNos[] = $ticketNo; + $itemRows[] = [ + 'ticket_no' => $ticketNo, + 'order_id' => $order->id, + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'original_number' => $num, + 'normalized_number' => $num, + 'play_code' => 'big', + 'dimension' => 4, + 'digit_slot' => null, + 'bet_mode' => 'straight', + 'unit_bet_amount' => 10, + 'total_bet_amount' => 10, + 'rebate_rate_snapshot' => 0, + 'commission_rate_snapshot' => 0, + 'actual_deduct_amount' => 10, + 'odds_snapshot_json' => $oddsJson, + 'rule_snapshot_json' => $ruleJson, + 'combination_count' => 1, + 'estimated_max_payout' => 250, + 'risk_locked_amount' => 0, + 'status' => 'pending_draw', + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + DB::table('ticket_items')->insert($itemRows); + + $comboRows = DB::table('ticket_items') + ->whereIn('ticket_no', $ticketNos) + ->get(['id', 'normalized_number']) + ->map(fn ($row): array => [ + 'ticket_item_id' => $row->id, + 'combination_no' => 0, + 'number_4d' => $row->normalized_number, + 'bet_amount' => 10, + 'estimated_payout' => 250, + 'created_at' => $now, + ]) + ->all(); + + DB::table('ticket_combinations')->insert($comboRows); + } + + return ['draw' => $draw]; + }); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Config/PlayConfigItemsReplaceController.php b/app/Http/Controllers/Api/V1/Admin/Config/PlayConfigItemsReplaceController.php index f0113b2..92853c9 100644 --- a/app/Http/Controllers/Api/V1/Admin/Config/PlayConfigItemsReplaceController.php +++ b/app/Http/Controllers/Api/V1/Admin/Config/PlayConfigItemsReplaceController.php @@ -40,9 +40,7 @@ final class PlayConfigItemsReplaceController extends Controller 'items.*.category' => ['required', 'string', 'max:16'], 'items.*.dimension' => ['nullable', 'integer', 'min:0', 'max:255'], 'items.*.bet_mode' => ['nullable', 'string', 'max:32'], - 'items.*.display_name_zh' => ['required', 'string', 'max:64'], - 'items.*.display_name_en' => ['nullable', 'string', 'max:64'], - 'items.*.display_name_ne' => ['nullable', 'string', 'max:64'], + 'items.*.display_name' => ['required', 'string', 'max:64'], 'items.*.is_enabled' => ['sometimes', 'boolean'], 'items.*.min_bet_amount' => ['required', 'integer', 'min:0'], 'items.*.max_bet_amount' => ['required', 'integer', 'min:0'], diff --git a/app/Http/Controllers/Api/V1/Admin/Dashboard/AdminDashboardAnalyticsController.php b/app/Http/Controllers/Api/V1/Admin/Dashboard/AdminDashboardAnalyticsController.php new file mode 100644 index 0000000..3b7ca32 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Dashboard/AdminDashboardAnalyticsController.php @@ -0,0 +1,47 @@ +lotteryAdmin(); + if (! $admin instanceof AdminUser) { + return ApiResponse::error( + trans('admin.unauthenticated', [], $request->lotteryLocale()), + ErrorCode::AdminUnauthenticated->value, + null, + 401, + ); + } + + $payload = $this->analytics->build($admin, $request->filters()); + if ($payload === null) { + return ApiResponse::error( + trans('admin.forbidden', [], $request->lotteryLocale()), + ErrorCode::AdminForbidden->value, + null, + 403, + ); + } + + return ApiResponse::success($payload); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolManualBurstController.php b/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolManualBurstController.php index 8231c0c..f86e247 100644 --- a/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolManualBurstController.php +++ b/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolManualBurstController.php @@ -2,62 +2,56 @@ namespace App\Http\Controllers\Api\V1\Admin\Jackpot; +use App\Models\AdminUser; use App\Models\JackpotPool; use App\Support\ApiResponse; +use App\Lottery\ErrorCode; use Illuminate\Http\Request; -use App\Models\JackpotPayoutLog; use Illuminate\Http\JsonResponse; -use Illuminate\Support\Facades\DB; use App\Http\Controllers\Controller; +use App\Services\Jackpot\JackpotManualBurstService; final class AdminJackpotPoolManualBurstController extends Controller { + public function __construct( + private readonly JackpotManualBurstService $service, + ) {} + public function __invoke(Request $request, JackpotPool $pool): JsonResponse { + $admin = $request->user(); + if (! $admin instanceof AdminUser) { + return ApiResponse::error( + trans('admin.unauthenticated', [], $request->lotteryLocale()), + ErrorCode::AdminUnauthenticated->value, + null, + 401, + ); + } + + if (! $admin->isSuperAdmin()) { + return ApiResponse::error( + trans('admin.permission_denied', [], $request->lotteryLocale()), + ErrorCode::AdminForbidden->value, + ['required_any' => [AdminUser::ROLE_SUPER_ADMIN]], + 403, + ); + } + $data = $request->validate([ 'draw_id' => 'required|integer|exists:draws,id', - 'amount' => 'nullable|integer|min:1', ]); - $payload = DB::transaction(function () use ($pool, $data): array { - /** @var JackpotPool $locked */ - $locked = JackpotPool::query()->whereKey($pool->id)->lockForUpdate()->firstOrFail(); - $poolBefore = (int) $locked->current_amount; - $amount = isset($data['amount']) ? min((int) $data['amount'], $poolBefore) : $poolBefore; - - if ($amount <= 0) { - return [ - 'current_amount' => $poolBefore, - 'burst_amount' => 0, - 'log_id' => null, - ]; - } - - $drawId = (int) $data['draw_id']; - - $locked->forceFill([ - 'current_amount' => $poolBefore - $amount, - 'last_trigger_draw_id' => $drawId, - ])->save(); - - $log = JackpotPayoutLog::query()->create([ - 'draw_id' => $drawId, - 'jackpot_pool_id' => $locked->id, - 'trigger_type' => 'manual', - 'total_payout_amount' => $amount, - 'winner_count' => 0, - 'trigger_snapshot_json' => [ - 'pool_amount_before' => $poolBefore, - 'manual' => true, - ], - ]); - - return [ - 'current_amount' => (int) $locked->current_amount, - 'burst_amount' => $amount, - 'log_id' => (int) $log->id, - ]; - }); + try { + $payload = $this->service->execute($pool, (int) $data['draw_id']); + } catch (\RuntimeException $e) { + return ApiResponse::error( + trans('api.jackpot_manual_burst_failed', ['reason' => $e->getMessage()], $request->lotteryLocale()), + ErrorCode::ClientHttpError->value, + ['reason' => $e->getMessage()], + 409, + ); + } return ApiResponse::success($payload); } diff --git a/app/Http/Controllers/Api/V1/Admin/PlayTypePatchController.php b/app/Http/Controllers/Api/V1/Admin/PlayTypePatchController.php index 559d12f..50881be 100644 --- a/app/Http/Controllers/Api/V1/Admin/PlayTypePatchController.php +++ b/app/Http/Controllers/Api/V1/Admin/PlayTypePatchController.php @@ -3,30 +3,46 @@ namespace App\Http\Controllers\Api\V1\Admin; use App\Models\PlayType; +use App\Models\AdminUser; use App\Support\ApiResponse; use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; use App\Support\AdminConfigPresenter; +use App\Services\Config\PlayConfigStreamService; -/** PATCH /api/v1/admin/play-types/{play_code} — 主目录层开关与展示名(不等同于版本化 items)。 */ +/** + * PATCH /api/v1/admin/play-types/{play_code} + * + * is_enabled 写入当前生效玩法配置并即时推送大厅;其余字段仅更新主目录 play_types。 + */ final class PlayTypePatchController extends Controller { + public function __construct( + private readonly PlayConfigStreamService $playConfig, + ) {} + public function __invoke(Request $request, string $play_code): JsonResponse { + /** @var AdminUser $admin */ + $admin = $request->lotteryAdmin(); + /** @var PlayType $type */ $type = PlayType::query()->where('play_code', $play_code)->firstOrFail(); $data = $request->validate([ 'is_enabled' => ['sometimes', 'boolean'], 'sort_order' => ['sometimes', 'integer'], - 'display_name_zh' => ['sometimes', 'nullable', 'string', 'max:64'], - 'display_name_en' => ['sometimes', 'nullable', 'string', 'max:64'], - 'display_name_ne' => ['sometimes', 'nullable', 'string', 'max:64'], + 'display_name' => ['sometimes', 'nullable', 'string', 'max:64'], 'supports_multi_number' => ['sometimes', 'boolean'], 'reserved_rule_json' => ['sometimes', 'nullable', 'array'], ]); + if (array_key_exists('is_enabled', $data)) { + $this->playConfig->patchActivePlayToggle($admin, $play_code, (bool) $data['is_enabled'], $request); + unset($data['is_enabled']); + } + if ($data !== []) { $type->fill($data); $type->save(); diff --git a/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php b/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php index 8aa5775..05f3f81 100644 --- a/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php +++ b/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api\V1\Admin\Wallet; +use App\Models\AdminUser; use App\Support\ApiResponse; use App\Models\TransferOrder; use App\Support\PaginationTrait; @@ -80,8 +81,11 @@ final class TransferOrderListController extends Controller } } + $admin = $request->lotteryAdmin(); $paginator = $query->paginate($perPage, ['*'], 'page', $page); - $items = $paginator->getCollection()->map(fn (TransferOrder $o) => $this->formatRow($o)); + $items = $paginator->getCollection()->map( + fn (TransferOrder $o) => $this->formatRow($o, $admin instanceof AdminUser ? $admin : null), + ); return ApiResponse::success([ 'items' => $items, @@ -94,10 +98,14 @@ final class TransferOrderListController extends Controller /** * @return array */ - private function formatRow(TransferOrder $o): array + private function formatRow(TransferOrder $o, ?AdminUser $admin): array { $p = $o->player; $amount = (int) $o->amount; + $canWriteWallet = $admin !== null && ( + $admin->hasAdminPermission('prd.wallet_adjust.manage') + || $admin->hasAdminPermission('prd.wallet_reconcile.manage') + ); return [ 'id' => $o->id, @@ -113,8 +121,8 @@ final class TransferOrderListController extends Controller 'amount_formatted' => CurrencyFormatter::fromMinor($amount), 'idempotent_key' => $o->idempotent_key, 'status' => $o->status, - 'can_reverse' => $o->status === 'pending_reconcile', - 'can_manually_process' => in_array($o->status, ['processing', 'failed', 'pending_reconcile'], true), + 'can_reverse' => $canWriteWallet && $o->status === 'pending_reconcile', + 'can_manually_process' => $canWriteWallet && in_array($o->status, ['processing', 'failed', 'pending_reconcile'], true), 'external_ref_no' => $o->external_ref_no, 'external_request_payload' => $o->external_request_payload, 'external_response_payload' => $o->external_response_payload, diff --git a/app/Http/Middleware/RecordAdminApiAudit.php b/app/Http/Middleware/RecordAdminApiAudit.php new file mode 100644 index 0000000..4ba2f38 --- /dev/null +++ b/app/Http/Middleware/RecordAdminApiAudit.php @@ -0,0 +1,143 @@ +shouldRecord($request, $response)) { + return $response; + } + + $admin = $request->user(); + if (! $admin instanceof AdminUser) { + return $response; + } + + $resource = $this->resolveResource($request); + if ($resource === null || ! (bool) $resource->is_audit_required) { + return $response; + } + + $targetId = $this->resolveTargetId($request); + $actionCode = $this->resolveActionCode((string) $resource->code); + + AuditLogger::recordForAdmin( + $admin, + $request, + moduleCode: (string) $resource->module_code, + actionCode: $actionCode, + targetType: (string) $resource->code, + targetId: $targetId, + beforeJson: null, + afterJson: [ + 'http_method' => $request->method(), + 'route_name' => $this->normalizeRouteName((string) ($request->route()?->getName() ?? '')), + 'status' => $response->getStatusCode(), + 'payload' => $this->sanitizedPayload($request), + ], + ); + + return $response; + } + + private function shouldRecord(Request $request, Response $response): bool + { + if ($request->attributes->get(self::ATTRIBUTE_AUDIT_RECORDED) === true) { + return false; + } + + if (! in_array(strtoupper($request->method()), self::MUTATING_METHODS, true)) { + return false; + } + + $status = $response->getStatusCode(); + + return $status >= 200 && $status < 300; + } + + private function resolveResource(Request $request): ?object + { + $routeName = $request->route()?->getName(); + if (! is_string($routeName) || $routeName === '') { + return null; + } + + return DB::table('admin_api_resources') + ->where('route_name', $this->normalizeRouteName($routeName)) + ->where('status', 1) + ->first(['code', 'module_code', 'is_audit_required']); + } + + private function normalizeRouteName(string $routeName): string + { + return preg_replace('/^(api\.v1\.admin\.)+/', 'api.v1.admin.', $routeName) ?? $routeName; + } + + private function resolveActionCode(string $resourceCode): string + { + $pos = strrpos($resourceCode, '.'); + if ($pos === false) { + return $resourceCode; + } + + return substr($resourceCode, $pos + 1); + } + + private function resolveTargetId(Request $request): ?string + { + $route = $request->route(); + if ($route === null) { + return null; + } + + foreach (['batch', 'draw', 'transfer_no', 'player', 'admin_user', 'admin_role', 'id', 'play_code', 'number_4d', 'key'] as $key) { + $value = $route->parameter($key); + if ($value === null) { + continue; + } + + if (is_object($value) && method_exists($value, 'getKey')) { + return (string) $value->getKey(); + } + + return (string) $value; + } + + return null; + } + + /** + * @return array|null + */ + private function sanitizedPayload(Request $request): ?array + { + $data = $request->except([ + 'password', + 'password_confirmation', + 'current_password', + 'token', + ]); + + return $data === [] ? null : $data; + } +} diff --git a/app/Http/Requests/Admin/DashboardAnalyticsRequest.php b/app/Http/Requests/Admin/DashboardAnalyticsRequest.php new file mode 100644 index 0000000..d9b9aed --- /dev/null +++ b/app/Http/Requests/Admin/DashboardAnalyticsRequest.php @@ -0,0 +1,45 @@ + */ + public function rules(): array + { + return [ + 'period' => ['sometimes', 'string', Rule::in([ + 'today', + 'last_7_days', + 'last_30_days', + 'this_month', + 'lifetime', + 'custom', + ])], + 'date_from' => ['nullable', 'date_format:Y-m-d', 'required_if:period,custom'], + 'date_to' => ['nullable', 'date_format:Y-m-d', 'required_if:period,custom', 'after_or_equal:date_from'], + 'metric' => ['sometimes', 'string', Rule::in(['overview', 'bet', 'payout', 'profit'])], + 'play_code' => ['nullable', 'string', 'max:64'], + ]; + } + + /** @return array */ + public function filters(): array + { + return [ + 'period' => (string) $this->input('period', 'last_7_days'), + 'date_from' => $this->input('date_from'), + 'date_to' => $this->input('date_to'), + 'metric' => (string) $this->input('metric', 'overview'), + 'play_code' => $this->input('play_code'), + ]; + } +} diff --git a/app/Models/PlayConfigItem.php b/app/Models/PlayConfigItem.php index 1019a1d..1695a82 100644 --- a/app/Models/PlayConfigItem.php +++ b/app/Models/PlayConfigItem.php @@ -14,9 +14,7 @@ final class PlayConfigItem extends Model 'category', 'dimension', 'bet_mode', - 'display_name_zh', - 'display_name_en', - 'display_name_ne', + 'display_name', 'is_enabled', 'min_bet_amount', 'max_bet_amount', diff --git a/app/Models/PlayType.php b/app/Models/PlayType.php index bf23ad1..7a169c2 100644 --- a/app/Models/PlayType.php +++ b/app/Models/PlayType.php @@ -12,9 +12,7 @@ final class PlayType extends Model 'category', 'dimension', 'bet_mode', - 'display_name_zh', - 'display_name_en', - 'display_name_ne', + 'display_name', 'is_enabled', 'sort_order', 'supports_multi_number', diff --git a/app/Services/Admin/AdminDashboardAnalyticsBuilder.php b/app/Services/Admin/AdminDashboardAnalyticsBuilder.php new file mode 100644 index 0000000..a4069ef --- /dev/null +++ b/app/Services/Admin/AdminDashboardAnalyticsBuilder.php @@ -0,0 +1,82 @@ +|null + */ + public function build(AdminUser $admin, array $filters): ?array + { + if (! $this->canView($admin)) { + return null; + } + + $period = (string) ($filters['period'] ?? 'last_7_days'); + $metric = (string) ($filters['metric'] ?? 'overview'); + $playCode = isset($filters['play_code']) && $filters['play_code'] !== '' + ? (string) $filters['play_code'] + : null; + + $range = $this->reportQuery->resolveDashboardPeriod( + $period, + isset($filters['date_from']) ? (string) $filters['date_from'] : null, + isset($filters['date_to']) ? (string) $filters['date_to'] : null, + ); + + $dateFrom = $range['date_from']; + $dateTo = $range['date_to']; + + $trend = $this->reportQuery->dailyProfitSeriesFilled($dateFrom, $dateTo); + + return [ + 'period' => $period, + 'metric' => $metric, + 'play_code' => $playCode, + 'date_from' => $dateFrom, + 'date_to' => $dateTo, + 'currency_code' => $this->reportQuery->resolvePeriodCurrencyCode($dateFrom, $dateTo), + 'summary' => $this->reportQuery->periodFinanceTotals($dateFrom, $dateTo), + 'daily_series' => $trend['series'], + 'chart_meta' => [ + 'chart_date_from' => $trend['chart_date_from'], + 'chart_date_to' => $trend['chart_date_to'], + 'truncated' => $trend['truncated'], + 'span_days' => $trend['span_days'], + ], + 'play_breakdown' => $this->reportQuery->playDimensionBreakdownRows( + $dateFrom, + $dateTo, + $playCode, + ), + ]; + } + + private function canView(AdminUser $admin): bool + { + return $admin->hasAdminPermission('prd.dashboard.view') + || $admin->hasAdminPermission('prd.draw_result.manage') + || $admin->hasAdminPermission('prd.draw_result.view') + || $admin->hasAdminPermission('prd.risk.view') + || $admin->hasAdminPermission('prd.risk.manage') + || $admin->hasAdminPermission('prd.report.view'); + } +} diff --git a/app/Services/Admin/AdminDashboardSnapshotBuilder.php b/app/Services/Admin/AdminDashboardSnapshotBuilder.php index bb61fbf..6889e1d 100644 --- a/app/Services/Admin/AdminDashboardSnapshotBuilder.php +++ b/app/Services/Admin/AdminDashboardSnapshotBuilder.php @@ -22,6 +22,7 @@ final class AdminDashboardSnapshotBuilder { public function __construct( private readonly DrawHallSnapshotBuilder $hallSnapshot, + private readonly AdminReportQueryService $reportQuery, ) {} /** @return array */ @@ -34,6 +35,8 @@ final class AdminDashboardSnapshotBuilder $out = [ 'hall' => $hall, 'resolved_draw' => null, + 'today_finance' => null, + 'lifetime_finance' => null, 'finance' => null, 'draw' => null, 'risk' => null, @@ -67,6 +70,8 @@ final class AdminDashboardSnapshotBuilder ]; if ($canDraw) { + $out['today_finance'] = $this->todayFinanceSummary(); + $out['lifetime_finance'] = $this->reportQuery->platformLifetimeTotals(); $out['finance'] = $this->financeSummary($draw); $out['draw'] = $this->drawPanel($draw); $out['risk'] = $this->riskPanel($draw); @@ -81,8 +86,11 @@ final class AdminDashboardSnapshotBuilder private function canDrawFinanceAndRisk(AdminUser $admin): bool { - return $admin->hasAdminPermission('prd.draw_result.manage') - || $admin->hasAdminPermission('prd.draw_result.view'); + return $admin->hasAdminPermission('prd.dashboard.view') + || $admin->hasAdminPermission('prd.draw_result.manage') + || $admin->hasAdminPermission('prd.draw_result.view') + || $admin->hasAdminPermission('prd.risk.view') + || $admin->hasAdminPermission('prd.risk.manage'); } private function canWalletReconcile(AdminUser $admin): bool @@ -99,6 +107,36 @@ final class AdminDashboardSnapshotBuilder ->count(); } + /** + * 按业务日汇总「今日」投注/派彩/盈亏(与报表中心 daily-profit 口径一致)。 + * + * @return array + */ + private function todayFinanceSummary(): array + { + $today = now()->toDateString(); + $rows = $this->reportQuery->dailyProfitRows($today, $today); + $row = $rows[0] ?? [ + 'business_date' => $today, + 'total_bet_minor' => 0, + 'total_payout_minor' => 0, + 'approx_house_gross_minor' => 0, + ]; + + $currencyCode = (string) (TicketOrder::query() + ->join('draws', 'draws.id', '=', 'ticket_orders.draw_id') + ->where('draws.business_date', $today) + ->value('ticket_orders.currency_code') ?? ''); + + return [ + 'business_date' => (string) $row['business_date'], + 'currency_code' => $currencyCode !== '' ? $currencyCode : null, + 'total_bet_minor' => (int) $row['total_bet_minor'], + 'total_payout_minor' => (int) $row['total_payout_minor'], + 'approx_house_gross_minor' => (int) $row['approx_house_gross_minor'], + ]; + } + /** @return array */ private function financeSummary(Draw $draw): array { diff --git a/app/Services/Admin/AdminReportQueryService.php b/app/Services/Admin/AdminReportQueryService.php index 10394e7..8fb451f 100644 --- a/app/Services/Admin/AdminReportQueryService.php +++ b/app/Services/Admin/AdminReportQueryService.php @@ -28,6 +28,180 @@ final class AdminReportQueryService return ['date_from' => $dateFrom, 'date_to' => $dateTo]; } + /** + * @return array{date_from: string, date_to: string} + */ + public function resolveDashboardPeriod(string $period, ?string $dateFrom, ?string $dateTo): array + { + $today = now()->toDateString(); + + $range = match ($period) { + 'today' => ['date_from' => $today, 'date_to' => $today], + 'last_7_days' => [ + 'date_from' => now()->subDays(6)->toDateString(), + 'date_to' => $today, + ], + 'last_30_days' => [ + 'date_from' => now()->subDays(29)->toDateString(), + 'date_to' => $today, + ], + 'this_month' => [ + 'date_from' => now()->startOfMonth()->toDateString(), + 'date_to' => $today, + ], + 'lifetime' => $this->lifetimeBusinessDateBounds(), + 'custom' => [ + 'date_from' => $dateFrom !== null && $dateFrom !== '' ? $dateFrom : $today, + 'date_to' => $dateTo !== null && $dateTo !== '' ? $dateTo : $today, + ], + default => ['date_from' => $today, 'date_to' => $today], + }; + + $from = $range['date_from']; + $to = $range['date_to']; + if ($from > $to) { + [$from, $to] = [$to, $from]; + } + + return ['date_from' => $from, 'date_to' => $to]; + } + + /** + * @return array{date_from: string, date_to: string} + */ + private function lifetimeBusinessDateBounds(): array + { + $today = now()->toDateString(); + $bounds = DB::table('draws as d') + ->join('ticket_orders as o', 'o.draw_id', '=', 'd.id') + ->selectRaw('MIN(d.business_date) as date_from') + ->selectRaw('MAX(d.business_date) as date_to') + ->first(); + + $from = $this->formatBusinessDateValue($bounds?->date_from) ?? $today; + $to = $this->formatBusinessDateValue($bounds?->date_to) ?? $today; + + return ['date_from' => $from, 'date_to' => $to]; + } + + /** + * @return array{ + * total_bet_minor: int, + * total_payout_minor: int, + * approx_house_gross_minor: int, + * draw_count: int, + * business_day_count: int + * } + */ + public function periodFinanceTotals(string $dateFrom, string $dateTo): array + { + $rows = $this->dailyProfitRows($dateFrom, $dateTo); + $totalBet = 0; + $totalPayout = 0; + $totalGross = 0; + foreach ($rows as $row) { + $totalBet += (int) $row['total_bet_minor']; + $totalPayout += (int) $row['total_payout_minor']; + $totalGross += (int) $row['approx_house_gross_minor']; + } + + $activity = DB::table('draws as d') + ->join('ticket_orders as o', 'o.draw_id', '=', 'd.id') + ->whereBetween('d.business_date', [$dateFrom, $dateTo]) + ->selectRaw('COUNT(DISTINCT d.id) as draw_count') + ->selectRaw('COUNT(DISTINCT d.business_date) as business_day_count') + ->first(); + + return [ + 'total_bet_minor' => $totalBet, + 'total_payout_minor' => $totalPayout, + 'approx_house_gross_minor' => $totalGross, + 'draw_count' => (int) ($activity->draw_count ?? 0), + 'business_day_count' => (int) ($activity->business_day_count ?? 0), + ]; + } + + /** + * 连续业务日序列(无数据日补零),用于趋势图。 + * + * @return list> + */ + public function dailyProfitSeriesFilled(string $dateFrom, string $dateTo, int $maxDays = 90): array + { + $from = Carbon::parse($dateFrom)->startOfDay(); + $to = Carbon::parse($dateTo)->startOfDay(); + $spanDays = (int) $from->diffInDays($to) + 1; + + $chartFrom = $dateFrom; + $chartTo = $dateTo; + $truncated = false; + if ($spanDays > $maxDays) { + $chartFrom = $to->copy()->subDays($maxDays - 1)->format('Y-m-d'); + $truncated = true; + } + + $indexed = collect($this->dailyProfitRows($chartFrom, $chartTo))->keyBy('business_date'); + $cursor = Carbon::parse($chartFrom)->startOfDay(); + $end = Carbon::parse($chartTo)->startOfDay(); + $series = []; + + while ($cursor <= $end) { + $key = $cursor->format('Y-m-d'); + $series[] = $indexed[$key] ?? [ + 'business_date' => $key, + 'total_bet_minor' => 0, + 'total_payout_minor' => 0, + 'approx_house_gross_minor' => 0, + ]; + $cursor->addDay(); + } + + return [ + 'series' => $series, + 'chart_date_from' => $chartFrom, + 'chart_date_to' => $chartTo, + 'truncated' => $truncated, + 'span_days' => $spanDays, + ]; + } + + /** + * @return list> + */ + public function playDimensionBreakdownRows( + string $dateFrom, + string $dateTo, + ?string $playCode = null, + int $limit = 12, + ): array { + return $this->playDimensionBaseQuery($playCode, $dateFrom, $dateTo) + ->orderByDesc('total_bet_minor') + ->limit($limit) + ->get() + ->map(static function (object $row): array { + return [ + 'play_code' => (string) $row->play_code, + 'dimension' => (int) $row->dimension, + 'total_bet_minor' => (int) $row->total_bet_minor, + 'total_payout_minor' => (int) $row->total_payout_minor, + 'approx_house_gross_minor' => (int) $row->approx_house_gross_minor, + ]; + }) + ->values() + ->all(); + } + + public function resolvePeriodCurrencyCode(string $dateFrom, string $dateTo): ?string + { + $currencyCode = (string) (DB::table('ticket_orders as o') + ->join('draws as d', 'd.id', '=', 'o.draw_id') + ->whereBetween('d.business_date', [$dateFrom, $dateTo]) + ->orderByDesc('o.id') + ->value('o.currency_code') ?? ''); + + return $currencyCode !== '' ? $currencyCode : null; + } + public function dailyProfitPaginated(string $dateFrom, string $dateTo, int $page, int $perPage): LengthAwarePaginator { $rows = $this->dailyProfitRows($dateFrom, $dateTo); @@ -80,6 +254,57 @@ final class AdminReportQueryService ->all(); } + /** + * 全平台历史累计投注/派彩/盈亏(与 daily-profit 同口径,不限业务日)。 + * + * @return array{ + * currency_code: ?string, + * total_bet_minor: int, + * total_payout_minor: int, + * approx_house_gross_minor: int, + * draw_count: int, + * business_day_count: int, + * date_from: ?string, + * date_to: ?string + * } + */ + public function platformLifetimeTotals(): array + { + $totalBetMinor = (int) DB::table('ticket_orders')->sum('total_actual_deduct'); + + $payoutAgg = DB::table('ticket_items') + ->selectRaw('COALESCE(SUM(win_amount), 0) as win_minor, COALESCE(SUM(jackpot_win_amount), 0) as jackpot_minor') + ->first(); + $totalPayoutMinor = (int) ($payoutAgg->win_minor ?? 0) + (int) ($payoutAgg->jackpot_minor ?? 0); + + $activity = DB::table('draws as d') + ->join('ticket_orders as o', 'o.draw_id', '=', 'd.id') + ->selectRaw('COUNT(DISTINCT d.id) as draw_count') + ->selectRaw('COUNT(DISTINCT d.business_date) as business_day_count') + ->selectRaw('MIN(d.business_date) as date_from') + ->selectRaw('MAX(d.business_date) as date_to') + ->first(); + + $drawCount = (int) ($activity->draw_count ?? 0); + $businessDayCount = (int) ($activity->business_day_count ?? 0); + + $dateFrom = $this->formatBusinessDateValue($activity?->date_from); + $dateTo = $this->formatBusinessDateValue($activity?->date_to); + + $currencyCode = (string) (DB::table('ticket_orders')->orderByDesc('id')->value('currency_code') ?? ''); + + return [ + 'currency_code' => $currencyCode !== '' ? $currencyCode : null, + 'total_bet_minor' => $totalBetMinor, + 'total_payout_minor' => $totalPayoutMinor, + 'approx_house_gross_minor' => $totalBetMinor - $totalPayoutMinor, + 'draw_count' => $drawCount, + 'business_day_count' => $businessDayCount, + 'date_from' => $dateFrom, + 'date_to' => $dateTo, + ]; + } + public function playerWinLossPaginated( ?int $playerId, string $dateFrom, @@ -340,4 +565,26 @@ final class AdminReportQueryService return $query; } + + private function formatBusinessDateValue(mixed $value): ?string + { + if ($value === null) { + return null; + } + + if ($value instanceof Carbon) { + return $value->format('Y-m-d'); + } + + $raw = trim((string) $value); + if ($raw === '') { + return null; + } + + if (preg_match('/^\d{4}-\d{2}-\d{2}/', $raw, $m) === 1) { + return substr($m[0], 0, 10); + } + + return $raw; + } } diff --git a/app/Services/Config/EffectivePlayCatalogService.php b/app/Services/Config/EffectivePlayCatalogService.php index 0df3cb0..e51ac61 100644 --- a/app/Services/Config/EffectivePlayCatalogService.php +++ b/app/Services/Config/EffectivePlayCatalogService.php @@ -69,9 +69,7 @@ final class EffectivePlayCatalogService 'category' => $c->category, 'dimension' => $c->dimension === null ? null : (int) $c->dimension, 'bet_mode' => $c->bet_mode, - 'display_name_zh' => $c->display_name_zh, - 'display_name_en' => $c->display_name_en, - 'display_name_ne' => $c->display_name_ne, + 'display_name' => $c->display_name, 'sort_order' => (int) $c->display_order, 'supports_multi_number' => (bool) $c->supports_multi_number, 'master_enabled' => (bool) $c->is_enabled, @@ -148,9 +146,7 @@ final class EffectivePlayCatalogService 'category' => $r->category, 'dimension' => $r->dimension === null ? null : (int) $r->dimension, 'bet_mode' => $r->bet_mode, - 'display_name_zh' => $r->display_name_zh, - 'display_name_en' => $r->display_name_en, - 'display_name_ne' => $r->display_name_ne, + 'display_name' => $r->display_name, 'is_enabled' => (bool) $r->is_enabled, 'min_bet_amount' => (int) $r->min_bet_amount, 'max_bet_amount' => (int) $r->max_bet_amount, diff --git a/app/Services/Config/OddsStreamService.php b/app/Services/Config/OddsStreamService.php index 852ed69..277ddf5 100644 --- a/app/Services/Config/OddsStreamService.php +++ b/app/Services/Config/OddsStreamService.php @@ -13,6 +13,7 @@ use Illuminate\Support\Facades\DB; use App\Support\OddsStandardScopes; use App\Lottery\ConfigVersionStatus; use App\Services\Draw\LotteryHallRealtimeBroadcaster; +use App\Http\Middleware\RecordAdminApiAudit; use Illuminate\Validation\ValidationException; use Illuminate\Contracts\Pagination\LengthAwarePaginator; @@ -162,6 +163,7 @@ final class OddsStreamService beforeJson: $before, afterJson: $after, ); + $request?->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true); } public function deleteVersion(OddsVersion $version, AdminUser $admin, ?Request $request = null): void @@ -182,6 +184,7 @@ final class OddsStreamService beforeJson: $before, afterJson: null, ); + $request?->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true); } /** @return array */ diff --git a/app/Services/Config/PlayConfigStreamService.php b/app/Services/Config/PlayConfigStreamService.php index 4fe53d9..09e0668 100644 --- a/app/Services/Config/PlayConfigStreamService.php +++ b/app/Services/Config/PlayConfigStreamService.php @@ -11,6 +11,7 @@ use App\Models\PlayConfigVersion; use Illuminate\Support\Facades\DB; use App\Lottery\ConfigVersionStatus; use App\Services\Draw\LotteryHallRealtimeBroadcaster; +use App\Http\Middleware\RecordAdminApiAudit; use Illuminate\Validation\ValidationException; use Illuminate\Contracts\Pagination\LengthAwarePaginator; @@ -62,9 +63,7 @@ final class PlayConfigStreamService 'category' => $row->category, 'dimension' => $row->dimension, 'bet_mode' => $row->bet_mode, - 'display_name_zh' => $row->display_name_zh, - 'display_name_en' => $row->display_name_en, - 'display_name_ne' => $row->display_name_ne, + 'display_name' => $row->display_name, 'is_enabled' => $row->is_enabled, 'min_bet_amount' => $row->min_bet_amount, 'max_bet_amount' => $row->max_bet_amount, @@ -85,9 +84,7 @@ final class PlayConfigStreamService 'category' => $pt->category, 'dimension' => $pt->dimension, 'bet_mode' => $pt->bet_mode, - 'display_name_zh' => $pt->display_name_zh, - 'display_name_en' => $pt->display_name_en, - 'display_name_ne' => $pt->display_name_ne, + 'display_name' => $pt->display_name, 'is_enabled' => (bool) $pt->is_enabled, 'min_bet_amount' => 100, 'max_bet_amount' => 500_000_000, @@ -109,6 +106,53 @@ final class PlayConfigStreamService /** * @param array> $items */ + /** + * 即时切换当前生效玩法开关(无需发布草稿),并推送大厅 WS。 + */ + public function patchActivePlayToggle(AdminUser $admin, string $playCode, bool $enabled, ?Request $request = null): PlayConfigItem + { + /** @var PlayConfigVersion $active */ + $active = PlayConfigVersion::query() + ->where('status', ConfigVersionStatus::Active->value) + ->firstOrFail(); + + /** @var PlayConfigItem $item */ + $item = PlayConfigItem::query() + ->where('version_id', $active->id) + ->where('play_code', $playCode) + ->firstOrFail(); + + $before = ['is_enabled' => (bool) $item->is_enabled]; + if ($before['is_enabled'] === $enabled) { + return $item; + } + + DB::transaction(function () use ($item, $enabled, $active, $admin, $playCode): void { + $item->forceFill(['is_enabled' => $enabled])->save(); + $active->forceFill(['updated_by' => $admin->id])->save(); + + PlayType::query() + ->where('play_code', $playCode) + ->update(['is_enabled' => $enabled]); + }); + + $this->hallRealtime->notifyPlayToggle($playCode, $enabled, 'play toggle applied to active config'); + + AuditLogger::recordForAdmin( + $admin, + $request, + moduleCode: 'play_config', + actionCode: 'toggle_active', + targetType: 'play_config_item', + targetId: $playCode, + beforeJson: $before, + afterJson: ['is_enabled' => $enabled, 'active_version_id' => $active->id], + ); + $request?->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true); + + return $item->refresh(); + } + public function replaceItems(PlayConfigVersion $draft, array $items, AdminUser $admin): void { DB::transaction(function () use ($draft, $items, $admin): void { @@ -121,9 +165,7 @@ final class PlayConfigStreamService 'category' => $row['category'] ?? null, 'dimension' => $row['dimension'] ?? null, 'bet_mode' => $row['bet_mode'] ?? null, - 'display_name_zh' => $row['display_name_zh'] ?? null, - 'display_name_en' => $row['display_name_en'] ?? null, - 'display_name_ne' => $row['display_name_ne'] ?? null, + 'display_name' => $row['display_name'] ?? null, 'is_enabled' => (bool) ($row['is_enabled'] ?? true), 'min_bet_amount' => (int) ($row['min_bet_amount'] ?? 0), 'max_bet_amount' => (int) ($row['max_bet_amount'] ?? 0), @@ -183,6 +225,7 @@ final class PlayConfigStreamService beforeJson: $before, afterJson: $after, ); + $request?->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true); } /** @@ -223,6 +266,7 @@ final class PlayConfigStreamService beforeJson: $before, afterJson: null, ); + $request?->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true); } /** @return array */ @@ -278,8 +322,8 @@ final class PlayConfigStreamService $errors["items.$index.max_bet_amount"][] = '最大下注额不能小于最小下注额'; } - if ($row->display_name_zh === null || $row->display_name_zh === '') { - $errors["items.$index.display_name_zh"][] = '显示名称不能为空'; + if ($row->display_name === null || $row->display_name === '') { + $errors["items.$index.display_name"][] = '显示名称不能为空'; } if ($row->display_order === null) { diff --git a/app/Services/Config/RiskCapStreamService.php b/app/Services/Config/RiskCapStreamService.php index cd5c061..e306f51 100644 --- a/app/Services/Config/RiskCapStreamService.php +++ b/app/Services/Config/RiskCapStreamService.php @@ -8,6 +8,7 @@ use Illuminate\Http\Request; use App\Services\AuditLogger; use App\Models\RiskCapVersion; use Illuminate\Support\Facades\DB; +use App\Http\Middleware\RecordAdminApiAudit; use App\Lottery\ConfigVersionStatus; use Illuminate\Validation\ValidationException; use Illuminate\Contracts\Pagination\LengthAwarePaginator; @@ -129,6 +130,7 @@ final class RiskCapStreamService beforeJson: $before, afterJson: $after, ); + $request?->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true); } public function deleteVersion(RiskCapVersion $version, AdminUser $admin, ?Request $request = null): void @@ -149,6 +151,7 @@ final class RiskCapStreamService beforeJson: $before, afterJson: null, ); + $request?->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true); } /** @return array */ diff --git a/app/Services/Draw/DrawHallSnapshotBuilder.php b/app/Services/Draw/DrawHallSnapshotBuilder.php index 5fa8298..c048a69 100644 --- a/app/Services/Draw/DrawHallSnapshotBuilder.php +++ b/app/Services/Draw/DrawHallSnapshotBuilder.php @@ -30,6 +30,19 @@ final class DrawHallSnapshotBuilder public function effectiveHallDisplayStatus(Draw $target, Carbon $nowUtc): string { $db = (string) $target->status; + + if ($db === DrawStatus::Pending->value) { + $startUtc = $target->start_time; + if ($startUtc instanceof Carbon && $startUtc <= $nowUtc) { + $closeUtc = $target->close_time; + if ($closeUtc === null || $closeUtc > $nowUtc) { + $db = DrawStatus::Open->value; + } + } else { + return $db; + } + } + if ($db !== DrawStatus::Open->value) { return $db; } @@ -62,7 +75,14 @@ final class DrawHallSnapshotBuilder $nowUtc = ($nowUtc ?? Carbon::now())->utc(); $bettingOpen = Draw::query() - ->where('status', DrawStatus::Open->value) + ->where(function ($q) use ($nowUtc): void { + $q->where('status', DrawStatus::Open->value) + ->orWhere(function ($q2) use ($nowUtc): void { + $q2->where('status', DrawStatus::Pending->value) + ->whereNotNull('start_time') + ->where('start_time', '<=', $nowUtc); + }); + }) ->where(function ($q) use ($nowUtc): void { $q->whereNull('close_time') ->orWhere('close_time', '>', $nowUtc); @@ -70,6 +90,10 @@ final class DrawHallSnapshotBuilder ->orderBy('draw_time') ->first(); + if ($bettingOpen !== null) { + return $bettingOpen; + } + $chronological = Draw::query() ->whereNotIn('status', [ DrawStatus::Settled->value, @@ -78,7 +102,29 @@ final class DrawHallSnapshotBuilder ->orderBy('draw_time') ->first(); - return $bettingOpen ?? $chronological; + if ($chronological !== null && $this->isCooldownExpired($chronological, $nowUtc)) { + $next = Draw::query() + ->whereNotIn('status', [ + DrawStatus::Settled->value, + DrawStatus::Cancelled->value, + ]) + ->where('draw_time', '>', $chronological->draw_time) + ->orderBy('draw_time') + ->first(); + + if ($next !== null) { + return $next; + } + } + + return $chronological; + } + + private function isCooldownExpired(Draw $draw, Carbon $nowUtc): bool + { + return (string) $draw->status === DrawStatus::Cooldown->value + && $draw->cooling_end_time instanceof Carbon + && $draw->cooling_end_time <= $nowUtc; } /** diff --git a/app/Services/Draw/DrawResultViewService.php b/app/Services/Draw/DrawResultViewService.php index f6ce2d9..42d313b 100644 --- a/app/Services/Draw/DrawResultViewService.php +++ b/app/Services/Draw/DrawResultViewService.php @@ -31,6 +31,17 @@ final class DrawResultViewService * consolation: array * } */ + /** 已发布批次的头奖 4D 号码;未发布或缺失时返回空字符串。 */ + public function firstPrizeNumber4dForDraw(Draw $draw): string + { + $summary = $this->summarizeDraw($draw); + if ($summary === null) { + return ''; + } + + return (string) ($summary['results']['1st'] ?? ''); + } + public function numbersFromItems(Collection $items): array { $byType = [ diff --git a/app/Services/Draw/DrawRngRunner.php b/app/Services/Draw/DrawRngRunner.php index 0906aea..363868c 100644 --- a/app/Services/Draw/DrawRngRunner.php +++ b/app/Services/Draw/DrawRngRunner.php @@ -32,8 +32,10 @@ final class DrawRngRunner 'draw.require_manual_review', (bool) config('lottery.draw.require_manual_review', false), ); - $seedMaterial = bin2hex(random_bytes(32)); - $rngSeedHash = hash('sha256', $seedMaterial); + $seedHex = DrawRngSeedDerivation::generateSeedHex(); + $rngSeedHash = DrawRngSeedDerivation::hashSeedHex($seedHex); + $rawSeedEncrypted = DrawRngSeedDerivation::encryptSeedHex($seedHex); + $derivedRows = DrawRngSeedDerivation::deriveAllSlotRows($seedHex, (int) $draw->id); $nextVersion = max(1, (int) $draw->current_result_version + 1); @@ -42,28 +44,24 @@ final class DrawRngRunner 'result_version' => $nextVersion, 'source_type' => DrawResultSourceType::Rng->value, 'rng_seed_hash' => $rngSeedHash, - 'raw_seed_encrypted' => null, + 'raw_seed_encrypted' => $rawSeedEncrypted, 'status' => $manualReview ? DrawResultBatchStatus::PendingReview->value : DrawResultBatchStatus::Published->value, 'created_by' => null, 'confirmed_by' => null, 'confirmed_at' => $manualReview ? null : now(), ]); - foreach (DrawPrizeLayout::slots() as $slot) { - $num = str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT); - $suffix3 = substr($num, -3); - $suffix2 = substr($num, -2); - + foreach ($derivedRows as $row) { DrawResultItem::query()->create([ 'draw_id' => $draw->id, 'result_batch_id' => $batch->id, - 'prize_type' => $slot['prize_type'], - 'prize_index' => $slot['prize_index'], - 'number_4d' => $num, - 'suffix_3d' => $suffix3, - 'suffix_2d' => $suffix2, - 'head_digit' => $num !== '' ? (int) substr($num, 0, 1) : null, - 'tail_digit' => $num !== '' ? (int) substr($num, 3, 1) : null, + 'prize_type' => $row['prize_type'], + 'prize_index' => $row['prize_index'], + 'number_4d' => $row['number_4d'], + 'suffix_3d' => $row['suffix_3d'], + 'suffix_2d' => $row['suffix_2d'], + 'head_digit' => $row['head_digit'], + 'tail_digit' => $row['tail_digit'], ]); } diff --git a/app/Services/Draw/DrawRngSeedDerivation.php b/app/Services/Draw/DrawRngSeedDerivation.php new file mode 100644 index 0000000..943b8a2 --- /dev/null +++ b/app/Services/Draw/DrawRngSeedDerivation.php @@ -0,0 +1,126 @@ + + */ + public static function deriveAllSlotRows(string $seedHex, int $drawId): array + { + $rows = []; + foreach (DrawPrizeLayout::slots() as $slotIndex => $slot) { + $num = self::deriveNumber4d($seedHex, $drawId, $slotIndex); + $rows[] = [ + 'prize_type' => $slot['prize_type'], + 'prize_index' => $slot['prize_index'], + 'number_4d' => $num, + 'suffix_3d' => substr($num, -3), + 'suffix_2d' => substr($num, -2), + 'head_digit' => $num !== '' ? (int) substr($num, 0, 1) : null, + 'tail_digit' => $num !== '' ? (int) substr($num, 3, 1) : null, + ]; + } + + return $rows; + } + + /** 审计:校验批次种子摘要、密文可解密且号码可由种子复算。 */ + public static function verifyBatchAudit(DrawResultBatch $batch, Draw $draw): bool + { + if ($batch->source_type !== 'rng') { + return false; + } + + $encrypted = $batch->raw_seed_encrypted; + $hash = $batch->rng_seed_hash; + if (! is_string($encrypted) || $encrypted === '' || ! is_string($hash) || $hash === '') { + return false; + } + + try { + $seedHex = self::decryptSeedHex($encrypted); + } catch (\InvalidArgumentException) { + return false; + } + + if (self::hashSeedHex($seedHex) !== $hash) { + return false; + } + + $expected = self::deriveAllSlotRows($seedHex, (int) $draw->id); + $items = $batch->items()->get(); + + if ($items->count() !== count($expected)) { + return false; + } + + foreach ($expected as $row) { + $item = $items->first(fn (DrawResultItem $i) => $i->prize_type === $row['prize_type'] + && (int) $i->prize_index === $row['prize_index']); + if ($item === null || $item->number_4d !== $row['number_4d']) { + return false; + } + } + + return true; + } +} diff --git a/app/Services/Draw/DrawTickService.php b/app/Services/Draw/DrawTickService.php index efaea8e..a129d21 100644 --- a/app/Services/Draw/DrawTickService.php +++ b/app/Services/Draw/DrawTickService.php @@ -7,6 +7,7 @@ use App\Models\Draw; use App\Lottery\DrawStatus; use App\Services\LotterySettings; use App\Services\Settlement\SettlementOrchestrator; +use App\Services\Settlement\SettlementTickFinalizer; /** * 每分钟调度:期号状态推进 → RNG(若到期号)→ 冷静期结束时进入结算态 → 补齐未来缓冲。 @@ -21,11 +22,14 @@ final class DrawTickService private readonly DrawHallSnapshotBuilder $hallSnapshot, private readonly LotteryHallRealtimeBroadcaster $hallRealtime, private readonly SettlementOrchestrator $settlementOrchestrator, + private readonly SettlementTickFinalizer $settlementFinalizer, ) {} /** * @return array{ * status_updates: array, + * settling_settled: int, + * settlement_finalized: array{approved: int, paid: int}, * rng_rung: int, * rng_errors: array, * planned: array @@ -45,6 +49,7 @@ final class DrawTickService ]; $settlingSettled = $this->settleSettlingDraws(); + $settlementFinalized = $this->settlementFinalizer->finalizePendingBatches(); $rngOutcome = $this->rng->runDue($nowUtc); $planned = $this->planner->ensureBuffer($nowUtc); @@ -52,6 +57,7 @@ final class DrawTickService $report = [ 'status_updates' => $statusUpdates, 'settling_settled' => $settlingSettled, + 'settlement_finalized' => $settlementFinalized, 'rng_rung' => $rngOutcome['rung'], 'rng_errors' => $rngOutcome['errors'], 'planned' => $planned, diff --git a/app/Services/Draw/LotteryHallRealtimeBroadcaster.php b/app/Services/Draw/LotteryHallRealtimeBroadcaster.php index 8b3fa1e..e5d8944 100644 --- a/app/Services/Draw/LotteryHallRealtimeBroadcaster.php +++ b/app/Services/Draw/LotteryHallRealtimeBroadcaster.php @@ -22,7 +22,7 @@ final class LotteryHallRealtimeBroadcaster private readonly DrawHallSnapshotBuilder $snapshot, ) {} - /** 每秒调度:`draw.countdown` 仅发送轻量心跳,不重查全量大厅快照。 */ + /** 每秒调度:`draw.countdown` 推送大厅快照(与 GET draw/current 一致),避免仅本地倒计时无法切期。 */ public function countdownPulse(): void { if (! $this->driverSupportsRealtime()) { @@ -31,7 +31,7 @@ final class LotteryHallRealtimeBroadcaster $ms = (int) floor(microtime(true) * 1000); - broadcast(new DrawCountdownBroadcast(null, $ms)); + broadcast(new DrawCountdownBroadcast($this->snapshot->build(), $ms)); } /** diff --git a/app/Services/Jackpot/JackpotBurstAllocator.php b/app/Services/Jackpot/JackpotBurstAllocator.php index 9cdb7e0..194813c 100644 --- a/app/Services/Jackpot/JackpotBurstAllocator.php +++ b/app/Services/Jackpot/JackpotBurstAllocator.php @@ -10,7 +10,7 @@ use App\Models\JackpotPayoutLog; use Illuminate\Support\Collection; /** - * 产品文档 §5.11.2–5.11.3:中头奖且满足阈值或连续未爆期数 → 按比例释放奖池,按注项 `total_bet_amount` 比例分配。 + * 产品文档 §5.11.2–5.11.3:中头奖且满足阈值或连续未爆期数 → 按比例/全额释放奖池,按注项 `total_bet_amount` 比例分配。 */ final class JackpotBurstAllocator { @@ -36,36 +36,72 @@ final class JackpotBurstAllocator } $trigger = $thresholdOk ? 'threshold' : ($gapOk ? 'forced_gap' : 'play_combo'); + $releaseFullPool = $trigger === 'forced_gap'; + $winnerItems = $winners->map(fn (array $r): TicketItem => $r['item'])->values(); + + return $this->burstToWinners( + $draw, + $pool, + $winnerItems, + $trigger, + $releaseFullPool, + [ + 'threshold_ok' => $thresholdOk, + 'gap_ok' => $gapOk, + 'combo_ok' => $comboOk, + 'combo_trigger_play_codes' => $this->comboTriggerPlayCodes($pool), + ], + ); + } + + /** + * 超管手动爆池:跳过头奖触发条件校验,仍要求存在头奖中奖注单,并按配置派彩比例释放奖池。 + * + * @param Collection $winnerItems + * @return array{allocations: array, pool_payout: int, trigger: string, log_id: int} + */ + public function burstManual(Draw $draw, JackpotPool $pool, Collection $winnerItems): array + { + if ($winnerItems->isEmpty()) { + throw new \RuntimeException('jackpot_manual_no_first_prize_winners'); + } + + $out = $this->burstToWinners($draw, $pool, $winnerItems, 'manual', false, ['manual' => true]); + + return [ + 'allocations' => $out['allocations'], + 'pool_payout' => $out['pool_payout'], + 'trigger' => 'manual', + 'log_id' => (int) ($out['log_id'] ?? 0), + ]; + } + + /** + * @param Collection $winnerItems + * @param array $snapshotExtra + * @return array{allocations: array, pool_payout: int, trigger: string, log_id: int} + */ + private function burstToWinners( + Draw $draw, + JackpotPool $pool, + Collection $winnerItems, + string $trigger, + bool $releaseFullPool, + array $snapshotExtra, + ): array { $poolBefore = (int) $pool->current_amount; - $poolPayout = (int) floor($poolBefore * (float) $pool->payout_rate); + $poolPayout = $releaseFullPool + ? $poolBefore + : (int) floor($poolBefore * (float) $pool->payout_rate); + if ($poolPayout <= 0) { - return ['allocations' => [], 'pool_payout' => 0, 'trigger' => null]; + return ['allocations' => [], 'pool_payout' => 0, 'trigger' => $trigger, 'log_id' => 0]; } - $list = $winners->values()->all(); - $weightTotal = 0; - foreach ($list as $r) { - $weightTotal += (int) $r['item']->total_bet_amount; - } - if ($weightTotal <= 0) { - return ['allocations' => [], 'pool_payout' => 0, 'trigger' => null]; - } - - $allocations = []; - $remaining = $poolPayout; - $n = count($list); - foreach ($list as $idx => $r) { - /** @var TicketItem $item */ - $item = $r['item']; - $w = (int) $item->total_bet_amount; - if ($idx === $n - 1) { - $share = max(0, $remaining); - } else { - $share = (int) floor($poolPayout * $w / $weightTotal); - $remaining -= $share; - } - $allocations[(int) $item->id] = $share; + $allocations = $this->distributeByBetWeight($winnerItems, $poolPayout); + if ($allocations === []) { + return ['allocations' => [], 'pool_payout' => 0, 'trigger' => $trigger, 'log_id' => 0]; } $pool->forceFill([ @@ -73,23 +109,59 @@ final class JackpotBurstAllocator 'last_trigger_draw_id' => $draw->id, ])->save(); - JackpotPayoutLog::query()->create([ + $log = JackpotPayoutLog::query()->create([ 'draw_id' => $draw->id, 'jackpot_pool_id' => $pool->id, 'trigger_type' => $trigger, 'total_payout_amount' => $poolPayout, 'winner_count' => count($allocations), - 'trigger_snapshot_json' => [ - 'threshold_ok' => $thresholdOk, - 'gap_ok' => $gapOk, - 'combo_ok' => $comboOk, - 'combo_trigger_play_codes' => $this->comboTriggerPlayCodes($pool), + 'trigger_snapshot_json' => array_merge($snapshotExtra, [ 'pool_amount_before' => $poolBefore, 'payout_rate' => (string) $pool->payout_rate, - ], + 'release_full_pool' => $releaseFullPool, + ]), ]); - return ['allocations' => $allocations, 'pool_payout' => $poolPayout, 'trigger' => $trigger]; + return [ + 'allocations' => $allocations, + 'pool_payout' => $poolPayout, + 'trigger' => $trigger, + 'log_id' => (int) $log->id, + ]; + } + + /** + * @param Collection $winnerItems + * @return array + */ + private function distributeByBetWeight(Collection $winnerItems, int $poolPayout): array + { + $list = $winnerItems->values()->all(); + $weightTotal = 0; + foreach ($list as $item) { + $weightTotal += (int) $item->total_bet_amount; + } + if ($weightTotal <= 0) { + return []; + } + + $allocations = []; + $remaining = $poolPayout; + $n = count($list); + foreach ($list as $idx => $item) { + $w = (int) $item->total_bet_amount; + if ($idx === $n - 1) { + $share = max(0, $remaining); + } else { + $share = (int) floor($poolPayout * $w / $weightTotal); + $remaining -= $share; + } + if ($share > 0) { + $allocations[(int) $item->id] = $share; + } + } + + return $allocations; } private function gapTriggerMet(JackpotPool $pool): bool diff --git a/app/Services/Jackpot/JackpotManualBurstService.php b/app/Services/Jackpot/JackpotManualBurstService.php new file mode 100644 index 0000000..3e29c93 --- /dev/null +++ b/app/Services/Jackpot/JackpotManualBurstService.php @@ -0,0 +1,264 @@ +whereKey($pool->id)->lockForUpdate()->firstOrFail(); + + if ((int) $locked->status !== 1) { + throw new \RuntimeException('jackpot_disabled'); + } + + if ((int) $locked->current_amount <= 0) { + throw new \RuntimeException('jackpot_pool_empty'); + } + + $draw = Draw::query()->whereKey($drawId)->firstOrFail(); + $this->assertDrawReady($draw); + + if (JackpotPayoutLog::query() + ->where('jackpot_pool_id', $locked->id) + ->where('draw_id', $drawId) + ->exists()) { + throw new \RuntimeException('jackpot_already_burst_for_draw'); + } + + $batch = $this->resolveSettlementBatch($draw); + $winnerItems = $this->firstPrizeWinnerItems($batch); + if ($winnerItems->isEmpty()) { + throw new \RuntimeException('jackpot_manual_no_first_prize_winners'); + } + + $existingJackpot = (int) $batch->total_jackpot_payout_amount; + if ($existingJackpot > 0) { + throw new \RuntimeException('jackpot_already_allocated_for_draw'); + } + + $burst = $this->allocator->burstManual($draw, $locked, $winnerItems); + $poolPayout = (int) $burst['pool_payout']; + if ($poolPayout <= 0) { + return [ + 'current_amount' => (int) $locked->current_amount, + 'burst_amount' => 0, + 'log_id' => null, + 'winner_count' => 0, + 'draw_no' => (string) $draw->draw_no, + 'wallet_credited' => false, + ]; + } + + $allocations = $burst['allocations']; + $this->applyAllocationsToSettlement($batch, $allocations); + + $walletCredited = $this->creditWalletsIfAlreadyPaid($batch, $allocations, (int) $burst['log_id'], $locked->currency_code); + + $locked->refresh(); + + $firstPrizeNumber = $this->drawResults->firstPrizeNumber4dForDraw($draw); + if ($firstPrizeNumber === '') { + $firstPrizeNumber = '----'; + } + + $this->hallRealtime->notifyJackpotBurst( + (int) $draw->id, + (string) $draw->draw_no, + $firstPrizeNumber, + (string) $locked->currency_code, + $poolPayout, + count($allocations), + 'manual', + (int) $locked->current_amount, + ); + $this->hallRealtime->notifyStatusChange($this->hallSnapshot->build()); + + return [ + 'current_amount' => (int) $locked->current_amount, + 'burst_amount' => $poolPayout, + 'log_id' => (int) $burst['log_id'], + 'winner_count' => count($allocations), + 'draw_no' => (string) $draw->draw_no, + 'wallet_credited' => $walletCredited, + ]; + }); + } + + private function assertDrawReady(Draw $draw): void + { + $allowed = [ + DrawStatus::Settling->value, + DrawStatus::Settled->value, + ]; + if (! in_array($draw->status, $allowed, true)) { + throw new \RuntimeException('draw_not_ready_for_jackpot_burst'); + } + + $hasPublished = DrawResultBatch::query() + ->where('draw_id', $draw->id) + ->where('status', DrawResultBatchStatus::Published->value) + ->where('result_version', (int) $draw->current_result_version) + ->exists(); + + if (! $hasPublished) { + throw new \RuntimeException('draw_result_not_published'); + } + } + + private function resolveSettlementBatch(Draw $draw): SettlementBatch + { + $batch = SettlementBatch::query() + ->where('draw_id', $draw->id) + ->whereIn('status', [ + SettlementBatchStatus::PendingReview->value, + SettlementBatchStatus::Approved->value, + SettlementBatchStatus::Paid->value, + SettlementBatchStatus::Completed->value, + ]) + ->orderByDesc('id') + ->first(); + + if ($batch === null) { + throw new \RuntimeException('settlement_batch_not_found'); + } + + return $batch; + } + + /** + * @return Collection + */ + private function firstPrizeWinnerItems(SettlementBatch $batch): Collection + { + $details = TicketSettlementDetail::query() + ->where('settlement_batch_id', $batch->id) + ->where('matched_prize_tier', 'first') + ->where('win_amount', '>', 0) + ->with('ticketItem') + ->get(); + + return $details + ->map(fn (TicketSettlementDetail $d) => $d->ticketItem) + ->filter(fn (?TicketItem $item): bool => $item instanceof TicketItem) + ->values(); + } + + /** + * @param array $allocations + */ + private function applyAllocationsToSettlement(SettlementBatch $batch, array $allocations): void + { + $addedJackpot = 0; + + foreach ($allocations as $ticketItemId => $share) { + $detail = TicketSettlementDetail::query() + ->where('settlement_batch_id', $batch->id) + ->where('ticket_item_id', $ticketItemId) + ->first(); + + if ($detail === null) { + continue; + } + + $detail->forceFill(['jackpot_allocation_amount' => $share])->save(); + + $item = $detail->ticketItem; + if ($item !== null) { + $item->forceFill(['jackpot_win_amount' => $share])->save(); + } + + $addedJackpot += $share; + } + + if ($addedJackpot > 0) { + $batch->forceFill([ + 'total_jackpot_payout_amount' => (int) $batch->total_jackpot_payout_amount + $addedJackpot, + 'total_payout_amount' => (int) $batch->total_payout_amount + $addedJackpot, + ])->save(); + } + } + + /** + * 若结算批次已派彩,则补发 Jackpot 份额到玩家钱包。 + * + * @param array $allocations + */ + private function creditWalletsIfAlreadyPaid( + SettlementBatch $batch, + array $allocations, + int $jackpotLogId, + string $currencyCode, + ): bool { + if (! in_array($batch->status, [SettlementBatchStatus::Paid->value, SettlementBatchStatus::Completed->value], true)) { + return false; + } + + $playerTotals = []; + foreach ($allocations as $ticketItemId => $share) { + if ($share <= 0) { + continue; + } + $item = TicketItem::query()->whereKey($ticketItemId)->first(); + if ($item === null) { + continue; + } + $pid = (int) $item->player_id; + $playerTotals[$pid] = ($playerTotals[$pid] ?? 0) + $share; + } + + foreach ($playerTotals as $playerId => $amount) { + $player = Player::query()->whereKey($playerId)->firstOrFail(); + $this->wallet->creditJackpotManualPayout( + $player, + $currencyCode, + $amount, + (int) $batch->id, + $jackpotLogId, + ); + } + + return $playerTotals !== []; + } +} diff --git a/app/Services/Settlement/SettlementBatchWorkflowService.php b/app/Services/Settlement/SettlementBatchWorkflowService.php index 0a02967..a7812b5 100644 --- a/app/Services/Settlement/SettlementBatchWorkflowService.php +++ b/app/Services/Settlement/SettlementBatchWorkflowService.php @@ -21,7 +21,17 @@ final class SettlementBatchWorkflowService public function approve(SettlementBatch $batch, AdminUser $admin, ?string $remark = null): SettlementBatch { - return DB::transaction(function () use ($batch, $admin, $remark): SettlementBatch { + return $this->approveInternal($batch, $admin->id, $remark); + } + + public function approveBySystem(SettlementBatch $batch, ?string $remark = null): SettlementBatch + { + return $this->approveInternal($batch, null, $remark); + } + + private function approveInternal(SettlementBatch $batch, ?int $reviewedBy, ?string $remark): SettlementBatch + { + return DB::transaction(function () use ($batch, $reviewedBy, $remark): SettlementBatch { /** @var SettlementBatch $locked */ $locked = SettlementBatch::query()->whereKey($batch->id)->lockForUpdate()->firstOrFail(); if ($locked->status !== SettlementBatchStatus::PendingReview->value) { @@ -31,7 +41,7 @@ final class SettlementBatchWorkflowService $locked->forceFill([ 'status' => SettlementBatchStatus::Approved->value, 'review_status' => 'approved', - 'reviewed_by' => $admin->id, + 'reviewed_by' => $reviewedBy, 'reviewed_at' => now(), 'review_remark' => $remark, ])->save(); diff --git a/app/Services/Settlement/SettlementOrchestrator.php b/app/Services/Settlement/SettlementOrchestrator.php index 3875d4d..b88e0e1 100644 --- a/app/Services/Settlement/SettlementOrchestrator.php +++ b/app/Services/Settlement/SettlementOrchestrator.php @@ -13,6 +13,7 @@ use Illuminate\Support\Facades\DB; use App\Lottery\DrawResultBatchStatus; use App\Lottery\SettlementBatchStatus; use App\Models\TicketSettlementDetail; +use App\Services\Draw\DrawHallSnapshotBuilder; use App\Services\Draw\LotteryHallRealtimeBroadcaster; use App\Services\Ticket\RiskPoolService; use App\Services\Jackpot\JackpotBurstAllocator; @@ -30,6 +31,7 @@ final class SettlementOrchestrator private readonly JackpotBurstAllocator $jackpotBurst, private readonly RiskPoolService $riskPool, private readonly LotteryHallRealtimeBroadcaster $hallRealtime, + private readonly DrawHallSnapshotBuilder $hallSnapshot, ) {} /** @@ -214,6 +216,7 @@ final class SettlementOrchestrator $jackpotTrigger, (int) $jackpotPoolAfter, ); + $this->hallRealtime->notifyStatusChange($this->hallSnapshot->build()); } return true; diff --git a/app/Services/Settlement/SettlementTickFinalizer.php b/app/Services/Settlement/SettlementTickFinalizer.php new file mode 100644 index 0000000..6ad5b3e --- /dev/null +++ b/app/Services/Settlement/SettlementTickFinalizer.php @@ -0,0 +1,65 @@ + 0, 'paid' => 0]; + } + + $pending = SettlementBatch::query() + ->where('status', SettlementBatchStatus::PendingReview->value) + ->orderBy('id') + ->get(); + + foreach ($pending as $batch) { + try { + $this->workflow->approveBySystem($batch, 'auto approve on draw tick'); + $approved++; + } catch (\Throwable $e) { + report($e); + + continue; + } + + if (! (bool) LotterySettings::get('settlement.auto_payout_on_tick', true)) { + continue; + } + + try { + $this->workflow->payout($batch->fresh()); + $paid++; + AuditLogger::recordForSystem( + moduleCode: 'settlement', + actionCode: 'auto_payout', + targetType: 'settlement_batch', + targetId: (string) $batch->id, + afterJson: ['draw_id' => (int) $batch->draw_id], + ); + } catch (\Throwable $e) { + report($e); + } + } + + return ['approved' => $approved, 'paid' => $paid]; + } +} diff --git a/app/Services/Ticket/TicketWalletService.php b/app/Services/Ticket/TicketWalletService.php index 3c1abf6..16bf1cd 100644 --- a/app/Services/Ticket/TicketWalletService.php +++ b/app/Services/Ticket/TicketWalletService.php @@ -119,6 +119,71 @@ final class TicketWalletService /** * 结算派彩入账(产品文档:派彩写入钱包流水;幂等键按结算批次 + 玩家)。 */ + /** + * 手动爆池补发:结算批次已派彩后,将 Jackpot 份额追加入账(幂等键按批次 + 玩家 + 爆池日志)。 + */ + public function creditJackpotManualPayout( + Player $player, + string $currencyCode, + int $amountMinor, + int $settlementBatchId, + int $jackpotPayoutLogId, + ): void { + if ($amountMinor <= 0) { + return; + } + + $idempotentKey = 'jackpot-manual:'.$settlementBatchId.':'.$player->id.':'.$jackpotPayoutLogId; + if (WalletTxn::query()->where('idempotent_key', $idempotentKey)->exists()) { + return; + } + + $currency = strtoupper($currencyCode); + + $wallet = PlayerWallet::query() + ->where('player_id', $player->id) + ->where('wallet_type', 'lottery') + ->where('currency_code', $currency) + ->lockForUpdate() + ->first(); + + if ($wallet === null) { + $wallet = PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => $currency, + 'balance' => 0, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + $wallet = PlayerWallet::query()->whereKey($wallet->id)->lockForUpdate()->firstOrFail(); + } + + $before = (int) $wallet->balance; + $after = $before + $amountMinor; + $wallet->forceFill([ + 'balance' => $after, + 'version' => (int) $wallet->version + 1, + ])->save(); + + WalletTxn::query()->create([ + 'txn_no' => $this->newTxnNo(), + 'player_id' => $player->id, + 'wallet_id' => $wallet->id, + 'biz_type' => 'jackpot_manual_payout', + 'biz_no' => 'JP'.$jackpotPayoutLogId, + 'direction' => self::TXN_DIR_IN, + 'amount' => $amountMinor, + 'balance_before' => $before, + 'balance_after' => $after, + 'status' => self::TXN_POSTED, + 'external_ref_no' => null, + 'idempotent_key' => $idempotentKey, + 'remark' => 'manual_jackpot_burst', + ]); + } + public function creditSettlementPayout(Player $player, string $currencyCode, int $amountMinor, int $settlementBatchId): void { if ($amountMinor <= 0) { diff --git a/app/Support/AdminAuthorizationRegistry.php b/app/Support/AdminAuthorizationRegistry.php index abaa60b..5d18c0f 100644 --- a/app/Support/AdminAuthorizationRegistry.php +++ b/app/Support/AdminAuthorizationRegistry.php @@ -20,32 +20,40 @@ final class AdminAuthorizationRegistry public static function permissionDefinitions(): array { return [ + ['slug' => 'prd.dashboard.view', 'name' => '仪表盘·查看', 'nav_segment' => 'dashboard', 'permission_codes' => ['dashboard.view']], + ['slug' => 'prd.admin_user.manage', 'name' => '管理员列表·可管理', 'nav_segment' => 'admin_users', 'permission_codes' => ['system.admin_user.manage']], ['slug' => 'prd.admin_role.manage', 'name' => '角色管理·可管理', 'nav_segment' => 'admin_roles', 'permission_codes' => ['system.admin_role.manage']], ['slug' => 'prd.users.manage', 'name' => '用户管理·可管理', 'nav_segment' => 'players', 'permission_codes' => ['service.players.manage']], ['slug' => 'prd.users.view_finance', 'name' => '用户管理·财务查看', 'nav_segment' => 'players', 'permission_codes' => ['service.players.view', 'service.wallet.view']], - ['slug' => 'prd.users.view_cs', 'name' => '用户管理·客服单用户', 'nav_segment' => 'players', 'permission_codes' => ['service.players.view', 'service.tickets.view']], + ['slug' => 'prd.users.view_cs', 'name' => '用户管理·客服单用户', 'nav_segment' => 'players', 'permission_codes' => ['service.players.view']], + ['slug' => 'prd.tickets.view', 'name' => '玩家注单·查看', 'nav_segment' => 'tickets', 'permission_codes' => ['service.tickets.view']], ['slug' => 'prd.player_freeze.manage', 'name' => '冻结/解冻玩家·可管理', 'nav_segment' => 'players', 'permission_codes' => ['service.players.freeze']], ['slug' => 'prd.currency.manage', 'name' => '币种管理·可管理', 'nav_segment' => 'settings', 'permission_codes' => ['service.currency.manage']], - ['slug' => 'prd.wallet_reconcile.manage', 'name' => '钱包对账·可管理', 'nav_segment' => 'wallet', 'permission_codes' => ['service.wallet.manage', 'service.reconcile.manage']], + ['slug' => 'prd.wallet_reconcile.manage', 'name' => '钱包对账·可管理', 'nav_segment' => 'wallet', 'permission_codes' => ['service.wallet.manage', 'service.reconcile.manage', 'service.wallet.adjust']], ['slug' => 'prd.wallet_reconcile.view', 'name' => '钱包对账·查看', 'nav_segment' => 'wallet', 'permission_codes' => ['service.wallet.view', 'service.reconcile.view']], ['slug' => 'prd.wallet_reconcile.view_cs', 'name' => '钱包对账·客服单用户', 'nav_segment' => 'wallet', 'permission_codes' => ['service.wallet.view', 'service.reconcile.view']], - ['slug' => 'prd.wallet_adjust.manage', 'name' => '补单/冲正·可管理', 'nav_segment' => 'wallet', 'permission_codes' => ['service.wallet.manage']], + ['slug' => 'prd.wallet_adjust.manage', 'name' => '补单/冲正·可管理', 'nav_segment' => 'wallet', 'permission_codes' => ['service.wallet.adjust']], - ['slug' => 'prd.draw_result.manage', 'name' => '开奖结果录入·可管理', 'nav_segment' => 'draws', 'permission_codes' => ['draw.results.view', 'draw.review.review', 'draw.review.publish', 'risk.monitor.view']], - ['slug' => 'prd.draw_result.view', 'name' => '开奖结果·查看', 'nav_segment' => 'draws', 'permission_codes' => ['draw.results.view', 'risk.monitor.view']], + ['slug' => 'prd.draw_result.manage', 'name' => '开奖结果录入·可管理', 'nav_segment' => 'draws', 'permission_codes' => ['draw.results.view', 'draw.review.review', 'draw.review.publish']], + ['slug' => 'prd.draw_result.view', 'name' => '开奖结果·查看', 'nav_segment' => 'draws', 'permission_codes' => ['draw.results.view']], ['slug' => 'prd.draw_reopen.manage', 'name' => '开奖结果重开·可管理', 'nav_segment' => 'draws', 'permission_codes' => ['draw.review.publish']], + ['slug' => 'prd.risk.view', 'name' => '风控中心·查看', 'nav_segment' => 'risk', 'permission_codes' => ['risk.monitor.view']], + ['slug' => 'prd.risk.manage', 'name' => '风控中心·可管理', 'nav_segment' => 'risk', 'permission_codes' => ['risk.monitor.manage']], + ['slug' => 'prd.play_switch.manage', 'name' => '玩法开关·可管理', 'nav_segment' => 'rules_plays', 'permission_codes' => ['config.play.manage']], ['slug' => 'prd.odds.manage', 'name' => '赔率配置·可管理', 'nav_segment' => 'rules_odds', 'permission_codes' => ['config.odds.manage']], + ['slug' => 'prd.odds.view', 'name' => '赔率配置·查看', 'nav_segment' => 'rules_odds', 'permission_codes' => ['config.odds.view']], ['slug' => 'prd.risk_cap.manage', 'name' => '封顶配置·可管理', 'nav_segment' => 'risk_cap', 'permission_codes' => ['config.risk_cap.manage']], ['slug' => 'prd.risk_cap.view', 'name' => '封顶配置·查看', 'nav_segment' => 'risk_cap', 'permission_codes' => ['config.risk_cap.view']], ['slug' => 'prd.rebate.manage', 'name' => '佣金/回水·可管理', 'nav_segment' => 'rules_odds', 'permission_codes' => ['config.odds.manage']], - ['slug' => 'prd.rebate.view', 'name' => '佣金/回水·查看', 'nav_segment' => 'rules_odds', 'permission_codes' => ['config.odds.manage']], + ['slug' => 'prd.rebate.view', 'name' => '佣金/回水·查看', 'nav_segment' => 'rules_odds', 'permission_codes' => ['config.odds.view']], ['slug' => 'prd.jackpot.manage', 'name' => 'Jackpot 配置·可管理', 'nav_segment' => 'jackpot', 'permission_codes' => ['config.jackpot.manage']], ['slug' => 'prd.jackpot.view', 'name' => 'Jackpot 配置·查看', 'nav_segment' => 'jackpot', 'permission_codes' => ['config.jackpot.view']], + ['slug' => 'prd.jackpot.manual_burst', 'name' => 'Jackpot 手动爆池·仅超管', 'nav_segment' => 'jackpot', 'permission_codes' => ['jackpot.pool.manual_burst']], ['slug' => 'prd.payout.manage', 'name' => '派彩确认·可管理', 'nav_segment' => 'settlement', 'permission_codes' => ['settlement.batch.manage', 'settlement.batch.view']], ['slug' => 'prd.payout.review', 'name' => '派彩确认·可审核', 'nav_segment' => 'settlement', 'permission_codes' => ['settlement.batch.review', 'settlement.batch.view']], @@ -54,6 +62,7 @@ final class AdminAuthorizationRegistry ['slug' => 'prd.audit.view', 'name' => '审计日志·查看', 'nav_segment' => 'audit', 'permission_codes' => ['service.audit.view']], ['slug' => 'prd.report.view', 'name' => '报表中心·查看', 'nav_segment' => 'reports', 'permission_codes' => ['service.report.view']], + ['slug' => 'prd.report.export', 'name' => '报表中心·导出', 'nav_segment' => 'reports', 'permission_codes' => ['service.report.export']], ]; } @@ -110,13 +119,13 @@ final class AdminAuthorizationRegistry { return [ // 总览 - ['segment' => 'dashboard', 'label' => 'Dashboard', 'href' => '/admin'], + ['segment' => 'dashboard', 'label' => 'Dashboard', 'href' => '/admin', 'requiredAny' => ['prd.dashboard.view']], // 日常运营:开奖 → 注单 → 玩家 ['segment' => 'draws', 'label' => 'Draws', 'href' => '/admin/draws', 'requiredAny' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.draw_reopen.manage']], - ['segment' => 'tickets', 'label' => 'Tickets', 'href' => '/admin/tickets', 'requiredAny' => ['prd.users.view_cs', 'prd.users.manage', 'prd.users.view_finance', 'prd.draw_result.view', 'prd.draw_result.manage', 'prd.payout.view', 'prd.payout.review', 'prd.payout.manage']], + ['segment' => 'tickets', 'label' => 'Tickets', 'href' => '/admin/tickets', 'requiredAny' => ['prd.tickets.view']], ['segment' => 'players', 'label' => 'Players', 'href' => '/admin/players', 'requiredAny' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.player_freeze.manage']], // 规则与参数 - ['segment' => 'rules_plays', 'label' => 'Play rules', 'href' => '/admin/rules/plays', 'requiredAny' => ['prd.play_switch.manage', 'prd.odds.manage']], + ['segment' => 'rules_plays', 'label' => 'Play rules', 'href' => '/admin/rules/plays', 'requiredAny' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view']], ['segment' => 'rules_odds', 'label' => 'Odds & rebate', 'href' => '/admin/rules/odds', 'requiredAny' => ['prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view']], ['segment' => 'jackpot', 'label' => 'Jackpot', 'href' => '/admin/jackpot', 'activeMatchPrefix' => '/admin/jackpot', 'requiredAny' => ['prd.jackpot.manage', 'prd.jackpot.view']], ['segment' => 'risk_cap', 'label' => 'Risk cap rules', 'href' => '/admin/risk/cap', 'activeMatchPrefix' => '/admin/risk/cap', 'requiredAny' => ['prd.risk_cap.manage', 'prd.risk_cap.view']], @@ -129,7 +138,7 @@ final class AdminAuthorizationRegistry // 权限与系统 ['segment' => 'admin_users', 'label' => 'Admin Users', 'href' => '/admin/admin-users', 'requiredAny' => ['prd.admin_user.manage']], ['segment' => 'admin_roles', 'label' => 'Admin Roles', 'href' => '/admin/admin-roles', 'requiredAny' => ['prd.admin_role.manage']], - ['segment' => 'risk', 'label' => 'Risk', 'href' => '/admin/risk', 'requiredAny' => ['prd.draw_result.view', 'prd.draw_result.manage']], + ['segment' => 'risk', 'label' => 'Risk', 'href' => '/admin/risk', 'requiredAny' => ['prd.risk.view', 'prd.risk.manage']], ['segment' => 'audit', 'label' => 'Audit Logs', 'href' => '/admin/audit-logs', 'requiredAny' => ['prd.audit.view']], ['segment' => 'settings', 'label' => 'Settings', 'href' => '/admin/settings', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.currency.manage']], ]; @@ -185,23 +194,24 @@ final class AdminAuthorizationRegistry private static function permissionSlugsForNavigationSegment(string $segment): array { $explicit = [ + 'dashboard' => ['prd.dashboard.view'], 'admin_users' => ['prd.admin_user.manage'], 'admin_roles' => ['prd.admin_role.manage'], 'players' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.player_freeze.manage'], 'currencies' => ['prd.currency.manage'], 'wallet' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.wallet_adjust.manage', 'prd.users.view_finance'], 'draws' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.draw_reopen.manage'], - 'rules_plays' => ['prd.play_switch.manage', 'prd.odds.manage'], + 'rules_plays' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view'], 'rules_odds' => ['prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view'], 'jackpot' => ['prd.jackpot.manage', 'prd.jackpot.view'], 'risk_cap' => ['prd.risk_cap.manage', 'prd.risk_cap.view'], - 'risk' => ['prd.draw_result.manage', 'prd.draw_result.view'], + 'risk' => ['prd.risk.view', 'prd.risk.manage'], 'settlement' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view'], 'reconcile' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs'], - 'reports' => ['prd.report.view'], - 'tickets' => ['prd.users.view_cs', 'prd.users.manage'], + 'reports' => ['prd.report.view', 'prd.report.export'], + 'tickets' => ['prd.tickets.view'], 'audit' => ['prd.audit.view'], - 'settings' => [], + 'settings' => ['prd.wallet_reconcile.manage', 'prd.currency.manage'], ]; if (isset($explicit[$segment])) { @@ -331,7 +341,8 @@ final class AdminAuthorizationRegistry { return [ ['code' => 'admin.ping', 'module_code' => 'system', 'name' => '后台连通性探测', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/ping', 'route_name' => 'api.v1.admin.ping', 'auth_mode' => 'login_only', 'is_audit_required' => false], - ['code' => 'admin.dashboard', 'module_code' => 'dashboard', 'name' => '后台仪表盘', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/dashboard', 'route_name' => 'api.v1.admin.dashboard', 'auth_mode' => 'login_only', 'is_audit_required' => false], + ['code' => 'admin.dashboard', 'module_code' => 'dashboard', 'name' => '后台仪表盘', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/dashboard', 'route_name' => 'api.v1.admin.dashboard', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['dashboard.view']], + ['code' => 'admin.dashboard.analytics', 'module_code' => 'dashboard', 'name' => '仪表盘分析', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/dashboard/analytics', 'route_name' => 'api.v1.admin.dashboard.analytics', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['dashboard.view']], ['code' => 'admin.auth.me', 'module_code' => 'system', 'name' => '后台当前管理员摘要', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/auth/me', 'route_name' => 'api.v1.admin.auth.me', 'auth_mode' => 'login_only', 'is_audit_required' => false], ['code' => 'admin.audit.index', 'module_code' => 'audit', 'name' => '审计日志查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/audit-logs', 'route_name' => 'api.v1.admin.audit-logs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.audit.view']], @@ -349,26 +360,26 @@ final class AdminAuthorizationRegistry ['code' => 'admin.admin-roles.destroy', 'module_code' => 'system', 'name' => '删除角色', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/admin-roles/{admin_role}', 'route_name' => 'api.v1.admin.admin-roles.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_role.manage']], ['code' => 'admin.admin-roles.permissions.sync', 'module_code' => 'system', 'name' => '角色权限同步', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/admin-roles/{admin_role}/permissions', 'route_name' => 'api.v1.admin.admin-roles.permissions.sync', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_role.manage']], - ['code' => 'admin.play-types.index', 'module_code' => 'config', 'name' => '玩法类型列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/play-types', 'route_name' => 'api.v1.admin.play-types.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage']], - ['code' => 'admin.play-types.patch', 'module_code' => 'config', 'name' => '玩法类型切换', 'http_method' => 'PATCH', 'uri_pattern' => '/api/v1/admin/play-types/{play_code}', 'route_name' => 'api.v1.admin.play-types.patch', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']], - ['code' => 'admin.config.play-versions.index', 'module_code' => 'config', 'name' => '玩法版本列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/play-versions', 'route_name' => 'api.v1.admin.config.play-versions.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage']], - ['code' => 'admin.config.play-versions.show', 'module_code' => 'config', 'name' => '玩法版本详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/play-versions/{id}', 'route_name' => 'api.v1.admin.config.play-versions.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage']], - ['code' => 'admin.config.play-versions.store', 'module_code' => 'config', 'name' => '创建玩法版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/play-versions', 'route_name' => 'api.v1.admin.config.play-versions.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']], - ['code' => 'admin.config.play-versions.items.replace', 'module_code' => 'config', 'name' => '替换玩法版本条目', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/config/play-versions/{id}/items', 'route_name' => 'api.v1.admin.config.play-versions.items.replace', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']], - ['code' => 'admin.config.play-versions.publish', 'module_code' => 'config', 'name' => '发布玩法版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/play-versions/{id}/publish', 'route_name' => 'api.v1.admin.config.play-versions.publish', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']], - ['code' => 'admin.config.play-versions.destroy', 'module_code' => 'config', 'name' => '删除玩法版本', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/config/play-versions/{id}', 'route_name' => 'api.v1.admin.config.play-versions.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']], - ['code' => 'admin.config.odds-versions.index', 'module_code' => 'config', 'name' => '赔率版本列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/odds-versions', 'route_name' => 'api.v1.admin.config.odds-versions.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view']], - ['code' => 'admin.config.odds-versions.show', 'module_code' => 'config', 'name' => '赔率版本详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/odds-versions/{id}', 'route_name' => 'api.v1.admin.config.odds-versions.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view']], - ['code' => 'admin.config.odds-versions.store', 'module_code' => 'config', 'name' => '创建赔率版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/odds-versions', 'route_name' => 'api.v1.admin.config.odds-versions.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']], - ['code' => 'admin.config.odds-versions.items.replace', 'module_code' => 'config', 'name' => '替换赔率版本条目', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/config/odds-versions/{id}/items', 'route_name' => 'api.v1.admin.config.odds-versions.items.replace', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']], - ['code' => 'admin.config.odds-versions.publish', 'module_code' => 'config', 'name' => '发布赔率版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/odds-versions/{id}/publish', 'route_name' => 'api.v1.admin.config.odds-versions.publish', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']], - ['code' => 'admin.config.odds-versions.destroy', 'module_code' => 'config', 'name' => '删除赔率版本', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/config/odds-versions/{id}', 'route_name' => 'api.v1.admin.config.odds-versions.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']], - ['code' => 'admin.config.risk-cap-versions.index', 'module_code' => 'config', 'name' => '封顶版本列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions', 'route_name' => 'api.v1.admin.config.risk-cap-versions.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.risk_cap.manage', 'prd.risk_cap.view']], - ['code' => 'admin.config.risk-cap-versions.show', 'module_code' => 'config', 'name' => '封顶版本详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions/{id}', 'route_name' => 'api.v1.admin.config.risk-cap-versions.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.risk_cap.manage', 'prd.risk_cap.view']], - ['code' => 'admin.config.risk-cap-versions.store', 'module_code' => 'config', 'name' => '创建封顶版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions', 'route_name' => 'api.v1.admin.config.risk-cap-versions.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']], - ['code' => 'admin.config.risk-cap-versions.items.replace', 'module_code' => 'config', 'name' => '替换封顶版本条目', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions/{id}/items', 'route_name' => 'api.v1.admin.config.risk-cap-versions.items.replace', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']], - ['code' => 'admin.config.risk-cap-versions.publish', 'module_code' => 'config', 'name' => '发布封顶版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions/{id}/publish', 'route_name' => 'api.v1.admin.config.risk-cap-versions.publish', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']], - ['code' => 'admin.config.risk-cap-versions.destroy', 'module_code' => 'config', 'name' => '删除封顶版本', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions/{id}', 'route_name' => 'api.v1.admin.config.risk-cap-versions.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']], + ['code' => 'admin.play-types.index', 'module_code' => 'config', 'name' => '玩法类型列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/play-types', 'route_name' => 'api.v1.admin.play-types.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view', 'prd.rebate.manage', 'prd.rebate.view']], + ['code' => 'admin.play-types.patch', 'module_code' => 'config', 'name' => '玩法类型切换', 'http_method' => 'PATCH', 'uri_pattern' => '/api/v1/admin/play-types/{play_code}', 'route_name' => 'api.v1.admin.play-types.patch', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage']], + ['code' => 'admin.config.play-versions.index', 'module_code' => 'config', 'name' => '玩法版本列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/play-versions', 'route_name' => 'api.v1.admin.config.play-versions.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view']], + ['code' => 'admin.config.play-versions.show', 'module_code' => 'config', 'name' => '玩法版本详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/play-versions/{id}', 'route_name' => 'api.v1.admin.config.play-versions.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view']], + ['code' => 'admin.config.play-versions.store', 'module_code' => 'config', 'name' => '创建玩法版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/play-versions', 'route_name' => 'api.v1.admin.config.play-versions.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage']], + ['code' => 'admin.config.play-versions.items.replace', 'module_code' => 'config', 'name' => '替换玩法版本条目', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/config/play-versions/{id}/items', 'route_name' => 'api.v1.admin.config.play-versions.items.replace', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage']], + ['code' => 'admin.config.play-versions.publish', 'module_code' => 'config', 'name' => '发布玩法版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/play-versions/{id}/publish', 'route_name' => 'api.v1.admin.config.play-versions.publish', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage']], + ['code' => 'admin.config.play-versions.destroy', 'module_code' => 'config', 'name' => '删除玩法版本', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/config/play-versions/{id}', 'route_name' => 'api.v1.admin.config.play-versions.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage']], + ['code' => 'admin.config.odds-versions.index', 'module_code' => 'config', 'name' => '赔率版本列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/odds-versions', 'route_name' => 'api.v1.admin.config.odds-versions.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['config.odds.manage', 'config.odds.view']], + ['code' => 'admin.config.odds-versions.show', 'module_code' => 'config', 'name' => '赔率版本详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/odds-versions/{id}', 'route_name' => 'api.v1.admin.config.odds-versions.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['config.odds.manage', 'config.odds.view']], + ['code' => 'admin.config.odds-versions.store', 'module_code' => 'config', 'name' => '创建赔率版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/odds-versions', 'route_name' => 'api.v1.admin.config.odds-versions.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.odds.manage', 'prd.rebate.manage']], + ['code' => 'admin.config.odds-versions.items.replace', 'module_code' => 'config', 'name' => '替换赔率版本条目', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/config/odds-versions/{id}/items', 'route_name' => 'api.v1.admin.config.odds-versions.items.replace', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.odds.manage', 'prd.rebate.manage']], + ['code' => 'admin.config.odds-versions.publish', 'module_code' => 'config', 'name' => '发布赔率版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/odds-versions/{id}/publish', 'route_name' => 'api.v1.admin.config.odds-versions.publish', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.odds.manage', 'prd.rebate.manage']], + ['code' => 'admin.config.odds-versions.destroy', 'module_code' => 'config', 'name' => '删除赔率版本', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/config/odds-versions/{id}', 'route_name' => 'api.v1.admin.config.odds-versions.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.odds.manage', 'prd.rebate.manage']], + ['code' => 'admin.config.risk-cap-versions.index', 'module_code' => 'config', 'name' => '封顶版本列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions', 'route_name' => 'api.v1.admin.config.risk-cap-versions.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['config.risk_cap.manage', 'config.risk_cap.view']], + ['code' => 'admin.config.risk-cap-versions.show', 'module_code' => 'config', 'name' => '封顶版本详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions/{id}', 'route_name' => 'api.v1.admin.config.risk-cap-versions.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['config.risk_cap.manage', 'config.risk_cap.view']], + ['code' => 'admin.config.risk-cap-versions.store', 'module_code' => 'config', 'name' => '创建封顶版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions', 'route_name' => 'api.v1.admin.config.risk-cap-versions.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.risk_cap.manage']], + ['code' => 'admin.config.risk-cap-versions.items.replace', 'module_code' => 'config', 'name' => '替换封顶版本条目', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions/{id}/items', 'route_name' => 'api.v1.admin.config.risk-cap-versions.items.replace', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.risk_cap.manage']], + ['code' => 'admin.config.risk-cap-versions.publish', 'module_code' => 'config', 'name' => '发布封顶版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions/{id}/publish', 'route_name' => 'api.v1.admin.config.risk-cap-versions.publish', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.risk_cap.manage']], + ['code' => 'admin.config.risk-cap-versions.destroy', 'module_code' => 'config', 'name' => '删除封顶版本', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions/{id}', 'route_name' => 'api.v1.admin.config.risk-cap-versions.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.risk_cap.manage']], ['code' => 'admin.settings.index', 'module_code' => 'settings', 'name' => '系统设置列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settings', 'route_name' => 'api.v1.admin.settings.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage']], ['code' => 'admin.settings.update', 'module_code' => 'settings', 'name' => '系统设置更新', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/settings/{key}', 'route_name' => 'api.v1.admin.settings.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage']], ['code' => 'admin.currencies.index', 'module_code' => 'settings', 'name' => '币种列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/currencies', 'route_name' => 'api.v1.admin.currencies.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.currency.manage']], @@ -380,16 +391,16 @@ final class AdminAuthorizationRegistry ['code' => 'admin.draws.show', 'module_code' => 'draw', 'name' => '期开奖详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}', 'route_name' => 'api.v1.admin.draws.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']], ['code' => 'admin.draws.finance-summary', 'module_code' => 'draw', 'name' => '期开奖资金摘要', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/finance-summary', 'route_name' => 'api.v1.admin.draws.finance-summary', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']], ['code' => 'admin.draws.result-batches.index', 'module_code' => 'draw', 'name' => '开奖结果批次列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/result-batches', 'route_name' => 'api.v1.admin.draws.result-batches.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']], - ['code' => 'admin.draws.risk-pool-lock-logs.index', 'module_code' => 'risk', 'name' => '风控锁池日志列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pool-lock-logs', 'route_name' => 'api.v1.admin.draws.risk-pool-lock-logs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']], - ['code' => 'admin.draws.risk-pools.index', 'module_code' => 'risk', 'name' => '风控池列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pools', 'route_name' => 'api.v1.admin.draws.risk-pools.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']], - ['code' => 'admin.draws.risk-pools.show', 'module_code' => 'risk', 'name' => '风控池详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pools/{number_4d}', 'route_name' => 'api.v1.admin.draws.risk-pools.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']], + ['code' => 'admin.draws.risk-pool-lock-logs.index', 'module_code' => 'risk', 'name' => '风控锁池日志列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pool-lock-logs', 'route_name' => 'api.v1.admin.draws.risk-pool-lock-logs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.risk.view', 'prd.risk.manage']], + ['code' => 'admin.draws.risk-pools.index', 'module_code' => 'risk', 'name' => '风控池列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pools', 'route_name' => 'api.v1.admin.draws.risk-pools.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.risk.view', 'prd.risk.manage']], + ['code' => 'admin.draws.risk-pools.show', 'module_code' => 'risk', 'name' => '风控池详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pools/{number_4d}', 'route_name' => 'api.v1.admin.draws.risk-pools.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.risk.view', 'prd.risk.manage']], ['code' => 'admin.draws.result-batches.store', 'module_code' => 'draw', 'name' => '创建开奖结果批次', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/result-batches', 'route_name' => 'api.v1.admin.draws.result-batches.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']], ['code' => 'admin.draws.result-batches.publish', 'module_code' => 'draw', 'name' => '发布开奖结果批次', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/result-batches/{batch}/publish', 'route_name' => 'api.v1.admin.draws.result-batches.publish', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']], ['code' => 'admin.draws.reopen', 'module_code' => 'draw', 'name' => '重开开奖', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/reopen', 'route_name' => 'api.v1.admin.draws.reopen', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_reopen.manage']], ['code' => 'admin.draws.generate-plan', 'module_code' => 'draw', 'name' => '生成开奖计划', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/generate-plan', 'route_name' => 'api.v1.admin.draws.generate-plan', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']], ['code' => 'admin.draws.manual-close', 'module_code' => 'draw', 'name' => '人工封盘', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/manual-close', 'route_name' => 'api.v1.admin.draws.manual-close', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']], - ['code' => 'admin.draws.risk-pools.manual-close', 'module_code' => 'risk', 'name' => '人工关闭风控池', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pools/{number_4d}/manual-close', 'route_name' => 'api.v1.admin.draws.risk-pools.manual-close', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']], - ['code' => 'admin.draws.risk-pools.recover', 'module_code' => 'risk', 'name' => '恢复风控池', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pools/{number_4d}/recover', 'route_name' => 'api.v1.admin.draws.risk-pools.recover', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']], + ['code' => 'admin.draws.risk-pools.manual-close', 'module_code' => 'risk', 'name' => '人工关闭风控池', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pools/{number_4d}/manual-close', 'route_name' => 'api.v1.admin.draws.risk-pools.manual-close', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.risk.manage']], + ['code' => 'admin.draws.risk-pools.recover', 'module_code' => 'risk', 'name' => '恢复风控池', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pools/{number_4d}/recover', 'route_name' => 'api.v1.admin.draws.risk-pools.recover', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.risk.manage']], ['code' => 'admin.draws.cancel', 'module_code' => 'draw', 'name' => '取消开奖', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/cancel', 'route_name' => 'api.v1.admin.draws.cancel', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']], ['code' => 'admin.draws.rng', 'module_code' => 'draw', 'name' => '执行开奖 RNG', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/rng', 'route_name' => 'api.v1.admin.draws.rng', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']], ['code' => 'admin.draws.settlement.run', 'module_code' => 'settlement', 'name' => '执行结算', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/settlement/run', 'route_name' => 'api.v1.admin.draws.settlement.run', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.payout.manage', 'prd.payout.review']], @@ -406,7 +417,7 @@ 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.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.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']], ['code' => 'admin.players.store', 'module_code' => 'player_service', 'name' => '创建玩家', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/players', 'route_name' => 'api.v1.admin.players.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.users.manage']], @@ -417,11 +428,12 @@ final class AdminAuthorizationRegistry ['code' => 'admin.players.unfreeze', 'module_code' => 'player_service', 'name' => '解冻玩家', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/players/{player}/unfreeze', 'route_name' => 'api.v1.admin.players.unfreeze', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.freeze']], ['code' => 'admin.players.wallets', 'module_code' => 'player_service', 'name' => '玩家钱包查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players/{player}/wallets', 'route_name' => 'api.v1.admin.players.wallets', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.players.manage', 'service.wallet.view']], ['code' => 'admin.players.ticket-items', 'module_code' => 'player_service', 'name' => '玩家注单查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players/{player}/ticket-items', 'route_name' => 'api.v1.admin.players.ticket-items.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.players.manage', 'service.tickets.view']], + ['code' => 'admin.tickets.index', 'module_code' => 'ticket', 'name' => '后台注单列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/tickets', 'route_name' => 'api.v1.admin.tickets.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.tickets.view']], - ['code' => 'admin.wallet.transfer-orders', 'module_code' => 'wallet', 'name' => '转账单查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/wallet/transfer-orders', 'route_name' => 'api.v1.admin.wallet.transfer-orders', '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', 'module_code' => 'wallet', 'name' => '转账单查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/wallet/transfer-orders', 'route_name' => 'api.v1.admin.wallet.transfer-orders', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.wallet_adjust.manage', 'prd.users.manage', 'prd.users.view_finance']], ['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_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_reconcile.manage']], + ['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.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']], @@ -432,9 +444,9 @@ final class AdminAuthorizationRegistry ['code' => 'admin.reports.play-dimension', 'module_code' => 'report', 'name' => '玩法维度报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/play-dimension', 'route_name' => 'api.v1.admin.reports.play-dimension', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']], ['code' => 'admin.reports.rebate-commission', 'module_code' => 'report', 'name' => '佣金回水报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/rebate-commission', 'route_name' => 'api.v1.admin.reports.rebate-commission', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']], ['code' => 'admin.report-jobs.index', 'module_code' => 'report', 'name' => '报表任务列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs', 'route_name' => 'api.v1.admin.report-jobs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']], - ['code' => 'admin.report-jobs.store', 'module_code' => 'report', 'name' => '创建报表任务', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/report-jobs', 'route_name' => 'api.v1.admin.report-jobs.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.report.view']], - ['code' => 'admin.report-jobs.show', 'module_code' => 'report', 'name' => '报表任务详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs/{report_job}', 'route_name' => 'api.v1.admin.report-jobs.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']], - ['code' => 'admin.report-jobs.download', 'module_code' => 'report', 'name' => '下载报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs/{report_job}/download', 'route_name' => 'api.v1.admin.report-jobs.download', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']], + ['code' => 'admin.report-jobs.store', 'module_code' => 'report', 'name' => '创建报表任务', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/report-jobs', 'route_name' => 'api.v1.admin.report-jobs.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.report.export']], + ['code' => 'admin.report-jobs.show', 'module_code' => 'report', 'name' => '报表任务详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs/{report_job}', 'route_name' => 'api.v1.admin.report-jobs.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view', 'service.report.export']], + ['code' => 'admin.report-jobs.download', 'module_code' => 'report', 'name' => '下载报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs/{report_job}/download', 'route_name' => 'api.v1.admin.report-jobs.download', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.export']], ]; } diff --git a/app/Support/AdminConfigPresenter.php b/app/Support/AdminConfigPresenter.php index 8001490..2216a84 100644 --- a/app/Support/AdminConfigPresenter.php +++ b/app/Support/AdminConfigPresenter.php @@ -22,9 +22,7 @@ final class AdminConfigPresenter 'category' => $t->category, 'dimension' => $t->dimension, 'bet_mode' => $t->bet_mode, - 'display_name_zh' => $t->display_name_zh, - 'display_name_en' => $t->display_name_en, - 'display_name_ne' => $t->display_name_ne, + 'display_name' => $t->display_name, 'is_enabled' => (bool) $t->is_enabled, 'sort_order' => (int) $t->sort_order, 'supports_multi_number' => (bool) $t->supports_multi_number, @@ -66,9 +64,7 @@ final class AdminConfigPresenter 'category' => $r->category, 'dimension' => $r->dimension === null ? null : (int) $r->dimension, 'bet_mode' => $r->bet_mode, - 'display_name_zh' => $r->display_name_zh, - 'display_name_en' => $r->display_name_en, - 'display_name_ne' => $r->display_name_ne, + 'display_name' => $r->display_name, 'is_enabled' => (bool) $r->is_enabled, 'min_bet_amount' => (int) $r->min_bet_amount, 'max_bet_amount' => (int) $r->max_bet_amount, diff --git a/bootstrap/app.php b/bootstrap/app.php index a31e532..b688939 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -16,6 +16,7 @@ use App\Support\LotteryLocale; use Illuminate\Foundation\Application; use App\Http\Middleware\EnsureAdminApi; use App\Http\Middleware\EnsurePlayerApi; +use App\Http\Middleware\RecordAdminApiAudit; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Auth\AuthenticationException; use App\Http\Middleware\EnsureAdminApiResourcePermission; @@ -48,6 +49,7 @@ return Application::configure(basePath: dirname(__DIR__)) // 后台 API 预留:Sanctum / RBAC 'lottery.admin' => EnsureAdminApi::class, 'admin.api-resource' => EnsureAdminApiResourcePermission::class, + 'admin.audit' => RecordAdminApiAudit::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/database/migrations/2026_05_13_100000_rebuild_admin_authorization_system.php b/database/migrations/2026_05_13_100000_rebuild_admin_authorization_system.php index 36c47f6..c841a57 100644 --- a/database/migrations/2026_05_13_100000_rebuild_admin_authorization_system.php +++ b/database/migrations/2026_05_13_100000_rebuild_admin_authorization_system.php @@ -297,7 +297,6 @@ return new class extends Migration $actionIds = DB::table('admin_action_catalog')->pluck('id', 'code'); $menuActions = [ - ['menu_code' => 'dashboard', 'action_code' => 'view', 'permission_code' => 'dashboard.view', 'name' => '仪表盘查看'], ['menu_code' => 'draw.results', 'action_code' => 'view', 'permission_code' => 'draw.results.view', 'name' => '开奖结果查看'], ['menu_code' => 'draw.review', 'action_code' => 'review', 'permission_code' => 'draw.review.review', 'name' => '开奖审核'], ['menu_code' => 'draw.review', 'action_code' => 'publish', 'permission_code' => 'draw.review.publish', 'name' => '开奖发布'], @@ -340,7 +339,7 @@ return new class extends Migration private function seedApiResources(Carbon $now): void { $resources = [ - ['code' => 'admin.dashboard', 'module_code' => 'dashboard', 'name' => '后台仪表盘', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/dashboard', 'route_name' => 'api.v1.admin.dashboard', 'auth_mode' => 'login_only', 'is_audit_required' => false, 'permission_codes' => ['dashboard.view']], + ['code' => 'admin.dashboard', 'module_code' => 'dashboard', 'name' => '后台仪表盘', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/dashboard', 'route_name' => 'api.v1.admin.dashboard', 'auth_mode' => 'login_only', 'is_audit_required' => false, 'permission_codes' => []], ['code' => 'admin.draws.index', 'module_code' => 'draw', 'name' => '期开奖列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws', 'route_name' => 'api.v1.admin.draws.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['draw.results.view']], ['code' => 'admin.draws.show', 'module_code' => 'draw', 'name' => '期开奖详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}', 'route_name' => 'api.v1.admin.draws.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['draw.results.view']], ['code' => 'admin.draws.publish', 'module_code' => 'draw', 'name' => '开奖发布', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/result-batches/{batch}/publish', 'route_name' => 'api.v1.admin.draws.result-batches.publish', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['draw.review.publish']], @@ -454,7 +453,7 @@ return new class extends Migration foreach ($legacyRoles as $role) { $roleId = (int) $role->id; - $grantedPermissions = ['dashboard.view' => true]; + $grantedPermissions = []; foreach ($legacyRolePermissions->get((int) $role->id, collect()) as $pivot) { $permissionId = (int) $pivot->permission_id; $legacySlug = $legacyPermissionById[$permissionId] ?? null; diff --git a/database/migrations/2026_05_25_120000_consolidate_play_display_name_columns.php b/database/migrations/2026_05_25_120000_consolidate_play_display_name_columns.php new file mode 100644 index 0000000..300db05 --- /dev/null +++ b/database/migrations/2026_05_25_120000_consolidate_play_display_name_columns.php @@ -0,0 +1,54 @@ +string('display_name', 64)->nullable()->after('bet_mode'); + }); + + DB::table($table)->update([ + 'display_name' => DB::raw( + "COALESCE( + NULLIF(TRIM(display_name_zh), ''), + NULLIF(TRIM(display_name_en), ''), + NULLIF(TRIM(display_name_ne), ''), + play_code + )", + ), + ]); + + Schema::table($table, function (Blueprint $blueprint): void { + $blueprint->dropColumn(['display_name_zh', 'display_name_en', 'display_name_ne']); + }); + } + } + + public function down(): void + { + foreach (['play_types', 'play_config_items'] as $table) { + Schema::table($table, function (Blueprint $blueprint): void { + $blueprint->string('display_name_zh', 64)->nullable()->after('bet_mode'); + $blueprint->string('display_name_en', 64)->nullable()->after('display_name_zh'); + $blueprint->string('display_name_ne', 64)->nullable()->after('display_name_en'); + }); + + DB::table($table)->update([ + 'display_name_zh' => DB::raw('display_name'), + 'display_name_en' => DB::raw('display_name'), + 'display_name_ne' => DB::raw('display_name'), + ]); + + Schema::table($table, function (Blueprint $blueprint): void { + $blueprint->dropColumn('display_name'); + }); + } + } +}; diff --git a/database/migrations/2026_05_25_120000_refine_admin_permission_granularity.php b/database/migrations/2026_05_25_120000_refine_admin_permission_granularity.php new file mode 100644 index 0000000..96ec8e2 --- /dev/null +++ b/database/migrations/2026_05_25_120000_refine_admin_permission_granularity.php @@ -0,0 +1,62 @@ +pluck('id', 'code'); + $menuIds = DB::table('admin_menus')->pluck('id', 'code'); + + $walletMenuId = $menuIds['service.wallet'] ?? null; + $updateActionId = $actionIds['update'] ?? null; + if ($walletMenuId !== null && $updateActionId !== null) { + $exists = DB::table('admin_menu_actions') + ->where('permission_code', 'service.wallet.adjust') + ->exists(); + + if (! $exists) { + DB::table('admin_menu_actions')->insert([ + 'menu_id' => $walletMenuId, + 'action_id' => $updateActionId, + 'permission_code' => 'service.wallet.adjust', + 'name' => '钱包补单/冲正', + 'status' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + $oddsMenuId = $menuIds['config.odds'] ?? null; + $viewActionId = $actionIds['view'] ?? null; + if ($oddsMenuId !== null && $viewActionId !== null) { + $exists = DB::table('admin_menu_actions') + ->where('permission_code', 'config.odds.view') + ->exists(); + + if (! $exists) { + DB::table('admin_menu_actions')->insert([ + 'menu_id' => $oddsMenuId, + 'action_id' => $viewActionId, + 'permission_code' => 'config.odds.view', + 'name' => '赔率配置查看', + 'status' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + } + + public function down(): void + { + DB::table('admin_menu_actions') + ->whereIn('permission_code', ['service.wallet.adjust', 'config.odds.view']) + ->delete(); + } +}; diff --git a/database/migrations/2026_05_25_130000_remove_stale_admin_menu_actions.php b/database/migrations/2026_05_25_130000_remove_stale_admin_menu_actions.php new file mode 100644 index 0000000..5a55b0f --- /dev/null +++ b/database/migrations/2026_05_25_130000_remove_stale_admin_menu_actions.php @@ -0,0 +1,48 @@ + */ + private const STALE_PERMISSION_CODES = [ + 'dashboard.view', + 'service.reports.view', + 'service.reports.export', + ]; + + public function up(): void + { + $menuActionIds = DB::table('admin_menu_actions') + ->whereIn('permission_code', self::STALE_PERMISSION_CODES) + ->pluck('id'); + + if ($menuActionIds->isNotEmpty()) { + DB::table('admin_menu_actions') + ->whereIn('id', $menuActionIds->all()) + ->delete(); + } + + $staleReportMenuId = DB::table('admin_menus') + ->where('code', 'service.reports') + ->value('id'); + + if ($staleReportMenuId !== null) { + $hasActions = DB::table('admin_menu_actions') + ->where('menu_id', (int) $staleReportMenuId) + ->exists(); + + if (! $hasActions) { + DB::table('admin_menus') + ->where('id', (int) $staleReportMenuId) + ->delete(); + } + } + } + + public function down(): void + { + // 数据清理迁移,不回滚以免再现僵尸 permission_code。 + } +}; diff --git a/database/migrations/2026_05_25_140000_add_admin_dashboard_analytics_resource.php b/database/migrations/2026_05_25_140000_add_admin_dashboard_analytics_resource.php new file mode 100644 index 0000000..4e45af8 --- /dev/null +++ b/database/migrations/2026_05_25_140000_add_admin_dashboard_analytics_resource.php @@ -0,0 +1,94 @@ +where('permission_code', 'dashboard.view') + ->value('id'); + + if ($menuActionId === null) { + return; + } + + $resource = collect(AdminAuthorizationRegistry::resources()) + ->firstWhere('code', 'admin.dashboard.analytics'); + + if ($resource === null) { + return; + } + + $resourceId = DB::table('admin_api_resources') + ->where('code', 'admin.dashboard.analytics') + ->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' => 'admin.dashboard.analytics', + '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) { + $actionId = DB::table('admin_menu_actions') + ->where('permission_code', $permissionCode) + ->value('id'); + if ($actionId === null) { + continue; + } + + DB::table('admin_api_resource_bindings')->insert([ + 'api_resource_id' => (int) $resourceId, + 'menu_action_id' => (int) $actionId, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + public function down(): void + { + $resourceId = DB::table('admin_api_resources') + ->where('code', 'admin.dashboard.analytics') + ->value('id'); + + if ($resourceId === null) { + return; + } + + DB::table('admin_api_resource_bindings') + ->where('api_resource_id', (int) $resourceId) + ->delete(); + DB::table('admin_api_resources') + ->where('id', (int) $resourceId) + ->delete(); + } +}; diff --git a/database/migrations/2026_05_25_180000_add_settlement_batch_review_columns.php b/database/migrations/2026_05_25_180000_add_settlement_batch_review_columns.php new file mode 100644 index 0000000..3d0882e --- /dev/null +++ b/database/migrations/2026_05_25_180000_add_settlement_batch_review_columns.php @@ -0,0 +1,51 @@ +string('review_status', 32)->default('pending')->after('total_jackpot_payout_amount'); + } + if (! Schema::hasColumn('settlement_batches', 'reviewed_by')) { + $table->foreignId('reviewed_by')->nullable()->after('review_status')->constrained('admin_users')->nullOnDelete(); + } + if (! Schema::hasColumn('settlement_batches', 'reviewed_at')) { + $table->timestamp('reviewed_at')->nullable()->after('reviewed_by'); + } + if (! Schema::hasColumn('settlement_batches', 'review_remark')) { + $table->string('review_remark', 255)->nullable()->after('reviewed_at'); + } + if (! Schema::hasColumn('settlement_batches', 'paid_at')) { + $table->timestamp('paid_at')->nullable()->after('review_remark'); + } + }); + } + + public function down(): void + { + Schema::table('settlement_batches', function (Blueprint $table): void { + if (Schema::hasColumn('settlement_batches', 'paid_at')) { + $table->dropColumn('paid_at'); + } + if (Schema::hasColumn('settlement_batches', 'review_remark')) { + $table->dropColumn('review_remark'); + } + if (Schema::hasColumn('settlement_batches', 'reviewed_at')) { + $table->dropColumn('reviewed_at'); + } + if (Schema::hasColumn('settlement_batches', 'reviewed_by')) { + $table->dropForeign(['reviewed_by']); + $table->dropColumn('reviewed_by'); + } + if (Schema::hasColumn('settlement_batches', 'review_status')) { + $table->dropColumn('review_status'); + } + }); + } +}; diff --git a/database/migrations/2026_05_26_100000_expand_admin_permission_granularity.php b/database/migrations/2026_05_26_100000_expand_admin_permission_granularity.php new file mode 100644 index 0000000..cd36f62 --- /dev/null +++ b/database/migrations/2026_05_26_100000_expand_admin_permission_granularity.php @@ -0,0 +1,216 @@ + */ + private const NEW_MENU_ACTIONS = [ + ['menu_code' => 'dashboard', 'action_code' => 'view', 'permission_code' => 'dashboard.view', 'name' => '仪表盘查看'], + ['menu_code' => 'service.report', 'action_code' => 'export', 'permission_code' => 'service.report.export', 'name' => '报表导出'], + ['menu_code' => 'risk.monitor', 'action_code' => 'manage', 'permission_code' => 'risk.monitor.manage', 'name' => '风控监控管理'], + ]; + + public function up(): void + { + $now = Carbon::now(); + $menuIds = DB::table('admin_menus')->pluck('id', 'code'); + $actionIds = DB::table('admin_action_catalog')->pluck('id', 'code'); + + $menuActionIds = []; + foreach (self::NEW_MENU_ACTIONS as $row) { + $menuId = $menuIds[$row['menu_code']] ?? null; + $actionId = $actionIds[$row['action_code']] ?? null; + if ($menuId === null || $actionId === null) { + continue; + } + + $exists = DB::table('admin_menu_actions') + ->where('permission_code', $row['permission_code']) + ->exists(); + + if ($exists) { + $menuActionIds[$row['permission_code']] = (int) DB::table('admin_menu_actions') + ->where('permission_code', $row['permission_code']) + ->value('id'); + + continue; + } + + $menuActionIds[$row['permission_code']] = (int) DB::table('admin_menu_actions')->insertGetId([ + 'menu_id' => (int) $menuId, + 'action_id' => (int) $actionId, + 'permission_code' => $row['permission_code'], + 'name' => $row['name'], + 'status' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + $this->grantMenuActionsToAllRoles($menuActionIds, $now); + $this->grantReportExportToReportViewRoles($menuActionIds['service.report.export'] ?? null, $now); + $this->grantTicketsViewToLegacyRoles($menuActionIds, $now); + } + + public function down(): void + { + $codes = array_column(self::NEW_MENU_ACTIONS, 'permission_code'); + $ids = DB::table('admin_menu_actions')->whereIn('permission_code', $codes)->pluck('id'); + foreach ($ids as $id) { + DB::table('admin_role_menu_actions')->where('menu_action_id', (int) $id)->delete(); + DB::table('admin_user_menu_actions')->where('menu_action_id', (int) $id)->delete(); + DB::table('admin_api_resource_bindings')->where('menu_action_id', (int) $id)->delete(); + DB::table('admin_menu_actions')->where('id', (int) $id)->delete(); + } + } + + /** + * @param array $menuActionIds + */ + private function grantMenuActionsToAllRoles(array $menuActionIds, Carbon $now): void + { + $dashboardId = $menuActionIds['dashboard.view'] ?? null; + if ($dashboardId === null) { + return; + } + + $roleIds = DB::table('admin_roles')->pluck('id'); + foreach ($roleIds as $roleId) { + DB::table('admin_role_menu_actions')->updateOrInsert([ + 'role_id' => (int) $roleId, + 'menu_action_id' => $dashboardId, + ]); + } + } + + private function grantReportExportToReportViewRoles(?int $exportMenuActionId, Carbon $now): void + { + if ($exportMenuActionId === null) { + return; + } + + $viewMenuActionId = DB::table('admin_menu_actions') + ->where('permission_code', 'service.report.view') + ->value('id'); + + if ($viewMenuActionId === null) { + return; + } + + $roleIds = DB::table('admin_role_menu_actions') + ->where('menu_action_id', (int) $viewMenuActionId) + ->distinct() + ->pluck('role_id'); + + foreach ($roleIds as $roleId) { + DB::table('admin_role_menu_actions')->updateOrInsert([ + 'role_id' => (int) $roleId, + 'menu_action_id' => $exportMenuActionId, + ]); + } + } + + /** + * 原注单入口依赖多种 prd.*;迁移为独立的 prd.tickets.view。 + * + * @param array $menuActionIds + */ + private function grantTicketsViewToLegacyRoles(array $menuActionIds, Carbon $now): void + { + $ticketsViewId = DB::table('admin_menu_actions') + ->where('permission_code', 'service.tickets.view') + ->value('id'); + + if ($ticketsViewId === null) { + return; + } + + $legacySlugs = [ + 'prd.users.view_cs', + 'prd.users.manage', + 'prd.users.view_finance', + 'prd.draw_result.view', + 'prd.draw_result.manage', + 'prd.payout.view', + 'prd.payout.review', + 'prd.payout.manage', + ]; + + $roleIds = $this->roleIdsWithAnyLegacySlug($legacySlugs); + + foreach ($roleIds as $roleId) { + DB::table('admin_role_menu_actions')->updateOrInsert([ + 'role_id' => (int) $roleId, + 'menu_action_id' => (int) $ticketsViewId, + ]); + } + + $riskViewId = DB::table('admin_menu_actions') + ->where('permission_code', 'risk.monitor.view') + ->value('id'); + $riskManageId = $menuActionIds['risk.monitor.manage'] ?? null; + + if ($riskManageId === null) { + return; + } + + $riskRoleIds = $this->roleIdsWithAnyLegacySlug([ + 'prd.draw_result.manage', + 'prd.draw_result.view', + 'prd.risk.manage', + 'prd.risk.view', + ]); + + foreach ($riskRoleIds as $roleId) { + if ($riskViewId !== null) { + DB::table('admin_role_menu_actions')->updateOrInsert([ + 'role_id' => (int) $roleId, + 'menu_action_id' => (int) $riskViewId, + ]); + } + DB::table('admin_role_menu_actions')->updateOrInsert([ + 'role_id' => (int) $roleId, + 'menu_action_id' => (int) $riskManageId, + ]); + } + } + + /** + * 通过角色已授权的 menu_action 反推曾拥有指定 prd.* 的角色(legacy 表已废弃)。 + * + * @param list $legacySlugs + * @return list + */ + private function roleIdsWithAnyLegacySlug(array $legacySlugs): array + { + $codes = []; + foreach ($legacySlugs as $slug) { + $codes = array_merge($codes, AdminPermissionBridge::menuActionCodesForLegacy($slug)); + } + $codes = array_values(array_unique($codes)); + + if ($codes === []) { + return []; + } + + $menuActionIds = DB::table('admin_menu_actions') + ->whereIn('permission_code', $codes) + ->where('status', 1) + ->pluck('id'); + + if ($menuActionIds->isEmpty()) { + return []; + } + + return DB::table('admin_role_menu_actions') + ->whereIn('menu_action_id', $menuActionIds->map(fn ($id) => (int) $id)->all()) + ->distinct() + ->pluck('role_id') + ->map(fn ($id) => (int) $id) + ->all(); + } +}; diff --git a/database/migrations/2026_05_27_100000_add_jackpot_manual_burst_permission.php b/database/migrations/2026_05_27_100000_add_jackpot_manual_burst_permission.php new file mode 100644 index 0000000..123410c --- /dev/null +++ b/database/migrations/2026_05_27_100000_add_jackpot_manual_burst_permission.php @@ -0,0 +1,112 @@ +where('code', 'manual_burst')->exists()) { + DB::table('admin_action_catalog')->insert([ + 'code' => 'manual_burst', + 'name' => '手动爆池', + 'sort_order' => 85, + 'status' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + $jackpotMenuId = (int) DB::table('admin_menus')->where('code', 'config.jackpot')->value('id'); + $manualBurstActionId = (int) DB::table('admin_action_catalog')->where('code', 'manual_burst')->value('id'); + + if ($jackpotMenuId <= 0 || $manualBurstActionId <= 0) { + return; + } + + DB::table('admin_menu_actions')->updateOrInsert( + ['permission_code' => 'jackpot.pool.manual_burst'], + [ + 'menu_id' => $jackpotMenuId, + 'action_id' => $manualBurstActionId, + 'name' => 'Jackpot 手动爆池', + 'status' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ], + ); + + if (Schema::hasTable('admin_permissions')) { + DB::table('admin_permissions')->updateOrInsert( + ['slug' => 'prd.jackpot.manual_burst'], + [ + 'name' => 'Jackpot 手动爆池·仅超管', + 'created_at' => $now, + 'updated_at' => $now, + ], + ); + } + + $menuActionId = (int) DB::table('admin_menu_actions') + ->where('permission_code', 'jackpot.pool.manual_burst') + ->value('id'); + + $superRoleId = (int) DB::table('admin_roles')->where('slug', 'super_admin')->value('id'); + if ($superRoleId > 0 && $menuActionId > 0) { + if (Schema::hasTable('admin_role_legacy_permissions')) { + DB::table('admin_role_legacy_permissions')->updateOrInsert( + [ + 'role_id' => $superRoleId, + 'permission_slug' => 'prd.jackpot.manual_burst', + ], + [ + 'created_at' => $now, + 'updated_at' => $now, + ], + ); + } + + DB::table('admin_role_menu_actions')->updateOrInsert( + [ + 'role_id' => $superRoleId, + 'menu_action_id' => $menuActionId, + ], + [], + ); + } + + $resourceId = DB::table('admin_api_resources') + ->where('code', 'admin.jackpot.pools.manual-burst') + ->value('id'); + + if ($resourceId !== null && $menuActionId > 0) { + DB::table('admin_api_resource_bindings') + ->where('api_resource_id', (int) $resourceId) + ->delete(); + + DB::table('admin_api_resource_bindings')->insert([ + 'api_resource_id' => (int) $resourceId, + 'menu_action_id' => $menuActionId, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + if ($superRoleId > 0 && Schema::hasTable('admin_role_api_resources')) { + DB::table('admin_role_api_resources')->updateOrInsert([ + 'role_id' => $superRoleId, + 'api_resource_id' => (int) $resourceId, + ], []); + } + } + } + + public function down(): void + { + // 避免误删线上已调整的授权绑定。 + } +}; diff --git a/database/seeders/AdminRbacAndUserSeeder.php b/database/seeders/AdminRbacAndUserSeeder.php index dbb5f1d..aed1223 100644 --- a/database/seeders/AdminRbacAndUserSeeder.php +++ b/database/seeders/AdminRbacAndUserSeeder.php @@ -39,17 +39,22 @@ final class AdminRbacAndUserSeeder extends Seeder ['name' => '风控运营员'], ); $this->syncRolePermissions($risk, [ + 'prd.dashboard.view', 'prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage', 'prd.draw_result.manage', + 'prd.risk.view', + 'prd.risk.manage', 'prd.payout.review', + 'prd.tickets.view', 'prd.wallet_reconcile.view', 'prd.audit.view', 'prd.player_freeze.manage', 'prd.report.view', + 'prd.report.export', ]); $finance = AdminRole::query()->updateOrCreate( @@ -57,16 +62,19 @@ final class AdminRbacAndUserSeeder extends Seeder ['name' => '财务/对账员'], ); $this->syncRolePermissions($finance, [ + 'prd.dashboard.view', 'prd.users.view_finance', 'prd.risk_cap.view', 'prd.rebate.view', 'prd.jackpot.view', 'prd.draw_result.view', 'prd.payout.view', + 'prd.tickets.view', 'prd.wallet_reconcile.manage', 'prd.wallet_adjust.manage', 'prd.audit.view', 'prd.report.view', + 'prd.report.export', ]); $cs = AdminRole::query()->updateOrCreate( @@ -74,7 +82,9 @@ final class AdminRbacAndUserSeeder extends Seeder ['name' => '客服人员'], ); $this->syncRolePermissions($cs, [ + 'prd.dashboard.view', 'prd.users.view_cs', + 'prd.tickets.view', 'prd.draw_result.view', 'prd.wallet_reconcile.view_cs', 'prd.report.view', diff --git a/database/seeders/DashboardHallFixtureSeeder.php b/database/seeders/DashboardHallFixtureSeeder.php index 030db4b..6e72fbe 100644 --- a/database/seeders/DashboardHallFixtureSeeder.php +++ b/database/seeders/DashboardHallFixtureSeeder.php @@ -11,6 +11,8 @@ use App\Lottery\DrawStatus; use App\Models\TicketOrder; use App\Models\TransferOrder; use App\Models\DrawResultBatch; +use App\Models\SettlementBatch; +use App\Lottery\SettlementBatchStatus; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\DB; use App\Lottery\DrawResultBatchStatus; @@ -54,7 +56,8 @@ final class DashboardHallFixtureSeeder extends Seeder DB::transaction(function () use ($draw, $player): void { $this->seedRiskPools($draw); $this->seedTicketOrders($draw, $player); - $this->seedPendingReviewBatch($draw); + $resultBatch = $this->seedPendingReviewBatch($draw); + $this->seedSettlementBatches($draw, $resultBatch); $this->seedAbnormalTransfers($player); }); @@ -269,12 +272,12 @@ final class DashboardHallFixtureSeeder extends Seeder ); } - private function seedPendingReviewBatch(Draw $draw): void + private function seedPendingReviewBatch(Draw $draw): DrawResultBatch { $max = (int) DrawResultBatch::query()->where('draw_id', $draw->id)->max('result_version'); $next = max(1, $max + 1); - DrawResultBatch::query()->firstOrCreate( + return DrawResultBatch::query()->firstOrCreate( [ 'draw_id' => $draw->id, 'result_version' => $next, @@ -291,6 +294,35 @@ final class DashboardHallFixtureSeeder extends Seeder ); } + private function seedSettlementBatches(Draw $draw, DrawResultBatch $resultBatch): void + { + $now = Carbon::now()->utc(); + $rows = [ + [1, SettlementBatchStatus::PendingReview, 3, 1, 15_000, 5_000, $now->copy()->subHours(2), null], + [2, SettlementBatchStatus::Approved, 3, 2, 20_000, 5_000, $now->copy()->subHour(), $now->copy()->subMinutes(30)], + [3, SettlementBatchStatus::Completed, 3, 2, 20_000, 5_000, $now->copy()->subMinutes(45), $now->copy()->subMinutes(10)], + ]; + + foreach ($rows as [$version, $status, $tickets, $wins, $payout, $jackpot, $started, $finished]) { + SettlementBatch::query()->firstOrCreate( + [ + 'draw_id' => $draw->id, + 'settle_version' => $version, + ], + [ + 'result_batch_id' => (int) $resultBatch->id, + 'status' => $status->value, + 'total_ticket_count' => $tickets, + 'total_win_count' => $wins, + 'total_payout_amount' => $payout, + 'total_jackpot_payout_amount' => $jackpot, + 'started_at' => $started, + 'finished_at' => $finished, + ], + ); + } + } + private function seedAbnormalTransfers(Player $player): void { $rows = [ diff --git a/database/seeders/LotterySettingsSeeder.php b/database/seeders/LotterySettingsSeeder.php index 509ab5d..f682432 100644 --- a/database/seeders/LotterySettingsSeeder.php +++ b/database/seeders/LotterySettingsSeeder.php @@ -82,6 +82,20 @@ final class LotterySettingsSeeder extends Seeder '是否在 draw tick 中自动对 `settling` 期号跑结算;false 时仅能通过后台 POST settlement/run 触发', ); + LotterySettings::put( + 'settlement.auto_approve_on_tick', + true, + 'settlement', + '冷静期结束后自动结算批次是否由 tick 自动审核通过;false 时需人工审核', + ); + + LotterySettings::put( + 'settlement.auto_payout_on_tick', + true, + 'settlement', + '结算批次审核通过后是否由 tick 自动派彩入账;false 时需人工执行 payout', + ); + LotterySettings::put( 'settlement.apply_rebate_to_payout', false, diff --git a/database/seeders/OperationalConfigV1Seeder.php b/database/seeders/OperationalConfigV1Seeder.php index a1e439d..463ab9a 100644 --- a/database/seeders/OperationalConfigV1Seeder.php +++ b/database/seeders/OperationalConfigV1Seeder.php @@ -69,9 +69,7 @@ final class OperationalConfigV1Seeder extends Seeder 'category' => $pt->category, 'dimension' => $pt->dimension, 'bet_mode' => $pt->bet_mode, - 'display_name_zh' => $pt->display_name_zh, - 'display_name_en' => $pt->display_name_en, - 'display_name_ne' => $pt->display_name_ne, + 'display_name' => $pt->display_name, 'is_enabled' => (bool) $pt->is_enabled, 'min_bet_amount' => 100, 'max_bet_amount' => 500_000_000, diff --git a/database/seeders/PlayOperationalAlignmentSeeder.php b/database/seeders/PlayOperationalAlignmentSeeder.php index a1d4e84..a25dc9f 100644 --- a/database/seeders/PlayOperationalAlignmentSeeder.php +++ b/database/seeders/PlayOperationalAlignmentSeeder.php @@ -64,9 +64,7 @@ final class PlayOperationalAlignmentSeeder extends Seeder 'category' => $pt->category, 'dimension' => $pt->dimension, 'bet_mode' => $pt->bet_mode, - 'display_name_zh' => $pt->display_name_zh, - 'display_name_en' => $pt->display_name_en, - 'display_name_ne' => $pt->display_name_ne, + 'display_name' => $pt->display_name, 'is_enabled' => (bool) $pt->is_enabled, 'min_bet_amount' => self::MIN_BET, 'max_bet_amount' => self::MAX_BET, diff --git a/database/seeders/PlayTypeSeeder.php b/database/seeders/PlayTypeSeeder.php index cce1901..83406d3 100644 --- a/database/seeders/PlayTypeSeeder.php +++ b/database/seeders/PlayTypeSeeder.php @@ -79,9 +79,7 @@ final class PlayTypeSeeder extends Seeder 'category' => $category, 'dimension' => $dimension, 'bet_mode' => $betMode, - 'display_name_zh' => $name, - 'display_name_en' => $name, - 'display_name_ne' => $name, + 'display_name' => $name, 'is_enabled' => $isEnabled, 'sort_order' => $sortOrder, 'supports_multi_number' => $supportsMultiNumber, diff --git a/lang/en/api.php b/lang/en/api.php index 348277e..3848d0d 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -6,4 +6,5 @@ return [ 'not_found' => 'The requested resource was not found.', 'too_many_requests' => 'Too many requests. Please try again later.', 'server_error' => 'Something went wrong. Please try again later.', + 'jackpot_manual_burst_failed' => 'Manual jackpot burst failed: :reason', ]; diff --git a/lang/ne/api.php b/lang/ne/api.php index f45727f..b2caae9 100644 --- a/lang/ne/api.php +++ b/lang/ne/api.php @@ -6,4 +6,5 @@ return [ 'not_found' => 'अनुरोध गरिएको स्रोत फेला परेन।', 'too_many_requests' => 'धेरै अनुरोधहरू। कृपया पछि प्रयास गर्नुहोस्।', 'server_error' => 'केही गडबड भयो। कृपया पछि प्रयास गर्नुहोस्।', + 'jackpot_manual_burst_failed' => 'म्यानुअल ज्याकपोट बर्स्ट असफल: :reason', ]; diff --git a/lang/zh/api.php b/lang/zh/api.php index 0339f06..697dc8a 100644 --- a/lang/zh/api.php +++ b/lang/zh/api.php @@ -6,4 +6,5 @@ return [ 'not_found' => '请求的资源不存在。', 'too_many_requests' => '请求过于频繁,请稍后再试。', 'server_error' => '服务暂时不可用,请稍后再试。', + 'jackpot_manual_burst_failed' => '手动爆池失败::reason', ]; diff --git a/routes/api.php b/routes/api.php index ff1d169..04eb5c6 100644 --- a/routes/api.php +++ b/routes/api.php @@ -21,7 +21,7 @@ Route::prefix('v1')->group(function (): void { require __DIR__.'/api/v1/admin/auth.php'; // 以下需 auth:sanctum + lottery.admin - Route::middleware(['auth:sanctum', 'lottery.admin']) + Route::middleware(['auth:sanctum', 'lottery.admin', 'admin.audit']) ->group(function (): void { require __DIR__.'/api/v1/admin/core.php'; require __DIR__.'/api/v1/admin/wallet.php'; diff --git a/routes/api/v1/admin/core.php b/routes/api/v1/admin/core.php index df2e19c..ff14383 100644 --- a/routes/api/v1/admin/core.php +++ b/routes/api/v1/admin/core.php @@ -3,6 +3,7 @@ use Illuminate\Support\Facades\Route; use App\Http\Controllers\Api\V1\Admin\Auth\MeController; use App\Http\Controllers\Api\V1\Admin\Audit\AuditLogIndexController; +use App\Http\Controllers\Api\V1\Admin\Dashboard\AdminDashboardAnalyticsController; use App\Http\Controllers\Api\V1\Admin\Dashboard\AdminDashboardController; use App\Http\Controllers\Api\V1\Admin\PingController as AdminPingController; @@ -20,6 +21,10 @@ Route::get('dashboard', AdminDashboardController::class) ->middleware('admin.api-resource') ->name('api.v1.admin.dashboard'); +Route::get('dashboard/analytics', AdminDashboardAnalyticsController::class) + ->middleware('admin.api-resource') + ->name('api.v1.admin.dashboard.analytics'); + // 当前管理员摘要 Route::get('auth/me', MeController::class) ->middleware('admin.api-resource') diff --git a/scripts/perf/README.md b/scripts/perf/README.md new file mode 100644 index 0000000..05d42b5 --- /dev/null +++ b/scripts/perf/README.md @@ -0,0 +1,26 @@ +# Performance scripts (PRD §17.2) + +Requires [k6](https://grafana.com/docs/k6/latest/set-up/install-k6/). + +## Environment variables + +| Variable | Required | Description | +| :--- | :--- | :--- | +| `BASE_URL` | yes | API origin, e.g. `http://127.0.0.1:8000` | +| `PLAYER_ID` | bet-qps, seal | `players.id` for `Bearer dev:{id}` | +| `DRAW_NO` | yes | Open draw `draw_no` (`YYYYMMDD-NNN`) | +| `PLAYER_IDS` | oversell | Comma-separated player ids | +| `HOT_NUMBER` | oversell | Shared number, default `8888` | +| `LINE_AMOUNT` | oversell | Per-request line amount, default `100` | + +Staging must use `LOTTERY_RISK_POOL_USE_REDIS_LUA=true` and sufficient wallet balance. + +## Commands + +```bash +k6 run bet-qps.js +k6 run oversell-race.js +k6 run seal-after-close.js +``` + +Thresholds are defined in each script (`thresholds` block). Non-zero exit means failed acceptance. diff --git a/scripts/perf/bet-qps.js b/scripts/perf/bet-qps.js new file mode 100644 index 0000000..ac51bbd --- /dev/null +++ b/scripts/perf/bet-qps.js @@ -0,0 +1,75 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Counter, Trend } from 'k6/metrics'; + +const okLatency = new Trend('place_ok_latency', true); +const okCount = new Counter('place_ok_total'); + +const baseUrl = __ENV.BASE_URL; +const playerId = __ENV.PLAYER_ID; +const drawNo = __ENV.DRAW_NO; + +if (!baseUrl || !playerId || !drawNo) { + throw new Error('Set BASE_URL, PLAYER_ID, DRAW_NO'); +} + +export const options = { + scenarios: { + bet_ramp: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '30s', target: 200 }, + { duration: '60s', target: 1000 }, + { duration: '30s', target: 1000 }, + { duration: '15s', target: 0 }, + ], + gracefulRampDown: '10s', + }, + }, + thresholds: { + http_req_failed: ['rate<0.05'], + http_req_duration: ['p(99)<200'], + place_ok_latency: ['p(99)<200'], + place_ok_total: ['count>30000'], + }, +}; + +function payload(vu, iter) { + const n = String(1000 + ((vu + iter) % 9000)).padStart(4, '0'); + return JSON.stringify({ + draw_id: drawNo, + currency_code: 'NPR', + client_trace_id: `k6-${__VU}-${__ITER}-${Date.now()}`, + lines: [{ number: n, play_code: 'big', amount: 10 }], + }); +} + +export default function () { + const res = http.post(`${baseUrl}/api/v1/ticket/place`, payload(__VU, __ITER), { + headers: { + Authorization: `Bearer dev:${playerId}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + tags: { name: 'ticket_place' }, + }); + + const ok = check(res, { + 'status 200': (r) => r.status === 200, + 'code success': (r) => { + try { + return r.json('code') === 0; + } catch { + return false; + } + }, + }); + + if (ok) { + okLatency.add(res.timings.duration); + okCount.add(1); + } + + sleep(0.01); +} diff --git a/scripts/perf/oversell-race.js b/scripts/perf/oversell-race.js new file mode 100644 index 0000000..86e39ba --- /dev/null +++ b/scripts/perf/oversell-race.js @@ -0,0 +1,72 @@ +import http from 'k6/http'; +import { check } from 'k6'; +import { Counter } from 'k6/metrics'; + +const baseUrl = __ENV.BASE_URL; +const drawNo = __ENV.DRAW_NO; +const hotNumber = __ENV.HOT_NUMBER || '8888'; +const lineAmount = Number(__ENV.LINE_AMOUNT || '100'); +const playerIds = (__ENV.PLAYER_IDS || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + +if (!baseUrl || !drawNo || playerIds.length === 0) { + throw new Error('Set BASE_URL, DRAW_NO, PLAYER_IDS (comma-separated)'); +} + +export const options = { + scenarios: { + oversell: { + executor: 'constant-vus', + vus: Math.min(playerIds.length, 200), + duration: '45s', + }, + }, + thresholds: { + http_req_failed: ['rate<0.15'], + oversell_seen: ['count>0'], + success_seen: ['count>0'], + }, +}; + +const oversellSeen = new Counter('oversell_seen'); +const successSeen = new Counter('success_seen'); + +function body(playerId, iter) { + return JSON.stringify({ + draw_id: drawNo, + currency_code: 'NPR', + client_trace_id: `race-${playerId}-${iter}-${Date.now()}`, + lines: [{ number: hotNumber, play_code: 'big', amount: lineAmount }], + }); +} + +export default function () { + const playerId = playerIds[(__VU - 1) % playerIds.length]; + const res = http.post(`${baseUrl}/api/v1/ticket/place`, body(playerId, __ITER), { + headers: { + Authorization: `Bearer dev:${playerId}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }); + + let code = -1; + try { + code = res.json('code'); + } catch { + // ignore + } + + if (code === 0) { + successSeen.add(1); + } + if (code === 4001) { + oversellSeen.add(1); + } + + check(res, { + 'structured response': (r) => r.status === 200 || r.status === 400, + }); +} diff --git a/scripts/perf/seal-after-close.js b/scripts/perf/seal-after-close.js new file mode 100644 index 0000000..5009f91 --- /dev/null +++ b/scripts/perf/seal-after-close.js @@ -0,0 +1,57 @@ +import http from 'k6/http'; +import { check } from 'k6'; +import { Counter } from 'k6/metrics'; + +const baseUrl = __ENV.BASE_URL; +const playerId = __ENV.PLAYER_ID; +const drawNo = __ENV.DRAW_NO; + +if (!baseUrl || !playerId || !drawNo) { + throw new Error('Set BASE_URL, PLAYER_ID, DRAW_NO (must already be sealed / past close_time)'); +} + +const sealedReject = new Counter('sealed_reject_2001'); + +export const options = { + vus: 50, + duration: '20s', + thresholds: { + sealed_reject_2001: ['count>400'], + http_req_failed: ['rate<0.01'], + }, +}; + +export default function () { + const res = http.post( + `${baseUrl}/api/v1/ticket/place`, + JSON.stringify({ + draw_id: drawNo, + currency_code: 'NPR', + client_trace_id: `seal-${__VU}-${__ITER}-${Date.now()}`, + lines: [{ number: '1234', play_code: 'big', amount: 10 }], + }), + { + headers: { + Authorization: `Bearer dev:${playerId}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }, + ); + + let code = null; + try { + code = res.json('code'); + } catch { + // ignore + } + + const rejected = check(res, { + 'http 400': (r) => r.status === 400, + 'code 2001': () => code === 2001, + }); + + if (rejected) { + sealedReject.add(1); + } +} diff --git a/tests/Feature/AdminApiAuditMiddlewareTest.php b/tests/Feature/AdminApiAuditMiddlewareTest.php new file mode 100644 index 0000000..4af2856 --- /dev/null +++ b/tests/Feature/AdminApiAuditMiddlewareTest.php @@ -0,0 +1,45 @@ +create([ + 'username' => 'audit_reopen_admin', + 'name' => 'Audit Reopen', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $draw = Draw::query()->create([ + 'draw_no' => '20260525-099', + 'business_date' => '2026-05-25', + 'sequence_no' => 99, + 'status' => DrawStatus::Cooldown->value, + 'cooling_end_time' => now()->addMinutes(10), + 'settle_version' => 0, + ]); + + $before = AuditLog::query()->count(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson("/api/v1/admin/draws/{$draw->id}/reopen", ['reason' => 'audit test']) + ->assertOk(); + + expect(AuditLog::query()->count())->toBe($before + 1); + + /** @var AuditLog $row */ + $row = AuditLog::query()->latest('id')->first(); + expect($row->module_code)->toBe('draw') + ->and($row->action_code)->toBe('reopen') + ->and($row->operator_id)->toBe($admin->id); +}); diff --git a/tests/Feature/AdminCurrencyApiTest.php b/tests/Feature/AdminCurrencyApiTest.php index 84961fd..adb0e2d 100644 --- a/tests/Feature/AdminCurrencyApiTest.php +++ b/tests/Feature/AdminCurrencyApiTest.php @@ -90,9 +90,7 @@ test('enabling currency as bettable bootstraps odds items and jackpot pool', fun 'category' => '4d', 'dimension' => 4, 'bet_mode' => 'single', - 'display_name_zh' => '直选', - 'display_name_en' => 'Straight', - 'display_name_ne' => 'सिधा', + 'display_name' => '直选', 'is_enabled' => true, 'sort_order' => 10, 'supports_multi_number' => false, diff --git a/tests/Feature/AdminDashboardAnalyticsApiTest.php b/tests/Feature/AdminDashboardAnalyticsApiTest.php new file mode 100644 index 0000000..6a19fa3 --- /dev/null +++ b/tests/Feature/AdminDashboardAnalyticsApiTest.php @@ -0,0 +1,105 @@ +artisan('lottery:admin-auth-sync')->assertExitCode(0); + $player = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'da-p1', + 'username' => 'da_u1', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $draw = Draw::query()->create([ + 'draw_no' => '20260510-001', + 'business_date' => '2026-05-10', + 'sequence_no' => 1, + 'status' => 'settled', + 'start_time' => now()->subDay(), + 'close_time' => now()->subDay(), + 'draw_time' => now()->subDay(), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 1, + 'settle_version' => 1, + 'is_reopened' => false, + ]); + + $order = TicketOrder::query()->create([ + 'order_no' => 'ORD-DA-1', + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'currency_code' => 'NPR', + 'total_bet_amount' => 8_000, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 8_000, + 'total_estimated_payout' => 0, + 'status' => 'settled', + 'submit_source' => 'h5', + 'client_trace_id' => null, + ]); + + TicketItem::query()->create([ + 'ticket_no' => 'TK-DA-1', + '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' => null, + 'unit_bet_amount' => 8_000, + 'total_bet_amount' => 8_000, + 'rebate_rate_snapshot' => 0, + 'commission_rate_snapshot' => 0, + 'actual_deduct_amount' => 8_000, + 'odds_snapshot_json' => null, + 'rule_snapshot_json' => null, + 'combination_count' => 1, + 'estimated_max_payout' => 0, + 'risk_locked_amount' => 0, + 'status' => 'won', + 'fail_reason_code' => null, + 'fail_reason_text' => null, + 'win_amount' => 2_000, + 'jackpot_win_amount' => 0, + 'settled_at' => now(), + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'dash_analytics_admin', + 'name' => 'Dash Analytics QA', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/dashboard/analytics?period=last_30_days&metric=overview') + ->assertOk() + ->assertJsonPath('data.summary.total_bet_minor', 8_000) + ->assertJsonPath('data.summary.total_payout_minor', 2_000) + ->assertJsonPath('data.summary.approx_house_gross_minor', 6_000) + ->assertJsonStructure([ + 'data' => [ + 'daily_series', + 'play_breakdown', + 'chart_meta' => ['truncated', 'span_days'], + ], + ]); +}); diff --git a/tests/Feature/AdminDashboardAnalyticsLifetimeTest.php b/tests/Feature/AdminDashboardAnalyticsLifetimeTest.php new file mode 100644 index 0000000..8699d94 --- /dev/null +++ b/tests/Feature/AdminDashboardAnalyticsLifetimeTest.php @@ -0,0 +1,103 @@ +resolveDashboardPeriod('lifetime', null, null); + expect($empty)->toHaveKeys(['date_from', 'date_to']) + ->and($empty['date_from'])->toBeString() + ->and($empty['date_to'])->toBeString(); + + $player = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'lt-p1', + 'username' => 'lt_u1', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + Draw::query()->create([ + 'draw_no' => '20260101-001', + 'business_date' => '2026-01-01', + 'sequence_no' => 1, + 'status' => 'settled', + 'start_time' => now()->subMonths(2), + 'close_time' => now()->subMonths(2), + 'draw_time' => now()->subMonths(2), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 1, + 'settle_version' => 1, + 'is_reopened' => false, + ]); + + $drawLate = Draw::query()->create([ + 'draw_no' => '20260520-001', + 'business_date' => '2026-05-20', + 'sequence_no' => 1, + 'status' => 'settled', + 'start_time' => now()->subDay(), + 'close_time' => now()->subDay(), + 'draw_time' => now()->subDay(), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 1, + 'settle_version' => 1, + 'is_reopened' => false, + ]); + + TicketOrder::query()->create([ + 'order_no' => 'ORD-LT-1', + 'player_id' => $player->id, + 'draw_id' => $drawLate->id, + 'currency_code' => 'NPR', + 'total_bet_amount' => 1_000, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 1_000, + 'total_estimated_payout' => 0, + 'status' => 'settled', + 'submit_source' => 'h5', + 'client_trace_id' => null, + ]); + + $range = $service->resolveDashboardPeriod('lifetime', null, null); + expect($range['date_from'])->toBe('2026-05-20') + ->and($range['date_to'])->toBe('2026-05-20'); +}); + +test('dashboard analytics lifetime period returns ok via http', function (): void { + $this->artisan('lottery:admin-auth-sync')->assertExitCode(0); + + $admin = AdminUser::query()->create([ + 'username' => 'lt_dash_admin', + 'name' => 'LT Dash', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/dashboard/analytics?period=lifetime&metric=overview') + ->assertOk() + ->assertJsonStructure([ + 'data' => [ + 'date_from', + 'date_to', + 'summary', + 'daily_series', + ], + ]); +}); diff --git a/tests/Feature/AdminDashboardApiTest.php b/tests/Feature/AdminDashboardApiTest.php index 930d241..f4d3649 100644 --- a/tests/Feature/AdminDashboardApiTest.php +++ b/tests/Feature/AdminDashboardApiTest.php @@ -61,6 +61,8 @@ test('admin dashboard aggregates hall finance and risk for super admin', functio ->assertJsonPath('data.resolved_draw.id', $draw->id) ->assertJsonPath('data.capabilities.draw_finance_risk', true) ->assertJsonPath('data.capabilities.wallet_transfer_view', true) + ->assertJsonPath('data.today_finance.business_date', now()->toDateString()) + ->assertJsonPath('data.today_finance.total_bet_minor', 0) ->assertJsonPath('data.finance.draw_id', $draw->id) ->assertJsonPath('data.draw.result_batch_counts.total', 0) ->assertJsonPath('data.risk.locked_amount', 200_100) diff --git a/tests/Feature/AdminPermissionGranularityTest.php b/tests/Feature/AdminPermissionGranularityTest.php new file mode 100644 index 0000000..572689a --- /dev/null +++ b/tests/Feature/AdminPermissionGranularityTest.php @@ -0,0 +1,75 @@ +seed(AdminRbacAndUserSeeder::class); + $this->artisan('lottery:admin-auth-sync')->assertExitCode(0); + + $playStore = resourceLegacySlugs('admin.config.play-versions.store'); + expect($playStore)->toBe(['prd.play_switch.manage']); + + $oddsStore = resourceLegacySlugs('admin.config.odds-versions.store'); + expect($oddsStore)->toContain('prd.odds.manage') + ->and($oddsStore)->not->toContain('prd.play_switch.manage'); + + $riskCapStore = resourceLegacySlugs('admin.config.risk-cap-versions.store'); + expect($riskCapStore)->toBe(['prd.risk_cap.manage']); +}); + +test('user with report view only cannot create report export job', function (): void { + $this->seed(AdminRbacAndUserSeeder::class); + $this->artisan('lottery:admin-auth-sync')->assertExitCode(0); + + $admin = AdminUser::query()->create([ + 'username' => 'report_view_only', + 'name' => 'Tester', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + $role = AdminRole::query()->create(['slug' => 'report_view_only', 'name' => 'Report view only']); + $role->syncLegacyPermissionSlugs(['prd.report.view', 'prd.dashboard.view']); + + $siteId = AdminUser::defaultAdminSiteId(); + $admin->roles()->sync([ + (int) $role->id => ['site_id' => $siteId, 'granted_at' => now()], + ]); + + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/reports/daily-profit') + ->assertOk(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/report-jobs', [ + 'report_type' => 'daily_profit_summary', + 'export_format' => 'csv', + 'parameters' => [ + 'date_from' => '2026-05-01', + 'date_to' => '2026-05-07', + ], + ]) + ->assertForbidden(); +}); + +/** @return list */ +function resourceLegacySlugs(string $code): array +{ + $resource = collect(AdminAuthorizationRegistry::resourceDefinitions()) + ->firstWhere('code', $code); + + expect($resource)->not->toBeNull(); + + return $resource['legacy_permission_slugs'] ?? []; +} diff --git a/tests/Feature/AdminReportAuthorizationFixTest.php b/tests/Feature/AdminReportAuthorizationFixTest.php index 1c99ec3..a73401d 100644 --- a/tests/Feature/AdminReportAuthorizationFixTest.php +++ b/tests/Feature/AdminReportAuthorizationFixTest.php @@ -52,7 +52,7 @@ test('finance role with report legacy can access report jobs after rbac seed', f ->assertOk(); }); -test('report api resources only bind service.report.view', function (): void { +test('report read api resources bind service.report.view only', function (): void { $this->seed(AdminRbacAndUserSeeder::class); $this->artisan('lottery:admin-auth-sync')->assertExitCode(0); @@ -63,13 +63,28 @@ test('report api resources only bind service.report.view', function (): void { ]; foreach ($codes as $code) { - $bindings = DB::table('admin_api_resources as ar') - ->join('admin_api_resource_bindings as arb', 'arb.api_resource_id', '=', 'ar.id') - ->join('admin_menu_actions as ma', 'ma.id', '=', 'arb.menu_action_id') - ->where('ar.code', $code) - ->pluck('ma.permission_code') - ->all(); - + $bindings = bindingsForResource($code); expect($bindings)->toBe(['service.report.view']); } }); + +test('report export api resources bind service.report.export', function (): void { + $this->seed(AdminRbacAndUserSeeder::class); + + $this->artisan('lottery:admin-auth-sync')->assertExitCode(0); + + expect(bindingsForResource('admin.report-jobs.download'))->toBe(['service.report.export']); + expect(bindingsForResource('admin.report-jobs.store'))->toBe(['service.report.export']); +}); + +/** @return list */ +function bindingsForResource(string $code): array +{ + return DB::table('admin_api_resources as ar') + ->join('admin_api_resource_bindings as arb', 'arb.api_resource_id', '=', 'ar.id') + ->join('admin_menu_actions as ma', 'ma.id', '=', 'arb.menu_action_id') + ->where('ar.code', $code) + ->orderBy('ma.permission_code') + ->pluck('ma.permission_code') + ->all(); +} diff --git a/tests/Feature/AdminReportPlatformLifetimeTotalsTest.php b/tests/Feature/AdminReportPlatformLifetimeTotalsTest.php new file mode 100644 index 0000000..ea1117f --- /dev/null +++ b/tests/Feature/AdminReportPlatformLifetimeTotalsTest.php @@ -0,0 +1,119 @@ +create([ + 'site_code' => 'main', + 'site_player_id' => 'plt-p1', + 'username' => 'plt_u1', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $drawA = Draw::query()->create([ + 'draw_no' => '20260501-001', + 'business_date' => '2026-05-01', + 'sequence_no' => 1, + 'status' => 'settled', + 'start_time' => now()->subDays(2), + 'close_time' => now()->subDays(2)->addHour(), + 'draw_time' => now()->subDays(2)->addHours(2), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 1, + 'settle_version' => 1, + 'is_reopened' => false, + ]); + + $drawB = Draw::query()->create([ + 'draw_no' => '20260502-001', + 'business_date' => '2026-05-02', + 'sequence_no' => 1, + 'status' => 'settled', + 'start_time' => now()->subDay(), + 'close_time' => now()->subDay()->addHour(), + 'draw_time' => now()->subDay()->addHours(2), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 1, + 'settle_version' => 1, + 'is_reopened' => false, + ]); + + $orderA = TicketOrder::query()->create([ + 'order_no' => 'ORD-A', + 'player_id' => $player->id, + 'draw_id' => $drawA->id, + 'currency_code' => 'NPR', + 'total_bet_amount' => 10_000, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 10_000, + 'total_estimated_payout' => 0, + 'status' => 'settled', + 'submit_source' => 'h5', + 'client_trace_id' => null, + ]); + + TicketOrder::query()->create([ + 'order_no' => 'ORD-B', + 'player_id' => $player->id, + 'draw_id' => $drawB->id, + 'currency_code' => 'NPR', + 'total_bet_amount' => 5_000, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 5_000, + 'total_estimated_payout' => 0, + 'status' => 'settled', + 'submit_source' => 'h5', + 'client_trace_id' => null, + ]); + + TicketItem::query()->create([ + 'ticket_no' => 'TK-PLT-1', + 'order_id' => $orderA->id, + 'player_id' => $player->id, + 'draw_id' => $drawA->id, + 'original_number' => '1234', + 'normalized_number' => '1234', + 'play_code' => 'big', + 'dimension' => 4, + 'digit_slot' => null, + 'bet_mode' => null, + 'unit_bet_amount' => 10_000, + 'total_bet_amount' => 10_000, + 'rebate_rate_snapshot' => 0, + 'commission_rate_snapshot' => 0, + 'actual_deduct_amount' => 10_000, + 'odds_snapshot_json' => null, + 'rule_snapshot_json' => null, + 'combination_count' => 1, + 'estimated_max_payout' => 0, + 'risk_locked_amount' => 0, + 'status' => 'won', + 'fail_reason_code' => null, + 'fail_reason_text' => null, + 'win_amount' => 3_000, + 'jackpot_win_amount' => 0, + 'settled_at' => now(), + ]); + + $totals = app(AdminReportQueryService::class)->platformLifetimeTotals(); + + expect($totals['total_bet_minor'])->toBe(15_000) + ->and($totals['total_payout_minor'])->toBe(3_000) + ->and($totals['approx_house_gross_minor'])->toBe(12_000) + ->and($totals['draw_count'])->toBe(2) + ->and($totals['business_day_count'])->toBe(2) + ->and($totals['date_from'])->toBe('2026-05-01') + ->and($totals['date_to'])->toBe('2026-05-02') + ->and($totals['currency_code'])->toBe('NPR'); +}); diff --git a/tests/Feature/AdminSettlementJackpotApiTest.php b/tests/Feature/AdminSettlementJackpotApiTest.php index c639fc1..d0fe0b7 100644 --- a/tests/Feature/AdminSettlementJackpotApiTest.php +++ b/tests/Feature/AdminSettlementJackpotApiTest.php @@ -3,12 +3,39 @@ use App\Models\AdminUser; use App\Models\Draw; use App\Lottery\DrawStatus; +use App\Models\DrawResultBatch; +use App\Models\DrawResultItem; use App\Models\JackpotPool; +use App\Models\JackpotPayoutLog; +use App\Models\Player; +use App\Models\PlayerWallet; +use App\Models\SettlementBatch; +use App\Models\TicketItem; +use App\Models\WalletTxn; +use App\Lottery\DrawResultBatchStatus; +use App\Services\Draw\DrawPrizeLayout; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Hash; +use App\Events\JackpotBurstBroadcast; +use App\Events\DrawStatusChangeBroadcast; use Illuminate\Foundation\Testing\RefreshDatabase; +use Database\Seeders\CurrencySeeder; +use Database\Seeders\PlayTypeSeeder; +use Database\Seeders\LotterySettingsSeeder; +use Database\Seeders\OperationalConfigV1Seeder; +use App\Services\Settlement\SettlementOrchestrator; +use App\Services\Settlement\SettlementBatchWorkflowService; +use Illuminate\Support\Facades\DB; uses(RefreshDatabase::class); +beforeEach(function (): void { + $this->seed(CurrencySeeder::class); + $this->seed(PlayTypeSeeder::class); + $this->seed(OperationalConfigV1Seeder::class); + $this->seed(LotterySettingsSeeder::class); +}); + function mintSettlementAdminToken(): string { $admin = AdminUser::query()->create([ @@ -23,6 +50,44 @@ function mintSettlementAdminToken(): string return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; } +function mintRiskOperatorToken(): string +{ + $now = now(); + DB::table('admin_roles')->updateOrInsert( + ['slug' => 'risk_operator'], + ['name' => 'Risk', 'code' => 'risk_operator', 'created_at' => $now, 'updated_at' => $now], + ); + $roleId = (int) DB::table('admin_roles')->where('slug', 'risk_operator')->value('id'); + + $admin = AdminUser::query()->create([ + 'username' => 'risk_jp_admin', + 'name' => 'Risk JP', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + DB::table('admin_user_site_roles')->insert([ + 'admin_user_id' => $admin->id, + 'site_id' => $siteId, + 'role_id' => $roleId, + 'granted_at' => $now, + ]); + + $manageMenuActionId = (int) DB::table('admin_menu_actions') + ->where('permission_code', 'config.jackpot.manage') + ->value('id'); + if ($manageMenuActionId > 0) { + DB::table('admin_role_menu_actions')->updateOrInsert( + ['role_id' => $roleId, 'menu_action_id' => $manageMenuActionId], + [], + ); + } + + return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; +} + test('admin settlement batches index is authenticated', function (): void { $this->getJson('/api/v1/admin/settlement-batches')->assertUnauthorized(); }); @@ -42,22 +107,27 @@ test('admin jackpot pools index returns rows', function (): void { ->assertJsonPath('data.items.0.combo_trigger_play_codes', []); }); -test('admin can update jackpot combo trigger and manually burst pool', function (): void { +test('admin can update jackpot combo trigger', function (): void { $pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail(); - $pool->forceFill([ - 'current_amount' => 1000, - 'contribution_rate' => '0.01', - 'trigger_threshold' => 1000, - 'payout_rate' => '0.5', - 'force_trigger_draw_gap' => 10, - 'min_bet_amount' => 0, - 'status' => 1, - 'last_trigger_draw_id' => null, - ])->save(); + $token = mintSettlementAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/jackpot/pools/'.$pool->id, [ + 'combo_trigger_play_codes' => ['straight', 'ibox'], + ]) + ->assertOk() + ->assertJsonPath('data.combo_trigger_play_codes.0', 'straight') + ->assertJsonPath('data.combo_trigger_play_codes.1', 'ibox'); +}); + +test('risk operator cannot manually burst jackpot', function (): void { + $pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail(); + $pool->forceFill(['current_amount' => 1000, 'status' => 1])->save(); + $draw = Draw::query()->create([ - 'draw_no' => '20260518-001', + 'draw_no' => '20260518-099', 'business_date' => '2026-05-18', - 'sequence_no' => 1, + 'sequence_no' => 99, 'status' => DrawStatus::Settled->value, 'start_time' => now()->subHours(2), 'close_time' => now()->subHour(), @@ -69,22 +139,263 @@ test('admin can update jackpot combo trigger and manually burst pool', function 'is_reopened' => false, ]); - $token = mintSettlementAdminToken(); - - $this->withHeader('Authorization', 'Bearer '.$token) - ->putJson('/api/v1/admin/jackpot/pools/'.$pool->id, [ - 'combo_trigger_play_codes' => ['straight', 'ibox'], - ]) - ->assertOk() - ->assertJsonPath('data.combo_trigger_play_codes.0', 'straight') - ->assertJsonPath('data.combo_trigger_play_codes.1', 'ibox'); + $token = mintRiskOperatorToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/jackpot/pools/'.$pool->id.'/manual-burst', [ + 'draw_id' => $draw->id, + ]) + ->assertForbidden(); +}); + +test('super admin manual burst allocates jackpot to first prize winners after settlement', function (): void { + $pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail(); + $pool->forceFill([ + 'current_amount' => 10_000, + 'contribution_rate' => '0', + 'trigger_threshold' => 999_999_999, + 'payout_rate' => '0.5000', + 'force_trigger_draw_gap' => 0, + 'min_bet_amount' => 0, + 'status' => 1, + 'last_trigger_draw_id' => null, + ])->save(); + + $player = Player::query()->create([ + 'site_code' => 'test', + 'site_player_id' => 'manual-burst-p1', + 'username' => 'manual_burst_p1', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'NPR', + 'balance' => 5_000_000, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + + $draw = Draw::query()->create([ + 'draw_no' => '20260518-010', + 'business_date' => '2026-05-18', + 'sequence_no' => 10, + 'status' => DrawStatus::Open->value, + 'start_time' => now()->subMinutes(5), + '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' => (string) $draw->draw_no, + 'currency_code' => 'NPR', + 'client_trace_id' => 'manual-burst-bet-1', + 'lines' => [['number' => '1234', 'play_code' => 'straight', 'amount' => 10_000]], + ]) + ->assertOk(); + + $batch = DrawResultBatch::query()->create([ + 'draw_id' => $draw->id, + 'result_version' => 1, + 'source_type' => 'rng', + 'raw_seed_encrypted' => null, + 'status' => DrawResultBatchStatus::Published->value, + 'created_by' => null, + 'confirmed_by' => null, + 'confirmed_at' => now(), + ]); + + foreach (DrawPrizeLayout::slots() as $slot) { + $num = $slot['prize_type'] === 'first' ? '1234' : '5678'; + DrawResultItem::query()->create([ + 'draw_id' => $draw->id, + 'result_batch_id' => $batch->id, + 'prize_type' => $slot['prize_type'], + 'prize_index' => $slot['prize_index'], + 'number_4d' => $num, + 'suffix_3d' => substr($num, -3), + 'suffix_2d' => substr($num, -2), + 'head_digit' => (int) substr($num, 0, 1), + 'tail_digit' => (int) substr($num, 3, 1), + ]); + } + + $draw->forceFill([ + 'status' => DrawStatus::Settling->value, + 'current_result_version' => 1, + ])->save(); + + expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); + + $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); + expect((int) $item->jackpot_win_amount)->toBe(0); + + $admin = AdminUser::query()->where('username', 'settlement_admin')->first(); + if ($admin === null) { + mintSettlementAdminToken(); + $admin = AdminUser::query()->where('username', 'settlement_admin')->firstOrFail(); + } else { + grantSuperAdminRole($admin); + } + + $settlementBatch = SettlementBatch::query()->where('draw_id', $draw->id)->firstOrFail(); + app(SettlementBatchWorkflowService::class)->approve($settlementBatch, $admin); + app(SettlementBatchWorkflowService::class)->payout($settlementBatch->fresh()); + + Event::fake([JackpotBurstBroadcast::class, DrawStatusChangeBroadcast::class]); + config([ + 'broadcasting.default' => 'reverb', + 'broadcasting.connections.reverb.driver' => 'reverb', + ]); + + $token = $admin->createToken('burst', ['*'], now()->addDay())->plainTextToken; $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/jackpot/pools/'.$pool->id.'/manual-burst', [ 'draw_id' => $draw->id, - 'amount' => 400, ]) ->assertOk() - ->assertJsonPath('data.burst_amount', 400) - ->assertJsonPath('data.current_amount', 600); + ->assertJsonPath('data.burst_amount', 5000) + ->assertJsonPath('data.current_amount', 5000) + ->assertJsonPath('data.winner_count', 1); + + $item->refresh(); + expect((int) $item->jackpot_win_amount)->toBe(5000); + + $log = JackpotPayoutLog::query()->where('draw_id', $draw->id)->firstOrFail(); + expect($log->trigger_type)->toBe('manual') + ->and($log->winner_count)->toBe(1) + ->and((int) $log->total_payout_amount)->toBe(5000); + + expect(WalletTxn::query()->where('biz_type', 'jackpot_manual_payout')->count())->toBe(1); + + Event::assertDispatched( + JackpotBurstBroadcast::class, + fn (JackpotBurstBroadcast $event): bool => $event->drawId === (int) $draw->id + && $event->triggerType === 'manual' + && $event->totalPayoutAmount === 5000 + && $event->winnerCount === 1 + && $event->firstPrizeNumber === '1234', + ); +}); + +test('manual burst broadcast includes published first prize number', function (): void { + $pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail(); + $pool->forceFill(['current_amount' => 500, 'status' => 1, 'payout_rate' => '1'])->save(); + + $player = Player::query()->create([ + 'site_code' => 'test', + 'site_player_id' => 'manual-burst-p2', + 'username' => 'manual_burst_p2', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'NPR', + 'balance' => 1_000_000, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + + $draw = Draw::query()->create([ + 'draw_no' => '20260518-002', + 'business_date' => '2026-05-18', + 'sequence_no' => 2, + 'status' => DrawStatus::Open->value, + 'start_time' => now()->subMinutes(5), + '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' => (string) $draw->draw_no, + 'currency_code' => 'NPR', + 'client_trace_id' => 'manual-burst-bet-2', + 'lines' => [['number' => '1234', 'play_code' => 'straight', 'amount' => 10_000]], + ]) + ->assertOk(); + + $resultBatch = DrawResultBatch::query()->create([ + 'draw_id' => $draw->id, + 'result_version' => 1, + 'source_type' => 'rng', + 'raw_seed_encrypted' => null, + 'status' => DrawResultBatchStatus::Published->value, + 'created_by' => null, + 'confirmed_by' => null, + 'confirmed_at' => now(), + ]); + + foreach (DrawPrizeLayout::slots() as $slot) { + $num = $slot['prize_type'] === 'first' ? '1234' : '5678'; + DrawResultItem::query()->create([ + 'draw_id' => $draw->id, + 'result_batch_id' => $resultBatch->id, + 'prize_type' => $slot['prize_type'], + 'prize_index' => $slot['prize_index'], + 'number_4d' => $num, + 'suffix_3d' => substr($num, -3), + 'suffix_2d' => substr($num, -2), + 'head_digit' => (int) substr($num, 0, 1), + 'tail_digit' => (int) substr($num, 3, 1), + ]); + } + + $draw->forceFill([ + 'status' => DrawStatus::Settling->value, + 'current_result_version' => 1, + 'result_source' => 'rng', + ])->save(); + + expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); + + $admin = AdminUser::query()->create([ + 'username' => 'manual_burst_admin2', + 'name' => 'Burst Admin', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + $settlementBatch = SettlementBatch::query()->where('draw_id', $draw->id)->firstOrFail(); + app(SettlementBatchWorkflowService::class)->approve($settlementBatch, $admin); + app(SettlementBatchWorkflowService::class)->payout($settlementBatch->fresh()); + + Event::fake([JackpotBurstBroadcast::class, DrawStatusChangeBroadcast::class]); + config([ + 'broadcasting.default' => 'reverb', + 'broadcasting.connections.reverb.driver' => 'reverb', + ]); + + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/jackpot/pools/'.$pool->id.'/manual-burst', [ + 'draw_id' => $draw->id, + ]) + ->assertOk(); + + Event::assertDispatched( + JackpotBurstBroadcast::class, + fn (JackpotBurstBroadcast $event): bool => $event->firstPrizeNumber === '1234', + ); }); diff --git a/tests/Feature/DrawPipelineTest.php b/tests/Feature/DrawPipelineTest.php index a79662a..07b5b51 100644 --- a/tests/Feature/DrawPipelineTest.php +++ b/tests/Feature/DrawPipelineTest.php @@ -16,6 +16,7 @@ use App\Lottery\DrawResultBatchStatus; use App\Services\Draw\DrawTickService; use App\Events\DrawStatusChangeBroadcast; use App\Services\Draw\DrawPlannerService; +use App\Services\Draw\DrawHallSnapshotBuilder; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); @@ -747,9 +748,70 @@ test('lottery hall-countdown dispatches draw.countdown when using reverb connect 'broadcasting.connections.reverb.driver' => 'reverb', ]); + Draw::query()->create([ + 'draw_no' => '20260509-001', + 'business_date' => '2026-05-09', + 'sequence_no' => 1, + 'status' => DrawStatus::Open->value, + 'start_time' => now()->subMinutes(5), + 'close_time' => now()->addMinutes(20), + 'draw_time' => now()->addMinutes(25), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + $this->artisan('lottery:hall-countdown')->assertSuccessful(); - Event::assertDispatched(DrawCountdownBroadcast::class); + Event::assertDispatched( + DrawCountdownBroadcast::class, + fn (DrawCountdownBroadcast $event): bool => is_array($event->data) && isset($event->data['draw_no']), + ); +}); + +test('hall snapshot switches to next bettable draw when cooldown ended', function (): void { + Carbon::setTestNow(Carbon::parse('2026-05-10 12:00:00', 'UTC')); + + Draw::query()->create([ + 'draw_no' => '20260510-001', + 'business_date' => '2026-05-10', + 'sequence_no' => 1, + 'status' => DrawStatus::Cooldown->value, + 'start_time' => now()->subHours(3), + 'close_time' => now()->subHours(2), + 'draw_time' => now()->subHours(2), + 'cooling_end_time' => now()->subMinutes(5), + 'result_source' => 'rng', + 'current_result_version' => 1, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + $next = Draw::query()->create([ + 'draw_no' => '20260510-002', + 'business_date' => '2026-05-10', + 'sequence_no' => 2, + 'status' => DrawStatus::Pending->value, + 'start_time' => now()->subMinutes(1), + 'close_time' => now()->addMinutes(20), + 'draw_time' => now()->addMinutes(25), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + $target = app(DrawHallSnapshotBuilder::class)->resolveHallTarget(now()->utc()); + expect($target)->not->toBeNull() + ->and($target->draw_no)->toBe('20260510-002'); + + $payload = app(DrawHallSnapshotBuilder::class)->build(now()->utc()); + expect($payload['status'] ?? null)->toBe('open'); + + Carbon::setTestNow(); }); test('draw tick dispatches draw.status_change when hall draw_no or status changes', function (): void { diff --git a/tests/Feature/JackpotPlacementSettlementTest.php b/tests/Feature/JackpotPlacementSettlementTest.php index d222675..40088b6 100644 --- a/tests/Feature/JackpotPlacementSettlementTest.php +++ b/tests/Feature/JackpotPlacementSettlementTest.php @@ -79,6 +79,17 @@ function jackpotOpenDraw(string $drawNo): Draw ]); } +/** 迁移已 seed 默认 NPR 池,测试内用 upsert 避免 UNIQUE(currency_code) 冲突 */ +function jackpotUpsertPool(array $attrs): JackpotPool +{ + $currencyCode = (string) ($attrs['currency_code'] ?? 'NPR'); + + return JackpotPool::query()->updateOrCreate( + ['currency_code' => $currencyCode], + $attrs, + ); +} + function jackpotPublishResults(Draw $draw, string $firstNumber = '1234'): void { $batch = DrawResultBatch::query()->create([ @@ -115,7 +126,7 @@ function jackpotPublishResults(Draw $draw, string $firstNumber = '1234'): void } test('jackpot contributes on place and bursts on settle for first-prize straight', function (): void { - JackpotPool::query()->create([ + jackpotUpsertPool([ 'currency_code' => 'NPR', 'current_amount' => 0, 'contribution_rate' => '0.1000', @@ -238,7 +249,7 @@ test('jackpot contributes on place and bursts on settle for first-prize straight }); test('jackpot contribution respects switch and minimum bet threshold', function (): void { - JackpotPool::query()->create([ + jackpotUpsertPool([ 'currency_code' => 'NPR', 'current_amount' => 0, 'contribution_rate' => '0.1000', @@ -288,7 +299,7 @@ test('jackpot bursts by configured play combination trigger before threshold', f 'broadcasting.connections.reverb.driver' => 'reverb', ]); - JackpotPool::query()->create([ + jackpotUpsertPool([ 'currency_code' => 'NPR', 'current_amount' => 50_000, 'contribution_rate' => '0.0000', @@ -336,7 +347,7 @@ test('jackpot bursts by configured play combination trigger before threshold', f }); test('jackpot splits burst payout between multiple winners by bet amount', function (): void { - JackpotPool::query()->create([ + jackpotUpsertPool([ 'currency_code' => 'NPR', 'current_amount' => 90_000, 'contribution_rate' => '0.0000', @@ -413,7 +424,7 @@ test('jackpot summary and result payload expose pool amount and draw gap', funct 'is_reopened' => false, ]); - JackpotPool::query()->create([ + jackpotUpsertPool([ 'currency_code' => 'NPR', 'current_amount' => 123_456, 'contribution_rate' => '0.0100', diff --git a/tests/Feature/OperationalConfigAcceptanceTest.php b/tests/Feature/OperationalConfigAcceptanceTest.php index 03c834a..86de8e0 100644 --- a/tests/Feature/OperationalConfigAcceptanceTest.php +++ b/tests/Feature/OperationalConfigAcceptanceTest.php @@ -80,9 +80,7 @@ test('§12.6 published play limits are visible on public effective catalog witho 'category' => $t->category, 'dimension' => $t->dimension, 'bet_mode' => $t->bet_mode, - 'display_name_zh' => $t->display_name_zh ?? $t->play_code, - 'display_name_en' => $t->display_name_en, - 'display_name_ne' => $t->display_name_ne, + 'display_name' => $t->display_name ?? $t->play_code, 'is_enabled' => true, 'min_bet_amount' => 777, 'max_bet_amount' => 400_000_000, @@ -271,9 +269,7 @@ test('§12.6 published play config controls master_enabled on public catalog wit 'category' => $r['category'], 'dimension' => $r['dimension'], 'bet_mode' => $r['bet_mode'], - 'display_name_zh' => $r['display_name_zh'], - 'display_name_en' => $r['display_name_en'], - 'display_name_ne' => $r['display_name_ne'], + 'display_name' => $r['display_name'], 'is_enabled' => $r['is_enabled'], 'min_bet_amount' => (int) $r['min_bet_amount'], 'max_bet_amount' => (int) $r['max_bet_amount'], @@ -381,9 +377,7 @@ test('§5 play_config publish is audited', function (): void { 'category' => $t->category, 'dimension' => $t->dimension, 'bet_mode' => $t->bet_mode, - 'display_name_zh' => $t->display_name_zh ?? $t->play_code, - 'display_name_en' => $t->display_name_en, - 'display_name_ne' => $t->display_name_ne, + 'display_name' => $t->display_name ?? $t->play_code, 'is_enabled' => true, 'min_bet_amount' => 100, 'max_bet_amount' => 500_000_000, @@ -404,6 +398,35 @@ test('§5 play_config publish is audited', function (): void { )->toBeTrue(); }); +test('play type patch toggles active config and broadcasts instantly', function (): void { + Event::fake([PlayToggleBroadcast::class]); + config([ + 'broadcasting.default' => 'reverb', + 'broadcasting.connections.reverb.driver' => 'reverb', + ]); + + $token = acceptanceMintAdminToken(); + $auth = ['Authorization' => 'Bearer '.$token]; + + $this->patchJson('/api/v1/admin/play-types/big', ['is_enabled' => false], $auth) + ->assertOk() + ->assertJsonPath('data.play_code', 'big') + ->assertJsonPath('data.is_enabled', false); + + Event::assertDispatched( + PlayToggleBroadcast::class, + fn (PlayToggleBroadcast $event): bool => $event->playCode === 'big' && $event->enabled === false, + ); + + expect( + AuditLog::query() + ->where('module_code', 'play_config') + ->where('action_code', 'toggle_active') + ->where('target_id', 'big') + ->exists(), + )->toBeTrue(); +}); + test('§9 play_config publish broadcasts changed play toggles', function (): void { Event::fake([PlayToggleBroadcast::class]); config([ diff --git a/tests/Feature/OperationalConfigApiTest.php b/tests/Feature/OperationalConfigApiTest.php index 2fe2a56..2fbfc67 100644 --- a/tests/Feature/OperationalConfigApiTest.php +++ b/tests/Feature/OperationalConfigApiTest.php @@ -78,9 +78,7 @@ test('admin play config draft publish flow', function (): void { 'category' => $t->category, 'dimension' => $t->dimension, 'bet_mode' => $t->bet_mode, - 'display_name_zh' => $t->display_name_zh ?? $t->play_code, - 'display_name_en' => $t->display_name_en, - 'display_name_ne' => $t->display_name_ne, + 'display_name' => $t->display_name ?? $t->play_code, 'is_enabled' => true, 'min_bet_amount' => 200, 'max_bet_amount' => 400_000_000, diff --git a/tests/Feature/PerformanceAcceptanceTest.php b/tests/Feature/PerformanceAcceptanceTest.php new file mode 100644 index 0000000..fe92830 --- /dev/null +++ b/tests/Feature/PerformanceAcceptanceTest.php @@ -0,0 +1,133 @@ +seed(CurrencySeeder::class); + $this->seed(PlayTypeSeeder::class); + $this->seed(OperationalConfigV1Seeder::class); + $this->seed(LotterySettingsSeeder::class); +}); + +test('draw planner schedules five minute draw_time gaps', function (): void { + config([ + 'lottery.draw.timezone' => 'UTC', + 'lottery.draw.interval_minutes' => 5, + 'lottery.draw.buffer_draws_ahead' => 12, + ]); + + $fixed = Carbon::parse('2026-05-25 00:00:00', 'UTC'); + app(DrawPlannerService::class)->ensureBuffer($fixed); + + $times = Draw::query() + ->whereNotNull('draw_time') + ->orderBy('draw_time') + ->limit(13) + ->pluck('draw_time') + ->map(fn ($t) => Carbon::parse($t)->utc()) + ->all(); + + expect(count($times))->toBeGreaterThanOrEqual(2); + + for ($i = 1; $i < count($times); $i++) { + $delta = (int) $times[$i]->diffInSeconds($times[$i - 1], absolute: true); + expect($delta)->toBe(300); + } +}); + +test('ticket place rejects bet when draw is closing', function (): void { + $player = perfPlayer(); + Draw::query()->create([ + 'draw_no' => '20260525-001', + 'business_date' => '2026-05-25', + 'sequence_no' => 1, + 'status' => DrawStatus::Closing->value, + 'start_time' => now()->subMinutes(10), + 'close_time' => now()->subMinute(), + 'draw_time' => now()->addMinute(), + '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' => '20260525-001', + 'currency_code' => 'NPR', + 'client_trace_id' => 'perf-sealed', + 'lines' => [['number' => '1234', 'play_code' => 'big', 'amount' => 10]], + ]) + ->assertStatus(400) + ->assertJsonPath('code', ErrorCode::DrawClosed->value); +}); + +test('ticket place rejects bet when close_time passed but status still open', function (): void { + $player = perfPlayer(); + Draw::query()->create([ + 'draw_no' => '20260525-002', + 'business_date' => '2026-05-25', + 'sequence_no' => 2, + 'status' => DrawStatus::Open->value, + 'start_time' => now()->subMinutes(10), + 'close_time' => now()->subSecond(), + 'draw_time' => now()->addMinute(), + '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' => '20260525-002', + 'currency_code' => 'NPR', + 'client_trace_id' => 'perf-close-time', + 'lines' => [['number' => '5678', 'play_code' => 'big', 'amount' => 10]], + ]) + ->assertStatus(400) + ->assertJsonPath('code', ErrorCode::DrawClosed->value); +}); + +function perfPlayer(): Player +{ + $uniq = bin2hex(random_bytes(4)); + $player = Player::query()->create([ + 'site_code' => 'test', + 'site_player_id' => 'perf-'.$uniq, + 'username' => 'perf_'.$uniq, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'NPR', + 'balance' => 1_000_000, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + + return $player; +} diff --git a/tests/Feature/PlayerFoundationTest.php b/tests/Feature/PlayerFoundationTest.php index 7d2deca..53c9bf1 100644 --- a/tests/Feature/PlayerFoundationTest.php +++ b/tests/Feature/PlayerFoundationTest.php @@ -70,10 +70,12 @@ test('player me works with main site jwt when dev bypass is off', function () { 'status' => 0, ]); + $now = time(); $jwt = JWT::encode([ 'site_code' => 'main', 'site_player_id' => 'jwt-user-1', - 'exp' => time() + 3600, + 'iat' => $now, + 'exp' => $now + 300, ], 'jwt-test-secret', 'HS256'); $this->withHeader('Authorization', 'Bearer '.$jwt) @@ -88,10 +90,12 @@ test('jwt first successful login auto-registers player mapping', function () { expect(Player::query()->count())->toBe(0); + $now = time(); $jwt = JWT::encode([ 'site_code' => 'main', 'site_player_id' => 'brand-new-sso-1', - 'exp' => time() + 3600, + 'iat' => $now, + 'exp' => $now + 300, ], 'jwt-test-secret', 'HS256'); $this->withHeader('Authorization', 'Bearer '.$jwt) diff --git a/tests/Feature/RngSeedAuditTest.php b/tests/Feature/RngSeedAuditTest.php new file mode 100644 index 0000000..a7feb36 --- /dev/null +++ b/tests/Feature/RngSeedAuditTest.php @@ -0,0 +1,76 @@ +toBe($b) + ->and(strlen($a))->toBe(4) + ->and($a)->not->toBe($c); +}); + +test('rng seed encrypt decrypt roundtrip preserves hex', function (): void { + $seedHex = DrawRngSeedDerivation::generateSeedHex(); + $encrypted = DrawRngSeedDerivation::encryptSeedHex($seedHex); + + expect(DrawRngSeedDerivation::decryptSeedHex($encrypted))->toBe($seedHex) + ->and(DrawRngSeedDerivation::hashSeedHex($seedHex))->toBe(hash('sha256', $seedHex)); +}); + +test('admin rng run stores encrypted seed and passes batch audit verification', function (): void { + config(['lottery.draw.require_manual_review' => true]); + + $draw = Draw::query()->create([ + 'draw_no' => '20260525-rng-audit', + 'business_date' => '2026-05-25', + 'sequence_no' => 901, + 'status' => DrawStatus::Closed->value, + 'start_time' => now()->subMinutes(20), + 'close_time' => now()->subMinutes(5), + 'draw_time' => now()->subMinute(), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'rng_audit_admin', + 'name' => 'RNG Audit', + '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}/rng") + ->assertOk(); + + $batch = DrawResultBatch::query()->where('draw_id', $draw->id)->firstOrFail(); + + expect($batch->source_type)->toBe('rng') + ->and($batch->rng_seed_hash)->not->toBeEmpty() + ->and($batch->raw_seed_encrypted)->not->toBeEmpty() + ->and($batch->items()->count())->toBe(23); + + $seedHex = DrawRngSeedDerivation::decryptSeedHex((string) $batch->raw_seed_encrypted); + expect(DrawRngSeedDerivation::hashSeedHex($seedHex))->toBe($batch->rng_seed_hash) + ->and(DrawRngSeedDerivation::verifyBatchAudit($batch->fresh(['items']), $draw->fresh()))->toBeTrue(); +}); diff --git a/tests/Feature/SettlementPhase145AcceptanceTest.php b/tests/Feature/SettlementPhase145AcceptanceTest.php index ae4a935..52eab79 100644 --- a/tests/Feature/SettlementPhase145AcceptanceTest.php +++ b/tests/Feature/SettlementPhase145AcceptanceTest.php @@ -14,6 +14,7 @@ use App\Models\TicketItem; use App\Lottery\DrawStatus; use App\Models\JackpotPool; use App\Models\TicketOrder; +use App\Models\OddsItem; use App\Models\PlayerWallet; use App\Models\DrawResultItem; use App\Models\DrawResultBatch; @@ -413,18 +414,238 @@ test('module 6 suffix plays settle once per ticket item instead of once per expa } }); +test('module 6 abc suffix plays pick best tier when multiple prize tiers share the same suffix', function (): void { + $cases = [ + [ + 'play' => 'pos_3abc', + 'number' => '234', + 'board' => fn (string $t, int $i): string => match ($t) { + 'first' => '1234', + 'second' => '5234', + 'third' => '9234', + default => p145_board_without_8888($t, $i), + }, + 'expected_tier' => 'first', + ], + [ + 'play' => 'pos_3abc', + 'number' => '234', + 'board' => fn (string $t, int $i): string => match ($t) { + 'first' => '1567', + 'second' => '5234', + 'third' => '8234', + default => p145_board_without_8888($t, $i), + }, + 'expected_tier' => 'second', + ], + [ + 'play' => 'pos_2abc', + 'number' => '99', + 'board' => fn (string $t, int $i): string => match ($t) { + 'first' => '8899', + 'second' => '2299', + 'third' => '1199', + default => p145_board_without_8888($t, $i), + }, + 'expected_tier' => 'first', + ], + ]; + + foreach ($cases as $case) { + $player = p145_player(80_000_000); + $drawNo = p145_next_draw_no(); + $draw = p145_draw($drawNo, random_int(1, 99_999)); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => $drawNo, + 'currency_code' => 'NPR', + 'client_trace_id' => 'module6-multi-tier-'.$case['play'].'-'.$case['expected_tier'].'-'.uniqid('', true), + 'lines' => [ + ['number' => $case['number'], 'play_code' => $case['play'], 'amount' => 10_000], + ], + ]) + ->assertOk(); + + $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); + $expectedWin = (int) floor(10_000 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$case['expected_tier']] / 10_000); + + p145_publish_board($draw, $case['board']); + $draw->forceFill([ + 'status' => DrawStatus::Settling->value, + 'current_result_version' => 1, + ])->save(); + + expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()), $case['play'])->toBeTrue(); + p145_approve_and_payout($draw); + + $item->refresh(); + $detail = TicketSettlementDetail::query()->where('ticket_item_id', $item->id)->firstOrFail(); + + expect($item->status)->toBe('settled_win', $case['play']) + ->and((int) $item->win_amount)->toBe($expectedWin, $case['play']) + ->and($detail->matched_prize_tier)->toBe($case['expected_tier'], $case['play']); + } +}); + +test('module 6 ibox sums payout across combinations hitting different prize tiers', function (): void { + $player = p145_player(80_000_000); + $drawNo = p145_next_draw_no(); + $draw = p145_draw($drawNo, random_int(1, 99_999)); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => $drawNo, + 'currency_code' => 'NPR', + 'client_trace_id' => 'module6-ibox-multi-tier', + 'lines' => [ + ['number' => '1122', 'play_code' => 'ibox', 'amount' => 100], + ], + ]) + ->assertOk(); + + $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); + $deduct = (int) $item->actual_deduct_amount; + expect($deduct)->toBe(600) + ->and((int) $item->combination_count)->toBe(6); + + $unitWinFirst = (int) floor(100 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['first'] / 10_000); + $unitWinStarter = (int) floor(100 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['starter'] / 10_000); + $expectedWin = $unitWinFirst + $unitWinStarter; + + p145_publish_board($draw, function (string $t, int $i): string { + return match ($t) { + 'first' => '1212', + 'starter' => $i === 0 ? '2121' : sprintf('71%02d', $i), + default => p145_board_without_8888($t, $i), + }; + }); + + $draw->forceFill([ + 'status' => DrawStatus::Settling->value, + 'current_result_version' => 1, + ])->save(); + + expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); + p145_approve_and_payout($draw); + + $item->refresh(); + $detail = TicketSettlementDetail::query()->where('ticket_item_id', $item->id)->firstOrFail(); + $matchLines = is_array($detail->match_detail_json) + ? ($detail->match_detail_json['lines'] ?? []) + : []; + + expect($item->status)->toBe('settled_win') + ->and((int) $item->win_amount)->toBe($expectedWin) + ->and($detail->matched_prize_tier)->toBe('first') + ->and(count($matchLines))->toBe(2); + + $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); + expect((int) $wallet->balance)->toBe(80_000_000 - $deduct + $expectedWin); +}); + +test('module 6 mbox remainder deducts floored total and settles win on per-combination unit amount', function (): void { + $player = p145_player(80_000_000); + $drawNo = p145_next_draw_no(); + $draw = p145_draw($drawNo, random_int(1, 99_999)); + $rawAmount = 10_001; + $comboCount = 24; + $unitBet = intdiv($rawAmount, $comboCount); + $expectedDeduct = $unitBet * $comboCount; + $expectedRemainder = $rawAmount - $expectedDeduct; + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => $drawNo, + 'currency_code' => 'NPR', + 'client_trace_id' => 'module6-mbox-remainder-win', + 'lines' => [ + ['number' => '1234', 'play_code' => 'mbox', 'amount' => $rawAmount], + ], + ]) + ->assertOk(); + + $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); + $ruleSnapshot = is_array($item->rule_snapshot_json) ? $item->rule_snapshot_json : []; + + expect((int) $item->combination_count)->toBe($comboCount) + ->and((int) $item->unit_bet_amount)->toBe($unitBet) + ->and((int) $item->total_bet_amount)->toBe($expectedDeduct) + ->and((int) $item->actual_deduct_amount)->toBe($expectedDeduct) + ->and($ruleSnapshot['rounding_refund_amount'] ?? null)->toBe($expectedRemainder); + + $expectedWin = (int) floor($unitBet * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['first'] / 10_000); + + p145_publish_board($draw, fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i)); + $draw->forceFill([ + 'status' => DrawStatus::Settling->value, + 'current_result_version' => 1, + ])->save(); + + expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); + p145_approve_and_payout($draw); + + $item->refresh(); + expect($item->status)->toBe('settled_win') + ->and((int) $item->win_amount)->toBe($expectedWin); + + $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); + expect((int) $wallet->balance)->toBe(80_000_000 - $expectedDeduct + $expectedWin); +}); + +test('module 6 mbox remainder is not refunded on losing settlement', function (): void { + $player = p145_player(80_000_000); + $drawNo = p145_next_draw_no(); + $draw = p145_draw($drawNo, random_int(1, 99_999)); + $rawAmount = 10_001; + $expectedDeduct = intdiv($rawAmount, 24) * 24; + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => $drawNo, + 'currency_code' => 'NPR', + 'client_trace_id' => 'module6-mbox-remainder-lose', + 'lines' => [ + ['number' => '1234', 'play_code' => 'mbox', 'amount' => $rawAmount], + ], + ]) + ->assertOk(); + + $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); + expect((int) $item->actual_deduct_amount)->toBe($expectedDeduct); + + p145_publish_board($draw, fn (string $t, int $i): string => p145_board_without_8888($t, $i)); + $draw->forceFill([ + 'status' => DrawStatus::Settling->value, + 'current_result_version' => 1, + ])->save(); + + expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); + p145_approve_and_payout($draw); + + $item->refresh(); + expect($item->status)->toBe('settled_lose') + ->and((int) $item->win_amount)->toBe(0); + + $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); + expect((int) $wallet->balance)->toBe(80_000_000 - $expectedDeduct); + expect(WalletTxn::query()->where('biz_type', 'settle_payout')->count())->toBe(0); +}); + test('§14.5 jackpot contributes on place and stays in pool when no first-prize burst', function (): void { - JackpotPool::query()->create([ - 'currency_code' => 'NPR', - 'current_amount' => 0, - 'contribution_rate' => '0.1000', - 'trigger_threshold' => 1, - 'payout_rate' => '1.0000', - 'force_trigger_draw_gap' => 0, - 'min_bet_amount' => 0, - 'status' => 1, - 'last_trigger_draw_id' => null, - ]); + JackpotPool::query()->updateOrCreate( + ['currency_code' => 'NPR'], + [ + 'current_amount' => 0, + 'contribution_rate' => '0.1000', + 'trigger_threshold' => 1, + 'payout_rate' => '1.0000', + 'force_trigger_draw_gap' => 0, + 'min_bet_amount' => 0, + 'status' => 1, + 'last_trigger_draw_id' => null, + ], + ); $player = p145_player(); $drawNo = p145_next_draw_no(); @@ -515,6 +736,117 @@ test('§14.5 placement partial failure only deducts successful lines when mid-or expect((int) $pool->locked_amount)->toBe(3000); }); +test('§14.5 settlement uses odds snapshot even if odds config changes after placement', function (): void { + $player = p145_player(80_000_000); + $drawNo = p145_next_draw_no(); + $draw = p145_draw($drawNo, random_int(1, 99_999)); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => $drawNo, + 'currency_code' => 'NPR', + 'client_trace_id' => 'p145-odds-snapshot-1', + 'lines' => [ + ['number' => '1234', 'play_code' => 'big', 'amount' => 10_000], + ], + ]) + ->assertOk(); + + $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); + $snapshotOdds = collect(is_array($item->odds_snapshot_json) ? $item->odds_snapshot_json : []) + ->firstWhere('prize_scope', 'first'); + expect($snapshotOdds)->not->toBeNull(); + + // 修改当前赔率配置:如果结算错误使用“实时配置”,这里会导致派奖金额变化。 + OddsItem::query() + ->where('play_code', 'big') + ->where('prize_scope', 'first') + ->where('currency_code', 'NPR') + ->update(['odds_value' => 10_000]); + + p145_publish_board($draw, fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i)); + $draw->forceFill([ + 'status' => DrawStatus::Settling->value, + 'current_result_version' => 1, + ])->save(); + + expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); + p145_approve_and_payout($draw); + + $item->refresh(); + $expectedWinBySnapshot = (int) floor(10_000 * ((int) $snapshotOdds['odds_value'] / 10_000)); + expect($item->status)->toBe('settled_win') + ->and((int) $item->win_amount)->toBe($expectedWinBySnapshot); +}); + +test('§14.5 settlement releases risk pool locks after payout (win and lose)', function (): void { + $player = p145_player(80_000_000); + $drawNo = p145_next_draw_no(); + $draw = p145_draw($drawNo, random_int(1, 99_999)); + + // 下注一单,确保产生风险池占用。 + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => $drawNo, + 'currency_code' => 'NPR', + 'client_trace_id' => 'p145-risk-release-1', + 'lines' => [ + ['number' => '8888', 'play_code' => 'big', 'amount' => 120], + ], + ]) + ->assertOk(); + + $pool = RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '8888')->firstOrFail(); + $cap = (int) $pool->total_cap_amount; + expect((int) $pool->locked_amount)->toBeGreaterThan(0) + ->and((int) $pool->remaining_amount)->toBeLessThan($cap); + + // 先走未中奖结算,验证释放。 + p145_publish_board($draw, fn (string $t, int $i): string => p145_board_without_8888($t, $i)); + $draw->forceFill([ + 'status' => DrawStatus::Settling->value, + 'current_result_version' => 1, + ])->save(); + + expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); + p145_approve_and_payout($draw); + + $poolAfterLose = $pool->fresh(); + expect((int) $poolAfterLose->locked_amount)->toBe(0) + ->and((int) $poolAfterLose->remaining_amount)->toBe($cap); + + // 再开一盘中奖结算,验证同样释放。 + $drawNo2 = p145_next_draw_no(); + $draw2 = p145_draw($drawNo2, random_int(1, 99_999)); + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => $drawNo2, + 'currency_code' => 'NPR', + 'client_trace_id' => 'p145-risk-release-2', + 'lines' => [ + ['number' => '1234', 'play_code' => 'big', 'amount' => 120], + ], + ]) + ->assertOk(); + + $pool2 = RiskPool::query()->where('draw_id', $draw2->id)->where('normalized_number', '1234')->firstOrFail(); + $cap2 = (int) $pool2->total_cap_amount; + expect((int) $pool2->locked_amount)->toBeGreaterThan(0); + + p145_publish_board($draw2, fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i)); + $draw2->forceFill([ + 'status' => DrawStatus::Settling->value, + 'current_result_version' => 1, + ])->save(); + + expect(app(SettlementOrchestrator::class)->trySettleDraw($draw2->fresh()))->toBeTrue(); + p145_approve_and_payout($draw2); + + $poolAfterWin = $pool2->fresh(); + expect((int) $poolAfterWin->locked_amount)->toBe(0) + ->and((int) $poolAfterWin->remaining_amount)->toBe($cap2); +}); + /** * 覆盖 {@see PlayTypeSeeder} 中已启用且注册匹配器的玩法(不含 `half_box`:种子为禁用)。 * `odd` / `even`:头奖取该注项首条展开组合号码,避免与 `expandOddEven` 枚举顺序硬编码耦合。 diff --git a/tests/Feature/TicketBettingApiTest.php b/tests/Feature/TicketBettingApiTest.php index 1347a74..0dbfc9c 100644 --- a/tests/Feature/TicketBettingApiTest.php +++ b/tests/Feature/TicketBettingApiTest.php @@ -150,6 +150,84 @@ test('module 6 box family expands combinations and computes amount semantics', f ->and($lines[7]['rule_snapshot_json']['rounding_refund_amount'] ?? null)->toBe(17); }); +test('module 6 mbox remainder splits amount evenly across preview and place', function (): void { + $player = ticketPlayerWithWallet(500_000); + ticketOpenDraw(); + + $cases = [ + [ + 'number' => '1234', + 'amount' => 10_001, + 'combination_count' => 24, + 'unit_bet_amount' => 416, + 'total_bet_amount' => 9984, + 'rounding_refund_amount' => 17, + ], + [ + 'number' => '1122', + 'amount' => 601, + 'combination_count' => 6, + 'unit_bet_amount' => 100, + 'total_bet_amount' => 600, + 'rounding_refund_amount' => 1, + ], + ]; + + foreach ($cases as $index => $case) { + $resp = $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/preview', [ + 'draw_id' => '20260511-001', + 'currency_code' => 'NPR', + 'client_trace_id' => 'trace-module6-mbox-remainder-'.$index, + 'lines' => [ + ['number' => $case['number'], 'play_code' => 'mbox', 'amount' => $case['amount']], + ], + ]) + ->assertOk(); + + $line = $resp->json('data.lines.0'); + expect($line['combination_count'])->toBe($case['combination_count']) + ->and($line['total_bet_amount'])->toBe($case['total_bet_amount']) + ->and($line['actual_deduct_amount'])->toBe($case['total_bet_amount']) + ->and($line['rule_snapshot_json']['rounding_refund_amount'] ?? null)->toBe($case['rounding_refund_amount']) + ->and(intdiv($case['amount'], $case['combination_count']))->toBe($case['unit_bet_amount']); + } + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => '20260511-001', + 'currency_code' => 'NPR', + 'client_trace_id' => 'trace-module6-mbox-place-remainder', + 'lines' => [ + ['number' => '1234', 'play_code' => 'mbox', 'amount' => 10_001], + ], + ]) + ->assertOk() + ->assertJsonPath('data.summary.total_bet_amount', 9984) + ->assertJsonPath('data.summary.total_actual_deduct', 9984); + + $item = TicketItem::query()->where('play_code', 'mbox')->firstOrFail(); + $ruleSnapshot = is_array($item->rule_snapshot_json) ? $item->rule_snapshot_json : []; + + expect((int) $item->unit_bet_amount)->toBe(416) + ->and((int) $item->total_bet_amount)->toBe(9984) + ->and((int) $item->actual_deduct_amount)->toBe(9984) + ->and($ruleSnapshot['rounding_refund_amount'] ?? null)->toBe(17); + + $comboAmounts = TicketCombination::query() + ->where('ticket_item_id', $item->id) + ->pluck('bet_amount') + ->map(fn ($amount) => (int) $amount) + ->unique() + ->values() + ->all(); + + expect($comboAmounts)->toBe([416]); + + $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); + expect((int) $wallet->balance)->toBe(500_000 - 9984); +}); + test('module 6 roll expands each R position and charges per expanded combination', function (): void { $player = ticketPlayerWithWallet(500_000); ticketOpenDraw(); @@ -537,6 +615,104 @@ test('ticket place rejects bet amount below configured minimum', function (): vo expect(TicketOrder::query()->count())->toBe(0); }); +test('ticket preview reports high risk warning without deducting wallet or creating order', function (): void { + $player = ticketPlayerWithWallet(); + $draw = ticketOpenDraw(); + + RiskPool::query()->create([ + 'draw_id' => $draw->id, + 'normalized_number' => '1234', + 'total_cap_amount' => 4000, + 'locked_amount' => 0, + 'remaining_amount' => 4000, + 'sold_out_status' => 0, + 'version' => 0, + ]); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/preview', [ + 'draw_id' => '20260511-001', + 'currency_code' => 'NPR', + 'client_trace_id' => 'trace-preview-risk-warning', + 'lines' => [ + ['number' => '1234', 'play_code' => 'big', 'amount' => 160], + ], + ]) + ->assertOk() + ->assertJsonPath('data.lines.0.risk_status', 'ok') + ->assertJsonPath('data.warnings.0.number_4d', '1234'); + + $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); + $pool = RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->firstOrFail(); + + expect((int) $wallet->balance)->toBe(200_000) + ->and((int) $pool->locked_amount)->toBe(0) + ->and((int) $pool->remaining_amount)->toBe(4000) + ->and(TicketOrder::query()->count())->toBe(0) + ->and(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(0); +}); + +test('ticket preview validates digit size dimension and slot rules', function (): void { + $player = ticketPlayerWithWallet(); + ticketOpenDraw(); + + $base = [ + 'draw_id' => '20260511-001', + 'currency_code' => 'NPR', + 'client_trace_id' => 'trace-digit-validation', + ]; + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/preview', $base + [ + 'lines' => [ + ['number' => '9', 'play_code' => 'digit_big', 'amount' => 100, 'digit_slot' => 3], + ], + ]) + ->assertStatus(400) + ->assertJsonPath('code', ErrorCode::BetInvalidPlayInput->value); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/preview', $base + [ + 'client_trace_id' => 'trace-digit-invalid-slot', + 'lines' => [ + ['number' => '9', 'play_code' => 'digit_big', 'amount' => 100, 'dimension' => 'D2', 'digit_slot' => 1], + ], + ]) + ->assertStatus(400) + ->assertJsonPath('code', ErrorCode::BetInvalidPlayInput->value); + + expect(TicketOrder::query()->count())->toBe(0); +}); + +test('ticket place persists valid 2d digit size slot snapshot', function (): void { + $player = ticketPlayerWithWallet(500_000); + ticketOpenDraw(); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => '20260511-001', + 'currency_code' => 'NPR', + 'client_trace_id' => 'trace-digit-d2-slot', + 'lines' => [ + ['number' => '9', 'play_code' => 'digit_big', 'amount' => 100, 'dimension' => 'D2', 'digit_slot' => 3], + ], + ]) + ->assertOk() + ->assertJsonPath('data.summary.success_count', 1) + ->assertJsonPath('data.items.0.status', 'pending_draw'); + + $item = TicketItem::query()->where('play_code', 'digit_big')->firstOrFail(); + $ruleSnapshot = is_array($item->rule_snapshot_json) ? $item->rule_snapshot_json : []; + + expect((int) $item->dimension)->toBe(2) + ->and((int) $item->digit_slot)->toBe(3) + ->and((int) $item->combination_count)->toBe(5000) + ->and($ruleSnapshot['dimension'] ?? null)->toBe('D2') + ->and($ruleSnapshot['digit_slot'] ?? null)->toBe(3) + ->and(TicketCombination::query()->where('ticket_item_id', $item->id)->where('number_4d', '0009')->exists())->toBeTrue() + ->and(TicketCombination::query()->where('ticket_item_id', $item->id)->where('number_4d', '0004')->exists())->toBeFalse(); +}); + test('ticket preview rejects invalid line amount per validation rules', function (): void { $player = ticketPlayerWithWallet(); ticketOpenDraw(); @@ -833,16 +1009,18 @@ test('ticket place reverses wallet and releases risk when post deduction confirm $player = ticketPlayerWithWallet(20_000); $draw = ticketOpenDraw(); - JackpotPool::query()->create([ - 'currency_code' => 'NPR', - 'current_amount' => 0, - 'contribution_rate' => 1, - 'trigger_threshold' => 0, - 'payout_rate' => 0, - 'force_trigger_draw_gap' => 0, - 'min_bet_amount' => 0, - 'status' => 1, - ]); + JackpotPool::query()->updateOrCreate( + ['currency_code' => 'NPR'], + [ + 'current_amount' => 0, + 'contribution_rate' => 1, + 'trigger_threshold' => 0, + 'payout_rate' => 0, + 'force_trigger_draw_gap' => 0, + 'min_bet_amount' => 0, + 'status' => 1, + ], + ); DB::statement("CREATE TRIGGER fail_jackpot_contribution_insert BEFORE INSERT ON jackpot_contributions BEGIN SELECT RAISE(ABORT, 'forced_confirmation_failure'); END"); $this->withHeader('Authorization', 'Bearer dev:'.$player->id)