diff --git a/README.md b/README.md index 27239f6..54a3687 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,28 @@ php artisan schedule:work 只做 HTTP / 降级轮询、不测 WebSocket 时:**终端 2、3 可先不开**;要完整大厅 WS,则 **三项都开**。 +## 后台权限体检 + +后台权限现在提供了一条可直接接入 CI 的体检命令,用来检查: + +- 受保护后台路由是否都已登记到 `admin_api_resources` +- `permission_required` 资源是否已绑定 `admin_api_resource_bindings` +- `admin_role_menu_actions` 与 `admin_role_api_resources` 是否漂移 + +本地可直接执行: + +```bash +php artisan lottery:admin-auth-audit +``` + +或通过 Composer 脚本执行: + +```bash +composer test:admin-auth +``` + +如果后续接 GitHub Actions、GitLab CI 或其他流水线,直接复用 `composer test:admin-auth` 即可。 + ## Contributing Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). diff --git a/app/Console/Commands/AuditAdminAuthorizationCommand.php b/app/Console/Commands/AuditAdminAuthorizationCommand.php new file mode 100644 index 0000000..60f4305 --- /dev/null +++ b/app/Console/Commands/AuditAdminAuthorizationCommand.php @@ -0,0 +1,230 @@ +option('skip-route-coverage')) { + $issues = array_merge($issues, $this->checkProtectedAdminRouteCoverage()); + } + + if (! (bool) $this->option('skip-resource-bindings')) { + $issues = array_merge($issues, $this->checkPermissionResourceBindings()); + } + + if (! (bool) $this->option('skip-role-resource-sync')) { + $issues = array_merge($issues, $this->checkRoleApiResourceSync()); + } + + if ($issues === []) { + $this->info('Admin authorization audit passed.'); + + return self::SUCCESS; + } + + $this->error(sprintf('Admin authorization audit found %d issue(s).', count($issues))); + foreach ($issues as $issue) { + $this->line(sprintf('- [%s] %s', $issue['type'], $issue['message'])); + } + + return self::FAILURE; + } + + /** + * @return list + */ + private function checkProtectedAdminRouteCoverage(): array + { + $protectedRoutes = $this->protectedAdminRoutes(); + if ($protectedRoutes === []) { + return [[ + 'type' => 'route_coverage', + 'message' => 'No protected admin routes were discovered from the current route collection.', + ]]; + } + + $resourceRouteNames = DB::table('admin_api_resources') + ->where('status', 1) + ->pluck('route_name') + ->filter(static fn ($routeName): bool => is_string($routeName) && $routeName !== '') + ->map(fn (string $routeName): string => $this->normalizeAdminRouteName($routeName)) + ->all(); + + $resourceRouteNameSet = array_fill_keys($resourceRouteNames, true); + $issues = []; + + foreach ($protectedRoutes as $route) { + if (! isset($resourceRouteNameSet[$route['name']])) { + $issues[] = [ + 'type' => 'route_coverage', + 'message' => sprintf( + 'Protected route `%s %s` (`%s`) has no active row in `admin_api_resources`.', + $route['method'], + $route['uri'], + $route['name'], + ), + ]; + } + } + + return $issues; + } + + /** + * @return list + */ + private function checkPermissionResourceBindings(): array + { + $rows = DB::table('admin_api_resources as ar') + ->leftJoin('admin_api_resource_bindings as arb', 'arb.api_resource_id', '=', 'ar.id') + ->select('ar.code', 'ar.route_name', DB::raw('count(arb.id) as binding_count')) + ->where('ar.status', 1) + ->where('ar.auth_mode', 'permission_required') + ->groupBy('ar.id', 'ar.code', 'ar.route_name') + ->havingRaw('count(arb.id) = 0') + ->orderBy('ar.code') + ->get(); + + $issues = []; + foreach ($rows as $row) { + $issues[] = [ + 'type' => 'resource_bindings', + 'message' => sprintf( + 'API resource `%s` (`%s`) requires permission but has no row in `admin_api_resource_bindings`.', + (string) $row->code, + (string) $row->route_name, + ), + ]; + } + + return $issues; + } + + /** + * @return list + */ + private function checkRoleApiResourceSync(): array + { + $expectedRows = DB::table('admin_role_menu_actions as rma') + ->join('admin_api_resource_bindings as arb', 'arb.menu_action_id', '=', 'rma.menu_action_id') + ->select('rma.role_id', 'arb.api_resource_id') + ->distinct() + ->get(); + + $expectedSet = []; + foreach ($expectedRows as $row) { + $expectedSet[$this->roleApiKey((int) $row->role_id, (int) $row->api_resource_id)] = true; + } + + $actualRows = DB::table('admin_role_api_resources') + ->select('role_id', 'api_resource_id') + ->get(); + + $actualSet = []; + foreach ($actualRows as $row) { + $actualSet[$this->roleApiKey((int) $row->role_id, (int) $row->api_resource_id)] = true; + } + + $roleSlugs = DB::table('admin_roles')->pluck('slug', 'id')->all(); + $resourceCodes = DB::table('admin_api_resources')->pluck('code', 'id')->all(); + $issues = []; + + foreach (array_keys($expectedSet) as $key) { + if (isset($actualSet[$key])) { + continue; + } + + [$roleId, $resourceId] = array_map('intval', explode(':', $key, 2)); + $issues[] = [ + 'type' => 'role_resource_sync', + 'message' => sprintf( + 'Missing role-resource grant: role `%s` should include API resource `%s`.', + (string) ($roleSlugs[$roleId] ?? $roleId), + (string) ($resourceCodes[$resourceId] ?? $resourceId), + ), + ]; + } + + foreach (array_keys($actualSet) as $key) { + if (isset($expectedSet[$key])) { + continue; + } + + [$roleId, $resourceId] = array_map('intval', explode(':', $key, 2)); + $issues[] = [ + 'type' => 'role_resource_sync', + 'message' => sprintf( + 'Extra role-resource grant: role `%s` has API resource `%s` without any supporting action binding.', + (string) ($roleSlugs[$roleId] ?? $roleId), + (string) ($resourceCodes[$resourceId] ?? $resourceId), + ), + ]; + } + + return $issues; + } + + /** + * @return list + */ + private function protectedAdminRoutes(): array + { + $routes = []; + + /** @var IlluminateRoute $route */ + foreach (Route::getRoutes() as $route) { + $middleware = $route->gatherMiddleware(); + if (! in_array('lottery.admin', $middleware, true) + && ! in_array('App\Http\Middleware\EnsureAdminApi', $middleware, true) + ) { + continue; + } + + $name = $route->getName(); + if (! is_string($name) || $name === '') { + continue; + } + + $methods = array_values(array_filter( + $route->methods(), + static fn (string $method): bool => $method !== 'HEAD', + )); + + $routes[] = [ + 'name' => $this->normalizeAdminRouteName($name), + 'method' => $methods[0] ?? 'GET', + 'uri' => '/'.ltrim($route->uri(), '/'), + ]; + } + + usort($routes, static fn (array $a, array $b): int => [$a['uri'], $a['method']] <=> [$b['uri'], $b['method']]); + + return $routes; + } + + private function normalizeAdminRouteName(string $routeName): string + { + return preg_replace('/^(api\.v1\.admin\.)+/', 'api.v1.admin.', $routeName) ?? $routeName; + } + + private function roleApiKey(int $roleId, int $apiResourceId): string + { + return $roleId.':'.$apiResourceId; + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerTicketItemsIndexController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerTicketItemsIndexController.php index 92a6c33..f7a7d7c 100644 --- a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerTicketItemsIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerTicketItemsIndexController.php @@ -36,7 +36,7 @@ final class AdminPlayerTicketItemsIndexController extends Controller ]) ->orderByDesc('ticket_items.id'); - if ($drawNo !== '') { + if (is_string($drawNo) && $drawNo !== '') { $query->whereHas('draw', fn ($q) => $q->where('draw_no', $drawNo)); } diff --git a/app/Http/Middleware/EnsureAdminApiResourcePermission.php b/app/Http/Middleware/EnsureAdminApiResourcePermission.php new file mode 100644 index 0000000..27139ec --- /dev/null +++ b/app/Http/Middleware/EnsureAdminApiResourcePermission.php @@ -0,0 +1,88 @@ +lotteryAdmin(); + if (! $admin instanceof AdminUser) { + return ApiResponse::error( + trans('admin.unauthenticated', [], $request->lotteryLocale()), + ErrorCode::AdminUnauthenticated->value, + null, + 401, + ); + } + + $route = $request->route(); + $routeName = is_object($route) ? $route->getName() : null; + if (! is_string($routeName) || $routeName === '') { + return ApiResponse::error('后台路由缺少 route name,无法执行资源鉴权。', ErrorCode::InternalError->value, null, 500); + } + + $normalizedRouteName = $this->normalizeAdminRouteName($routeName); + $resource = DB::table('admin_api_resources') + ->where('route_name', $normalizedRouteName) + ->where('status', 1) + ->first(['id', 'code', 'auth_mode']); + + if ($resource === null) { + return ApiResponse::error( + sprintf('后台 API 资源未配置:%s', $normalizedRouteName), + ErrorCode::InternalError->value, + ['route_name' => $normalizedRouteName], + 500, + ); + } + + if ($resource->auth_mode === 'login_only') { + return $next($request); + } + + $permissionCodes = DB::table('admin_api_resource_bindings as arb') + ->join('admin_menu_actions as ma', 'ma.id', '=', 'arb.menu_action_id') + ->where('arb.api_resource_id', (int) $resource->id) + ->where('ma.status', 1) + ->pluck('ma.permission_code') + ->filter(static fn ($code): bool => is_string($code) && $code !== '') + ->values() + ->all(); + + if ($permissionCodes === []) { + return ApiResponse::error( + sprintf('后台 API 资源未绑定权限动作:%s', (string) $resource->code), + ErrorCode::InternalError->value, + ['resource_code' => $resource->code], + 500, + ); + } + + foreach ($permissionCodes as $permissionCode) { + if ($admin->hasAdminPermission((string) $permissionCode)) { + return $next($request); + } + } + + return ApiResponse::error( + trans('admin.permission_denied', [], $request->lotteryLocale()), + ErrorCode::AdminForbidden->value, + ['required_any_codes' => $permissionCodes, 'resource_code' => $resource->code], + 403, + ); + } + + private function normalizeAdminRouteName(string $routeName): string + { + return preg_replace('/^(api\.v1\.admin\.)+/', 'api.v1.admin.', $routeName) ?? $routeName; + } +} diff --git a/app/Models/TicketOrder.php b/app/Models/TicketOrder.php index aec358e..1ef1112 100644 --- a/app/Models/TicketOrder.php +++ b/app/Models/TicketOrder.php @@ -21,6 +21,9 @@ final class TicketOrder extends Model 'status', 'submit_source', 'client_trace_id', + 'play_config_version_no', + 'odds_version_no', + 'risk_cap_version_no', ]; protected function casts(): array @@ -32,6 +35,9 @@ final class TicketOrder extends Model 'total_rebate_amount' => 'integer', 'total_actual_deduct' => 'integer', 'total_estimated_payout' => 'integer', + 'play_config_version_no' => 'integer', + 'odds_version_no' => 'integer', + 'risk_cap_version_no' => 'integer', ]; } diff --git a/app/Services/Ticket/PlayCatalogResolver.php b/app/Services/Ticket/PlayCatalogResolver.php index 64a6b54..5ff9219 100644 --- a/app/Services/Ticket/PlayCatalogResolver.php +++ b/app/Services/Ticket/PlayCatalogResolver.php @@ -48,8 +48,9 @@ final class PlayCatalogResolver * 下注事务内:按固定顺序锁住当前生效的三套配置版本,与后台切版互斥;可选与预览戳比对。 * * @param array{play_config_version_no: int, odds_version_no: int, risk_cap_version_no: int}|null $expectedFromPreview + * @return array{play_config_version_no: int, odds_version_no: int, risk_cap_version_no: int} */ - public function lockActiveConfigVersionsForPlacement(?array $expectedFromPreview = null): void + public function lockActiveConfigVersionsForPlacement(?array $expectedFromPreview = null): array { $playV = PlayConfigVersion::query() ->where('status', ConfigVersionStatus::Active->value) @@ -76,6 +77,12 @@ final class PlayCatalogResolver throw new TicketOperationException('config_version_stale', ErrorCode::BetConfigStale->value); } } + + return [ + 'play_config_version_no' => (int) $playV->version_no, + 'odds_version_no' => (int) $oddsV->version_no, + 'risk_cap_version_no' => (int) $riskV->version_no, + ]; } /** diff --git a/app/Services/Ticket/RiskPoolService.php b/app/Services/Ticket/RiskPoolService.php index a200f7e..689b7b0 100644 --- a/app/Services/Ticket/RiskPoolService.php +++ b/app/Services/Ticket/RiskPoolService.php @@ -136,10 +136,10 @@ final class RiskPoolService $pool = $this->firstOrMakePool($drawId, $number4d); $key = $this->redisPoolKey($drawId, $number4d); - Redis::eval($this->initLua(), 1, $key, (int) $pool->total_cap_amount, (int) $pool->locked_amount); + Redis::eval($this->initLua(), 1, $key, (int) $pool->total_cap_amount, (int) $pool->locked_amount, (int) $pool->version); - $result = (int) Redis::eval($this->acquireLua(), 1, $key, $amount); - if ($result !== 1) { + $result = $this->normalizeLuaResult(Redis::eval($this->acquireLua(), 1, $key, $amount, (int) $pool->version)); + if (($result['code'] ?? null) !== 'OK') { throw new TicketOperationException('risk_sold_out', ErrorCode::RiskPoolSoldOut->value); } @@ -190,7 +190,7 @@ final class RiskPoolService { return <<<'LUA' if redis.call('EXISTS', KEYS[1]) == 0 then - redis.call('HMSET', KEYS[1], 'total', ARGV[1], 'locked', ARGV[2], 'remaining', ARGV[1] - ARGV[2]) + redis.call('HMSET', KEYS[1], 'total', ARGV[1], 'locked', ARGV[2], 'remaining', ARGV[1] - ARGV[2], 'version', ARGV[3]) end return 1 LUA; @@ -200,13 +200,25 @@ LUA; { return <<<'LUA' local amount = tonumber(ARGV[1]) +local expectedVersion = tonumber(ARGV[2]) +if amount == nil or amount <= 0 then + return {'INVALID_ARGUMENT', 0, 0, 0} +end +if redis.call('EXISTS', KEYS[1]) == 0 then + return {'POOL_NOT_INITIALIZED', 0, 0, 0} +end +local version = tonumber(redis.call('HGET', KEYS[1], 'version') or '0') +if expectedVersion ~= nil and version ~= expectedVersion then + return {'VERSION_CONFLICT', tonumber(redis.call('HGET', KEYS[1], 'remaining') or '0'), tonumber(redis.call('HGET', KEYS[1], 'locked') or '0'), version} +end local remaining = tonumber(redis.call('HGET', KEYS[1], 'remaining') or '0') if remaining < amount then - return 0 + return {'INSUFFICIENT_CAP', remaining, tonumber(redis.call('HGET', KEYS[1], 'locked') or '0'), version} end -redis.call('HINCRBY', KEYS[1], 'locked', amount) -redis.call('HINCRBY', KEYS[1], 'remaining', -amount) -return 1 +local locked = redis.call('HINCRBY', KEYS[1], 'locked', amount) +remaining = redis.call('HINCRBY', KEYS[1], 'remaining', -amount) +version = redis.call('HINCRBY', KEYS[1], 'version', 1) +return {'OK', remaining, locked, version} LUA; } @@ -265,6 +277,28 @@ LUA; } } + /** + * @return array{code:string, remaining:int, locked:int, version:int} + */ + private function normalizeLuaResult(mixed $result): array + { + if (! is_array($result)) { + return [ + 'code' => (int) $result === 1 ? 'OK' : 'INSUFFICIENT_CAP', + 'remaining' => 0, + 'locked' => 0, + 'version' => 0, + ]; + } + + return [ + 'code' => (string) ($result[0] ?? 'INSUFFICIENT_CAP'), + 'remaining' => (int) ($result[1] ?? 0), + 'locked' => (int) ($result[2] ?? 0), + 'version' => (int) ($result[3] ?? 0), + ]; + } + /** * @param list $locks */ diff --git a/app/Services/Ticket/TicketPlacementService.php b/app/Services/Ticket/TicketPlacementService.php index 4bf987c..abb7a20 100644 --- a/app/Services/Ticket/TicketPlacementService.php +++ b/app/Services/Ticket/TicketPlacementService.php @@ -6,6 +6,7 @@ use App\Models\Draw; use App\Models\Player; use App\Lottery\ErrorCode; use App\Models\TicketItem; +use App\Models\WalletTxn; use App\Lottery\DrawStatus; use App\Models\TicketOrder; use App\Models\PlayerWallet; @@ -31,6 +32,22 @@ final class TicketPlacementService public function place(Player $player, array $payload): array { $currencyCode = strtoupper((string) $payload['currency_code']); + $clientTraceId = isset($payload['client_trace_id']) && $payload['client_trace_id'] !== '' + ? (string) $payload['client_trace_id'] + : null; + + if ($clientTraceId !== null) { + $existing = TicketOrder::query() + ->where('player_id', $player->id) + ->where('client_trace_id', $clientTraceId) + ->whereIn('status', ['placed', 'partial_failed']) + ->first(); + + if ($existing !== null) { + return $this->responseForOrder($existing, null); + } + } + $expectedVersions = $payload['expected_config_versions'] ?? null; if (is_array($expectedVersions)) { $expectedVersions = [ @@ -59,7 +76,7 @@ final class TicketPlacementService throw new TicketOperationException('draw_closed', ErrorCode::DrawClosed->value); } - $this->catalogResolver->lockActiveConfigVersionsForPlacement($expectedVersions); + $configVersions = $this->catalogResolver->lockActiveConfigVersionsForPlacement($expectedVersions); $evaluatedLines = []; $totalBet = 0; @@ -137,6 +154,9 @@ final class TicketPlacementService 'status' => 'pending', 'submit_source' => 'h5', 'client_trace_id' => $payload['client_trace_id'] ?? null, + 'play_config_version_no' => $configVersions['play_config_version_no'], + 'odds_version_no' => $configVersions['odds_version_no'], + 'risk_cap_version_no' => $configVersions['risk_cap_version_no'], ]); $successfulItems = []; @@ -297,6 +317,7 @@ final class TicketPlacementService } $order->forceFill(['status' => 'refunded'])->save(); + $this->ticketWalletService->reverseBetDeduct($order); }); throw $e; @@ -304,6 +325,24 @@ final class TicketPlacementService $order = TicketOrder::query()->whereKey($order->id)->firstOrFail(); + return $this->responseForOrder($order, $balanceAfter); + } + + private function responseForOrder(TicketOrder $order, ?int $balanceAfter): array + { + $order = TicketOrder::query()->whereKey($order->id)->firstOrFail(); + $draw = Draw::query()->whereKey((int) $order->draw_id)->firstOrFail(); + $successCount = TicketItem::query()->where('order_id', $order->id)->where('status', 'success')->count(); + $failureCount = TicketItem::query()->where('order_id', $order->id)->where('status', 'failed')->count(); + if ($balanceAfter === null) { + $walletTxn = WalletTxn::query() + ->where('biz_type', 'bet_deduct') + ->where('biz_no', $order->order_no) + ->latest('id') + ->first(); + $balanceAfter = $walletTxn === null ? null : (int) $walletTxn->balance_after; + } + return [ 'order_no' => $order->order_no, 'draw' => [ @@ -315,8 +354,8 @@ final class TicketPlacementService 'total_rebate_amount' => (int) $order->total_rebate_amount, 'total_actual_deduct' => (int) $order->total_actual_deduct, 'total_estimated_payout' => (int) $order->total_estimated_payout, - 'success_count' => TicketItem::query()->where('order_id', $order->id)->where('status', 'success')->count(), - 'failure_count' => TicketItem::query()->where('order_id', $order->id)->where('status', 'failed')->count(), + 'success_count' => $successCount, + 'failure_count' => $failureCount, ], 'balance_after' => $balanceAfter, 'items' => TicketItem::query() diff --git a/app/Services/Ticket/TicketWalletService.php b/app/Services/Ticket/TicketWalletService.php index 9c35eb0..3c1abf6 100644 --- a/app/Services/Ticket/TicketWalletService.php +++ b/app/Services/Ticket/TicketWalletService.php @@ -69,6 +69,53 @@ final class TicketWalletService return $after; } + public function reverseBetDeduct(TicketOrder $order): void + { + $deductTxn = WalletTxn::query() + ->where('biz_type', 'bet_deduct') + ->where('biz_no', $order->order_no) + ->where('status', self::TXN_POSTED) + ->first(); + + if ($deductTxn === null) { + return; + } + + $idempotentKey = 'bet-reverse:'.$order->order_no; + if (WalletTxn::query()->where('biz_type', 'bet_reverse')->where('idempotent_key', $idempotentKey)->exists()) { + return; + } + + $wallet = PlayerWallet::query() + ->whereKey($deductTxn->wallet_id) + ->lockForUpdate() + ->firstOrFail(); + + $amount = (int) $deductTxn->amount; + $before = (int) $wallet->balance; + $after = $before + $amount; + $wallet->forceFill([ + 'balance' => $after, + 'version' => (int) $wallet->version + 1, + ])->save(); + + WalletTxn::query()->create([ + 'txn_no' => $this->newTxnNo(), + 'player_id' => (int) $deductTxn->player_id, + 'wallet_id' => (int) $deductTxn->wallet_id, + 'biz_type' => 'bet_reverse', + 'biz_no' => $order->order_no, + 'direction' => self::TXN_DIR_IN, + 'amount' => $amount, + 'balance_before' => $before, + 'balance_after' => $after, + 'status' => self::TXN_POSTED, + 'external_ref_no' => null, + 'idempotent_key' => $idempotentKey, + 'remark' => 'post_deduct_confirmation_failed', + ]); + } + /** * 结算派彩入账(产品文档:派彩写入钱包流水;幂等键按结算批次 + 玩家)。 */ diff --git a/app/Support/AdminApiResourceCatalog.php b/app/Support/AdminApiResourceCatalog.php new file mode 100644 index 0000000..5f1a2da --- /dev/null +++ b/app/Support/AdminApiResourceCatalog.php @@ -0,0 +1,167 @@ + + * }> + */ + public static function resources(): array + { + return array_map( + static fn (array $resource): array => [ + 'code' => $resource['code'], + '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'], + 'permission_codes' => self::permissionCodesForLegacySlugs($resource['legacy_permission_slugs'] ?? []), + ], + self::definitions(), + ); + } + + /** + * @param list $legacySlugs + * @return list + */ + private static function permissionCodesForLegacySlugs(array $legacySlugs): array + { + /** @var array> $legacyMap */ + $legacyMap = config('admin_permissions.legacy_map', []); + $codes = []; + foreach ($legacySlugs as $legacySlug) { + foreach (($legacyMap[$legacySlug] ?? []) as $permissionCode) { + if (is_string($permissionCode) && $permissionCode !== '') { + $codes[$permissionCode] = true; + } + } + } + + return array_keys($codes); + } + + /** + * @return list + * }> + */ + private static function definitions(): array + { + 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.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, 'legacy_permission_slugs' => ['prd.audit.all', 'prd.audit.self', 'prd.audit.finance']], + + ['code' => 'admin.admin-users.index', 'module_code' => 'system', 'name' => '管理员列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/admin-users', 'route_name' => 'api.v1.admin.admin-users.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.admin_user.manage']], + ['code' => 'admin.admin-users.store', 'module_code' => 'system', 'name' => '创建管理员', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/admin-users', 'route_name' => 'api.v1.admin.admin-users.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_user.manage']], + ['code' => 'admin.admin-users.show', 'module_code' => 'system', 'name' => '管理员详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/admin-users/{admin_user}', 'route_name' => 'api.v1.admin.admin-users.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.admin_user.manage']], + ['code' => 'admin.admin-users.update', 'module_code' => 'system', 'name' => '更新管理员', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/admin-users/{admin_user}', 'route_name' => 'api.v1.admin.admin-users.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_user.manage']], + ['code' => 'admin.admin-users.destroy', 'module_code' => 'system', 'name' => '删除管理员', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/admin-users/{admin_user}', 'route_name' => 'api.v1.admin.admin-users.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_user.manage']], + ['code' => 'admin.admin-users.permission-catalog', 'module_code' => 'system', 'name' => '管理员权限目录', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/admin-user-permission-catalog', 'route_name' => 'api.v1.admin.admin-users.permission-catalog', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.admin_user.manage']], + ['code' => 'admin.admin-users.permissions.sync', 'module_code' => 'system', 'name' => '管理员权限同步', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/admin-users/{admin_user}/permissions', 'route_name' => 'api.v1.admin.admin-users.permissions.sync', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_user.manage']], + ['code' => 'admin.admin-users.roles.sync', 'module_code' => 'system', 'name' => '管理员角色同步', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/admin-users/{admin_user}/roles', 'route_name' => 'api.v1.admin.admin-users.roles.sync', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_user.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.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.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, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.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, '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.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_result.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.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']], + + ['code' => 'admin.settlement-batches.index', 'module_code' => 'settlement', 'name' => '结算批次列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-batches', 'route_name' => 'api.v1.admin.settlement-batches.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view']], + ['code' => 'admin.settlement-batches.show', 'module_code' => 'settlement', 'name' => '结算批次详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-batches/{batch}', 'route_name' => 'api.v1.admin.settlement-batches.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view']], + ['code' => 'admin.settlement-batches.details', 'module_code' => 'settlement', 'name' => '结算批次明细', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-batches/{batch}/details', 'route_name' => 'api.v1.admin.settlement-batches.details', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view']], + ['code' => 'admin.settlement-batches.export', 'module_code' => 'settlement', 'name' => '导出结算批次', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-batches/{batch}/export', 'route_name' => 'api.v1.admin.settlement-batches.export', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view']], + ['code' => 'admin.settlement-batches.approve', 'module_code' => 'settlement', 'name' => '审核结算批次', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-batches/{batch}/approve', 'route_name' => 'api.v1.admin.settlement-batches.approve', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.payout.review']], + ['code' => 'admin.settlement-batches.reject', 'module_code' => 'settlement', 'name' => '驳回结算批次', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-batches/{batch}/reject', 'route_name' => 'api.v1.admin.settlement-batches.reject', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.payout.review']], + ['code' => 'admin.settlement-batches.payout', 'module_code' => 'settlement', 'name' => '执行派彩', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-batches/{batch}/payout', 'route_name' => 'api.v1.admin.settlement-batches.payout', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.payout.manage']], + + ['code' => 'admin.jackpot.pools.index', 'module_code' => 'jackpot', 'name' => '奖池列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/jackpot/pools', 'route_name' => 'api.v1.admin.jackpot.pools.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.jackpot.manage', 'prd.jackpot.view']], + ['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.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, 'legacy_permission_slugs' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs']], + ['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', 'prd.users.view_finance', 'prd.users.view_cs']], + ['code' => 'admin.players.show', 'module_code' => 'player_service', 'name' => '玩家详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players/{player}', 'route_name' => 'api.v1.admin.players.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs']], + ['code' => 'admin.players.update', 'module_code' => 'player_service', 'name' => '更新玩家', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/players/{player}', 'route_name' => 'api.v1.admin.players.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs']], + ['code' => 'admin.players.destroy', 'module_code' => 'player_service', 'name' => '删除玩家', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/players/{player}', 'route_name' => 'api.v1.admin.players.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs']], + ['code' => 'admin.players.freeze', 'module_code' => 'player_service', 'name' => '冻结玩家', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/players/{player}/freeze', 'route_name' => 'api.v1.admin.players.freeze', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs']], + ['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, 'legacy_permission_slugs' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs']], + ['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, 'legacy_permission_slugs' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs']], + ['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, 'legacy_permission_slugs' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.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']], + ['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.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']], + ['code' => 'admin.reconcile-jobs.store', 'module_code' => 'reconcile', 'name' => '创建对账任务', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/reconcile-jobs', 'route_name' => 'api.v1.admin.reconcile-jobs.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage']], + + ['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, 'legacy_permission_slugs' => ['prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player']], + ['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, 'legacy_permission_slugs' => ['prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player']], + ['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, 'legacy_permission_slugs' => ['prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player']], + ['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, 'legacy_permission_slugs' => ['prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player']], + ]; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 52354fc..bdbe01e 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -18,6 +18,7 @@ use App\Http\Middleware\EnsureAdminApi; use App\Http\Middleware\EnsurePlayerApi; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Auth\AuthenticationException; +use App\Http\Middleware\EnsureAdminApiResourcePermission; use App\Http\Middleware\EnsureAdminPermission; use Illuminate\Validation\ValidationException; use App\Http\Middleware\NegotiateLotteryLocale; @@ -48,6 +49,7 @@ return Application::configure(basePath: dirname(__DIR__)) // 后台 API 预留:Sanctum / RBAC 'lottery.admin' => EnsureAdminApi::class, 'admin.permission' => EnsureAdminPermission::class, + 'admin.api-resource' => EnsureAdminApiResourcePermission::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/composer.json b/composer.json index 6dd0611..155201e 100644 --- a/composer.json +++ b/composer.json @@ -63,6 +63,10 @@ "@php artisan config:clear --ansi @no_additional_args", "@php artisan test" ], + "test:admin-auth": [ + "@php artisan config:clear --ansi", + "@php artisan lottery:admin-auth-audit" + ], "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi" diff --git a/database/migrations/2026_05_18_090000_add_config_version_snapshots_to_ticket_orders.php b/database/migrations/2026_05_18_090000_add_config_version_snapshots_to_ticket_orders.php new file mode 100644 index 0000000..f005089 --- /dev/null +++ b/database/migrations/2026_05_18_090000_add_config_version_snapshots_to_ticket_orders.php @@ -0,0 +1,28 @@ +unsignedInteger('play_config_version_no')->default(0)->after('client_trace_id'); + $table->unsignedInteger('odds_version_no')->default(0)->after('play_config_version_no'); + $table->unsignedInteger('risk_cap_version_no')->default(0)->after('odds_version_no'); + }); + } + + public function down(): void + { + Schema::table('ticket_orders', function (Blueprint $table): void { + $table->dropColumn([ + 'play_config_version_no', + 'odds_version_no', + 'risk_cap_version_no', + ]); + }); + } +}; diff --git a/database/migrations/2026_05_18_120000_sync_complete_admin_api_resources.php b/database/migrations/2026_05_18_120000_sync_complete_admin_api_resources.php new file mode 100644 index 0000000..f0c6679 --- /dev/null +++ b/database/migrations/2026_05_18_120000_sync_complete_admin_api_resources.php @@ -0,0 +1,83 @@ +pluck('id', 'permission_code'); + + foreach (AdminApiResourceCatalog::resources() as $resource) { + $resourceId = DB::table('admin_api_resources') + ->where('code', $resource['code']) + ->value('id'); + + $payload = [ + 'module_code' => $resource['module_code'], + 'name' => $resource['name'], + 'http_method' => $resource['http_method'], + 'uri_pattern' => $resource['uri_pattern'], + 'route_name' => $resource['route_name'], + 'auth_mode' => $resource['auth_mode'], + 'is_audit_required' => $resource['is_audit_required'], + 'status' => 1, + 'meta_json' => null, + 'updated_at' => $now, + ]; + + if ($resourceId === null) { + $resourceId = DB::table('admin_api_resources')->insertGetId($payload + [ + 'code' => $resource['code'], + 'created_at' => $now, + ]); + } else { + DB::table('admin_api_resources') + ->where('id', (int) $resourceId) + ->update($payload); + } + + DB::table('admin_api_resource_bindings') + ->where('api_resource_id', (int) $resourceId) + ->delete(); + + foreach ($resource['permission_codes'] as $permissionCode) { + $menuActionId = $menuActionIds[$permissionCode] ?? null; + if ($menuActionId === null) { + continue; + } + + DB::table('admin_api_resource_bindings')->insert([ + 'api_resource_id' => (int) $resourceId, + 'menu_action_id' => (int) $menuActionId, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + DB::table('admin_role_api_resources')->delete(); + + $roleResourceRows = DB::table('admin_role_menu_actions as rma') + ->join('admin_api_resource_bindings as arb', 'arb.menu_action_id', '=', 'rma.menu_action_id') + ->select('rma.role_id', 'arb.api_resource_id') + ->distinct() + ->get(); + + foreach ($roleResourceRows as $row) { + DB::table('admin_role_api_resources')->insert([ + 'role_id' => (int) $row->role_id, + 'api_resource_id' => (int) $row->api_resource_id, + ]); + } + } + + public function down(): void + { + // 保持数据升级可逆风险最低:不在 down 中尝试删除资源,避免误删线上已使用授权关系。 + } +}; diff --git a/routes/api/v1/admin/config.php b/routes/api/v1/admin/config.php index bf0dd4c..8503a40 100644 --- a/routes/api/v1/admin/config.php +++ b/routes/api/v1/admin/config.php @@ -28,12 +28,12 @@ use App\Http\Controllers\Api\V1\Admin\AdminSettingController; */ // 玩法类型只读 -Route::middleware('admin.permission:prd.play_switch.manage|prd.odds.manage') +Route::middleware(['admin.api-resource', 'admin.permission:prd.play_switch.manage|prd.odds.manage']) ->get('play-types', PlayTypeIndexController::class) ->name('api.v1.admin.play-types.index'); // 玩法版本只读 -Route::middleware('admin.permission:prd.play_switch.manage|prd.odds.manage') +Route::middleware(['admin.api-resource', 'admin.permission:prd.play_switch.manage|prd.odds.manage']) ->prefix('config') ->name('api.v1.admin.config.') ->group(function (): void { @@ -45,7 +45,7 @@ Route::middleware('admin.permission:prd.play_switch.manage|prd.odds.manage') }); // 赔率/回水只读 -Route::middleware('admin.permission:prd.odds.manage|prd.rebate.manage|prd.rebate.view') +Route::middleware(['admin.api-resource', 'admin.permission:prd.odds.manage|prd.rebate.manage|prd.rebate.view']) ->prefix('config') ->name('api.v1.admin.config.') ->group(function (): void { @@ -57,7 +57,7 @@ Route::middleware('admin.permission:prd.odds.manage|prd.rebate.manage|prd.rebate }); // 封顶只读 -Route::middleware('admin.permission:prd.risk_cap.manage|prd.risk_cap.view') +Route::middleware(['admin.api-resource', 'admin.permission:prd.risk_cap.manage|prd.risk_cap.view']) ->prefix('config') ->name('api.v1.admin.config.') ->group(function (): void { @@ -69,7 +69,7 @@ Route::middleware('admin.permission:prd.risk_cap.manage|prd.risk_cap.view') }); // 玩法/赔率/封顶/Jackpot 配置写入 -Route::middleware('admin.permission:prd.play_switch.manage|prd.odds.manage|prd.risk_cap.manage|prd.rebate.manage|prd.jackpot.manage') +Route::middleware(['admin.api-resource', 'admin.permission:prd.play_switch.manage|prd.odds.manage|prd.risk_cap.manage|prd.rebate.manage|prd.jackpot.manage']) ->group(function (): void { Route::patch('play-types/{play_code}', PlayTypePatchController::class) ->where('play_code', '[a-z0-9_]+') @@ -120,7 +120,7 @@ Route::middleware('admin.permission:prd.play_switch.manage|prd.odds.manage|prd.r }); // 通用 KV 设置(钱包限额等) -Route::middleware('admin.permission:prd.wallet_reconcile.manage') +Route::middleware(['admin.api-resource', 'admin.permission:prd.wallet_reconcile.manage']) ->prefix('settings') ->name('api.v1.admin.settings.') ->group(function (): void { diff --git a/routes/api/v1/admin/core.php b/routes/api/v1/admin/core.php index c3e3a7f..bedaf18 100644 --- a/routes/api/v1/admin/core.php +++ b/routes/api/v1/admin/core.php @@ -10,12 +10,16 @@ use App\Http\Controllers\Api\V1\Admin\PingController as AdminPingController; */ // 连通性探测 -Route::get('ping', AdminPingController::class)->name('api.v1.admin.ping'); +Route::get('ping', AdminPingController::class) + ->middleware('admin.api-resource') + ->name('api.v1.admin.ping'); // 首页仪表盘 -Route::get('dashboard', AdminDashboardController::class)->name('api.v1.admin.dashboard'); +Route::get('dashboard', AdminDashboardController::class) + ->middleware('admin.api-resource') + ->name('api.v1.admin.dashboard'); // 审计日志 -Route::middleware('admin.permission:prd.audit.all|prd.audit.self|prd.audit.finance') +Route::middleware(['admin.api-resource', 'admin.permission:prd.audit.all|prd.audit.self|prd.audit.finance']) ->get('audit-logs', AuditLogIndexController::class) ->name('api.v1.admin.audit-logs.index'); diff --git a/routes/api/v1/admin/draw.php b/routes/api/v1/admin/draw.php index 24cfe18..864ec38 100644 --- a/routes/api/v1/admin/draw.php +++ b/routes/api/v1/admin/draw.php @@ -30,7 +30,7 @@ use App\Http\Controllers\Api\V1\Admin\Settlement\AdminSettlementBatchDetailsCont */ // 开奖结果查看 + 风控监控 -Route::middleware('admin.permission:prd.draw_result.manage|prd.draw_result.view') +Route::middleware(['admin.api-resource', 'admin.permission:prd.draw_result.manage|prd.draw_result.view']) ->group(function (): void { Route::get('draws', AdminDrawIndexController::class) ->name('api.v1.admin.draws.index'); @@ -50,7 +50,7 @@ Route::middleware('admin.permission:prd.draw_result.manage|prd.draw_result.view' }); // 开奖结果录入(发布批次) -Route::middleware('admin.permission:prd.draw_result.manage') +Route::middleware(['admin.api-resource', 'admin.permission:prd.draw_result.manage']) ->group(function (): void { Route::post('draws/{draw}/result-batches', DrawManualResultBatchStoreController::class) ->name('api.v1.admin.draws.result-batches.store'); @@ -75,12 +75,12 @@ Route::middleware('admin.permission:prd.draw_result.manage') }); // 派彩确认 -Route::middleware('admin.permission:prd.payout.manage|prd.payout.review') +Route::middleware(['admin.api-resource', 'admin.permission:prd.payout.manage|prd.payout.review']) ->post('draws/{draw}/settlement/run', DrawSettlementRunController::class) ->name('api.v1.admin.draws.settlement.run'); // 结算批次查看 -Route::middleware('admin.permission:prd.payout.manage|prd.payout.review|prd.payout.view') +Route::middleware(['admin.api-resource', 'admin.permission:prd.payout.manage|prd.payout.review|prd.payout.view']) ->group(function (): void { Route::get('settlement-batches', AdminSettlementBatchIndexController::class) ->name('api.v1.admin.settlement-batches.index'); @@ -92,7 +92,7 @@ Route::middleware('admin.permission:prd.payout.manage|prd.payout.review|prd.payo ->name('api.v1.admin.settlement-batches.export'); }); -Route::middleware('admin.permission:prd.payout.review') +Route::middleware(['admin.api-resource', 'admin.permission:prd.payout.review']) ->group(function (): void { Route::post('settlement-batches/{batch}/approve', AdminSettlementBatchApproveController::class) ->name('api.v1.admin.settlement-batches.approve'); @@ -100,6 +100,6 @@ Route::middleware('admin.permission:prd.payout.review') ->name('api.v1.admin.settlement-batches.reject'); }); -Route::middleware('admin.permission:prd.payout.manage') +Route::middleware(['admin.api-resource', 'admin.permission:prd.payout.manage']) ->post('settlement-batches/{batch}/payout', AdminSettlementBatchPayoutController::class) ->name('api.v1.admin.settlement-batches.payout'); diff --git a/routes/api/v1/admin/jackpot.php b/routes/api/v1/admin/jackpot.php index a7836b1..da20c28 100644 --- a/routes/api/v1/admin/jackpot.php +++ b/routes/api/v1/admin/jackpot.php @@ -12,7 +12,7 @@ use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotContributionIndexContr */ // 奖池查看 -Route::middleware('admin.permission:prd.jackpot.manage|prd.jackpot.view') +Route::middleware(['admin.api-resource', 'admin.permission:prd.jackpot.manage|prd.jackpot.view']) ->group(function (): void { Route::get('jackpot/pools', AdminJackpotPoolIndexController::class) ->name('api.v1.admin.jackpot.pools.index'); @@ -23,7 +23,7 @@ Route::middleware('admin.permission:prd.jackpot.manage|prd.jackpot.view') }); // 奖池修改(仅管理权限) -Route::middleware('admin.permission:prd.jackpot.manage') +Route::middleware(['admin.api-resource', 'admin.permission:prd.jackpot.manage']) ->group(function (): void { Route::put('jackpot/pools/{pool}', AdminJackpotPoolUpdateController::class) ->name('api.v1.admin.jackpot.pools.update'); diff --git a/routes/api/v1/admin/player.php b/routes/api/v1/admin/player.php index b21ca5e..edbf050 100644 --- a/routes/api/v1/admin/player.php +++ b/routes/api/v1/admin/player.php @@ -14,7 +14,7 @@ use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerTicketItemsIndexControll /** * 管理员玩家管理路由。 */ -Route::middleware('admin.permission:prd.users.manage|prd.users.view_finance|prd.users.view_cs') +Route::middleware(['admin.api-resource', 'admin.permission:prd.users.manage|prd.users.view_finance|prd.users.view_cs']) ->group(function (): void { Route::get('players', AdminPlayerIndexController::class) ->name('api.v1.admin.players.index'); diff --git a/routes/api/v1/admin/report.php b/routes/api/v1/admin/report.php index 0d4914d..86abfc2 100644 --- a/routes/api/v1/admin/report.php +++ b/routes/api/v1/admin/report.php @@ -9,7 +9,7 @@ use App\Http\Controllers\Api\V1\Admin\Reports\ReportJobStoreController; /** * 管理员报表路由。 */ -Route::middleware('admin.permission:prd.report.all|prd.report.risk|prd.report.finance|prd.report.player') +Route::middleware(['admin.api-resource', 'admin.permission:prd.report.all|prd.report.risk|prd.report.finance|prd.report.player']) ->group(function (): void { Route::get('report-jobs', ReportJobIndexController::class) ->name('api.v1.admin.report-jobs.index'); diff --git a/routes/api/v1/admin/user.php b/routes/api/v1/admin/user.php index 5f14d9b..e70ea45 100644 --- a/routes/api/v1/admin/user.php +++ b/routes/api/v1/admin/user.php @@ -13,7 +13,7 @@ use App\Http\Controllers\Api\V1\Admin\User\AdminUserPermissionSyncController; /** * 管理员账号与权限管理路由。 */ -Route::middleware('admin.permission:prd.admin_user.manage') +Route::middleware(['admin.api-resource', 'admin.permission:prd.admin_user.manage']) ->group(function (): void { Route::get('admin-users', AdminUserIndexController::class) ->name('api.v1.admin.admin-users.index'); diff --git a/routes/api/v1/admin/wallet.php b/routes/api/v1/admin/wallet.php index b5c33dd..453c215 100644 --- a/routes/api/v1/admin/wallet.php +++ b/routes/api/v1/admin/wallet.php @@ -14,7 +14,7 @@ use App\Http\Controllers\Api\V1\Admin\Wallet\WalletTransactionListController; */ // 钱包对账查看 -Route::middleware('admin.permission:prd.wallet_reconcile.manage|prd.wallet_reconcile.view|prd.wallet_reconcile.view_cs') +Route::middleware(['admin.api-resource', 'admin.permission:prd.wallet_reconcile.manage|prd.wallet_reconcile.view|prd.wallet_reconcile.view_cs']) ->group(function (): void { Route::get('wallet/transfer-orders', TransferOrderListController::class) ->name('api.v1.admin.wallet.transfer-orders'); @@ -30,7 +30,7 @@ Route::middleware('admin.permission:prd.wallet_reconcile.manage|prd.wallet_recon }); // 对账操作(仅管理权限) -Route::middleware('admin.permission:prd.wallet_reconcile.manage') +Route::middleware(['admin.api-resource', 'admin.permission:prd.wallet_reconcile.manage']) ->group(function (): void { Route::post('wallet/transfer-orders/{transfer_no}/reverse', [TransferOrderReconcileController::class, 'reverse']) ->name('api.v1.admin.wallet.transfer-orders.reverse'); @@ -39,6 +39,6 @@ Route::middleware('admin.permission:prd.wallet_reconcile.manage') }); // 对账任务创建(仅管理权限) -Route::middleware('admin.permission:prd.wallet_reconcile.manage') +Route::middleware(['admin.api-resource', 'admin.permission:prd.wallet_reconcile.manage']) ->post('reconcile-jobs', ReconcileJobStoreController::class) ->name('api.v1.admin.reconcile-jobs.store'); diff --git a/tests/Feature/AdminApiResourcePermissionMiddlewareTest.php b/tests/Feature/AdminApiResourcePermissionMiddlewareTest.php new file mode 100644 index 0000000..5eb0818 --- /dev/null +++ b/tests/Feature/AdminApiResourcePermissionMiddlewareTest.php @@ -0,0 +1,115 @@ +create([ + 'username' => $username, + 'name' => 'Admin '.$username, + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + if ($permissionSlugs !== []) { + $role = AdminRole::query()->create([ + 'slug' => 'role_'.$username, + 'name' => 'Role '.$username, + ]); + + $codes = []; + foreach ($permissionSlugs as $slug) { + $codes = array_merge($codes, AdminPermissionBridge::menuActionCodesForLegacy($slug)); + } + $codes = array_values(array_unique($codes)); + + $ids = DB::table('admin_menu_actions') + ->whereIn('permission_code', $codes) + ->where('status', 1) + ->pluck('id') + ->all(); + + foreach ($ids as $mid) { + DB::table('admin_role_menu_actions')->insert([ + 'role_id' => $role->id, + 'menu_action_id' => (int) $mid, + ]); + } + + $siteId = AdminUser::defaultAdminSiteId(); + $admin->roles()->sync([ + (int) $role->id => [ + 'site_id' => $siteId, + 'granted_at' => now(), + ], + ]); + } + + return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; +} + +test('admin api resource middleware allows login only resource for signed in admin', function (): void { + $token = mintAdminTokenWithLegacySlugs('resource_ping', []); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/ping') + ->assertOk() + ->assertJsonPath('code', ErrorCode::Success->value) + ->assertJsonPath('data.scope', 'admin'); +}); + +test('admin api resource middleware denies protected report resource without permission', function (): void { + $token = mintAdminTokenWithLegacySlugs('resource_denied', []); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/report-jobs') + ->assertForbidden() + ->assertJsonPath('code', ErrorCode::AdminForbidden->value); +}); + +test('admin api resource middleware allows protected report resource with mapped permission', function (): void { + $token = mintAdminTokenWithLegacySlugs('resource_reporter', ['prd.report.player']); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/report-jobs') + ->assertOk() + ->assertJsonPath('code', ErrorCode::Success->value) + ->assertJsonPath('data.meta.total', 0); +}); + +test('admin api resource middleware denies wallet reconcile resource without permission', function (): void { + $token = mintAdminTokenWithLegacySlugs('resource_wallet_denied', []); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/wallet/transactions') + ->assertForbidden() + ->assertJsonPath('code', ErrorCode::AdminForbidden->value); +}); + +test('admin api resource middleware allows wallet reconcile resource with mapped permission', function (): void { + $token = mintAdminTokenWithLegacySlugs('resource_wallet_viewer', ['prd.wallet_reconcile.view']); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/wallet/transactions') + ->assertOk() + ->assertJsonPath('code', ErrorCode::Success->value) + ->assertJsonPath('data.total', 0); +}); + +test('admin api resource middleware denies jackpot resource without permission', function (): void { + $token = mintAdminTokenWithLegacySlugs('resource_jackpot_denied', []); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/jackpot/pools') + ->assertForbidden() + ->assertJsonPath('code', ErrorCode::AdminForbidden->value); +}); diff --git a/tests/Feature/AdminAuthorizationAuditCommandTest.php b/tests/Feature/AdminAuthorizationAuditCommandTest.php new file mode 100644 index 0000000..75e62b5 --- /dev/null +++ b/tests/Feature/AdminAuthorizationAuditCommandTest.php @@ -0,0 +1,48 @@ +where('code', 'admin.config.play-versions.index') + ->delete(); + + $this->artisan('lottery:admin-auth-audit') + ->expectsOutputToContain('Admin authorization audit found') + ->expectsOutputToContain('[route_coverage]') + ->assertExitCode(1); +}); + +test('admin authorization audit passes on the default authorization catalog', function (): void { + $this->artisan('lottery:admin-auth-audit') + ->expectsOutputToContain('Admin authorization audit passed.') + ->assertExitCode(0); +}); + +test('admin authorization audit detects role api resource drift', function (): void { + $this->seed(AdminRbacAndUserSeeder::class); + + $resourceId = DB::table('admin_api_resources') + ->where('code', 'admin.audit.index') + ->value('id'); + + $roleId = DB::table('admin_roles') + ->where('slug', 'finance') + ->value('id'); + + expect($resourceId)->not->toBeNull(); + expect($roleId)->not->toBeNull(); + + DB::table('admin_role_api_resources') + ->where('role_id', (int) $roleId) + ->where('api_resource_id', (int) $resourceId) + ->delete(); + + $this->artisan('lottery:admin-auth-audit --skip-route-coverage') + ->expectsOutputToContain('Missing role-resource grant') + ->assertExitCode(1); +}); diff --git a/tests/Feature/RiskPoolLuaScriptTest.php b/tests/Feature/RiskPoolLuaScriptTest.php new file mode 100644 index 0000000..fd7cb62 --- /dev/null +++ b/tests/Feature/RiskPoolLuaScriptTest.php @@ -0,0 +1,23 @@ +setAccessible(true); + + $lua = (string) $method->invoke($service); + + expect($lua)->toContain("return {'OK'") + ->and($lua)->toContain('INVALID_ARGUMENT') + ->and($lua)->toContain('POOL_NOT_INITIALIZED') + ->and($lua)->toContain('VERSION_CONFLICT') + ->and($lua)->toContain('INSUFFICIENT_CAP') + ->and($lua)->toContain('remaining') + ->and($lua)->toContain('locked') + ->and($lua)->toContain('version'); +}); diff --git a/tests/Feature/TicketBettingApiTest.php b/tests/Feature/TicketBettingApiTest.php index e10a9a1..2cd52c3 100644 --- a/tests/Feature/TicketBettingApiTest.php +++ b/tests/Feature/TicketBettingApiTest.php @@ -9,11 +9,13 @@ use App\Models\TicketItem; use App\Lottery\DrawStatus; use App\Models\OddsVersion; use App\Models\TicketOrder; +use App\Models\JackpotPool; use App\Models\PlayerWallet; use App\Models\TicketCombination; use App\Models\PlayConfigItem; use App\Models\PlayConfigVersion; use App\Lottery\ConfigVersionStatus; +use Illuminate\Support\Facades\DB; use Database\Seeders\CurrencySeeder; use Database\Seeders\PlayTypeSeeder; use Database\Seeders\LotterySettingsSeeder; @@ -226,6 +228,59 @@ test('ticket place deducts wallet and persists order items combinations and logs expect((int) $wallet->balance)->toBe(200_000 - 12_400); }); +test('ticket place is idempotent by player draw and client trace id', function (): void { + $player = ticketPlayerWithWallet(); + ticketOpenDraw(); + + $payload = array_merge(ticketPreviewPayload(), ['client_trace_id' => 'same-submit-once']); + + $first = $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', $payload) + ->assertOk() + ->assertJsonPath('code', ErrorCode::Success->value) + ->json('data'); + + $second = $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', $payload) + ->assertOk() + ->assertJsonPath('code', ErrorCode::Success->value) + ->json('data'); + + expect($second['order_no'])->toBe($first['order_no']) + ->and(TicketOrder::query()->count())->toBe(1) + ->and(TicketItem::query()->count())->toBe(2) + ->and(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(1); + + $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); + expect((int) $wallet->balance)->toBe(200_000 - 12_400); +}); + +test('box family estimated max payout is the sum of every expanded combination payout', function (): void { + $player = ticketPlayerWithWallet(500_000); + ticketOpenDraw(); + + $response = $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => '20260511-001', + 'currency_code' => 'NPR', + 'client_trace_id' => 'combo-payout-sum', + 'lines' => [ + ['number' => '1122', 'play_code' => 'ibox', 'amount' => 100], + ], + ]) + ->assertOk() + ->json('data'); + + $item = TicketItem::query()->firstOrFail(); + $combinationSum = TicketCombination::query() + ->where('ticket_item_id', $item->id) + ->sum('estimated_payout'); + + expect((int) $response['summary']['total_estimated_payout'])->toBe((int) $combinationSum) + ->and((int) $item->estimated_max_payout)->toBe((int) $combinationSum) + ->and((int) $item->risk_locked_amount)->toBe((int) $combinationSum); +}); + test('ticket place rejects closed draw', function (): void { $player = ticketPlayerWithWallet(); $draw = ticketOpenDraw(); @@ -285,6 +340,10 @@ test('ticket place succeeds when expected_config_versions matches preview', func ->assertJsonPath('code', ErrorCode::Success->value); expect(TicketOrder::query()->count())->toBe(1); + $order = TicketOrder::query()->firstOrFail(); + expect((int) $order->play_config_version_no)->toBe((int) $versions['play_config_version_no']) + ->and((int) $order->odds_version_no)->toBe((int) $versions['odds_version_no']) + ->and((int) $order->risk_cap_version_no)->toBe((int) $versions['risk_cap_version_no']); }); test('ticket place rejects stale expected_config_versions', function (): void { @@ -769,3 +828,40 @@ test('ticket pending confirmation reconcile confirms order when wallet deduction ->and($item->fresh()->status)->toBe('success') ->and((int) RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->value('locked_amount'))->toBe(3000); }); + +test('ticket place reverses wallet and releases risk when post deduction confirmation fails', function (): void { + $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, + ]); + 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) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => '20260511-001', + 'currency_code' => 'NPR', + 'client_trace_id' => 'wallet-reverse-on-confirm-fail', + 'lines' => [ + ['number' => '1234', 'play_code' => 'big', 'amount' => 120], + ], + ]) + ->assertStatus(500); + + $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); + $order = TicketOrder::query()->where('client_trace_id', 'wallet-reverse-on-confirm-fail')->firstOrFail(); + + expect((int) $wallet->balance)->toBe(20_000) + ->and($order->status)->toBe('refunded') + ->and(WalletTxn::query()->where('biz_no', $order->order_no)->where('biz_type', 'bet_deduct')->count())->toBe(1) + ->and(WalletTxn::query()->where('biz_no', $order->order_no)->where('biz_type', 'bet_reverse')->count())->toBe(1) + ->and((int) RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->value('locked_amount'))->toBe(0); +});