diff --git a/app/Http/Controllers/Api/V1/Admin/Auth/LoginController.php b/app/Http/Controllers/Api/V1/Admin/Auth/LoginController.php index 4ebd245..a384c90 100644 --- a/app/Http/Controllers/Api/V1/Admin/Auth/LoginController.php +++ b/app/Http/Controllers/Api/V1/Admin/Auth/LoginController.php @@ -6,7 +6,7 @@ use App\Models\AdminUser; use App\Lottery\ErrorCode; use Illuminate\Support\Str; use App\Support\ApiResponse; -use App\Support\AdminAuthorizationRegistry; +use App\Support\AdminAuthProfile; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; use Illuminate\Support\Facades\Hash; @@ -69,19 +69,10 @@ final class LoginController extends Controller )->plainTextToken; $admin->forceFill(['last_login_at' => now()])->save(); - $permissionSlugs = $admin->fresh()->adminPermissionSlugs(); - return ApiResponse::success([ 'token' => $plainToken, 'token_type' => 'Bearer', - 'admin' => [ - 'id' => $admin->id, - 'username' => $admin->username, - 'nickname' => $admin->name, - 'email' => $admin->email, - 'permissions' => $permissionSlugs, - 'navigation' => AdminAuthorizationRegistry::visibleNavigationItems($permissionSlugs), - ], + 'admin' => AdminAuthProfile::fromAdmin($admin), ]); } } diff --git a/app/Http/Controllers/Api/V1/Admin/Auth/MeController.php b/app/Http/Controllers/Api/V1/Admin/Auth/MeController.php new file mode 100644 index 0000000..5746b99 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Auth/MeController.php @@ -0,0 +1,23 @@ +lotteryAdmin(); + + return ApiResponse::success([ + 'admin' => AdminAuthProfile::fromAdmin($admin), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminPermissionCatalogController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminPermissionCatalogController.php index 9fa14fe..f5f401f 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminPermissionCatalogController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminPermissionCatalogController.php @@ -8,6 +8,7 @@ use App\Support\AdminAuthorizationRegistry; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\DB; use App\Http\Controllers\Controller; +use App\Support\AdminRoleApiPresenter; /** GET /api/v1/admin/admin-user-permission-catalog */ final class AdminPermissionCatalogController extends Controller @@ -64,20 +65,7 @@ final class AdminPermissionCatalogController extends Controller 'permissions' => $permissions, 'permission_menu_groups' => $permissionMenuGroups, 'navigation' => AdminAuthorizationRegistry::navigationItems(), - 'roles' => $roles->map(static function (AdminRole $role): array { - $userCount = (int) DB::table('admin_user_site_roles') - ->where('role_id', $role->id) - ->distinct() - ->count('admin_user_id'); - - return [ - 'id' => (int) $role->id, - 'slug' => $role->slug, - 'name' => $role->name, - 'permission_slugs' => $role->legacyPermissionSlugs(), - 'user_count' => $userCount, - ]; - })->values()->all(), + 'roles' => $roles->map(static fn (AdminRole $role): array => AdminRoleApiPresenter::item($role))->values()->all(), ]); } } diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminRoleDestroyController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleDestroyController.php new file mode 100644 index 0000000..60b6238 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleDestroyController.php @@ -0,0 +1,45 @@ +slug === AdminRole::ROLE_SUPER_ADMIN) { + return ApiResponse::error('不能删除超级管理员角色', ErrorCode::ValidationFailed->value, null, 422); + } + if ((bool) $admin_role->is_system) { + return ApiResponse::error('系统内置角色不允许删除', ErrorCode::ValidationFailed->value, null, 422); + } + if ($admin_role->assignedUserCount() > 0) { + return ApiResponse::error('该角色下仍有关联管理员,不能删除', ErrorCode::ValidationFailed->value, null, 422); + } + + $before = AdminRoleApiPresenter::item($admin_role); + $id = (int) $admin_role->id; + $admin_role->delete(); + + AuditLogger::recordForAdmin( + $request->lotteryAdmin(), + $request, + 'system', + 'admin_role.delete', + 'admin_role', + (string) $id, + $before, + null, + ); + + return ApiResponse::success(['deleted' => true, 'id' => $id]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminRoleIndexController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleIndexController.php new file mode 100644 index 0000000..192d942 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleIndexController.php @@ -0,0 +1,21 @@ +orderBy('sort_order')->orderBy('id')->get(); + + return ApiResponse::success([ + 'items' => $roles->map(static fn (AdminRole $role): array => AdminRoleApiPresenter::item($role))->values()->all(), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminRolePermissionSyncController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminRolePermissionSyncController.php new file mode 100644 index 0000000..58d6c93 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminRolePermissionSyncController.php @@ -0,0 +1,39 @@ +validated('permission_slugs', []))); + $before = AdminRoleApiPresenter::item($admin_role); + + DB::transaction(function () use ($admin_role, $slugs): void { + $admin_role->syncLegacyPermissionSlugs($slugs); + }); + $admin_role->refresh(); + + AuditLogger::recordForAdmin( + $request->lotteryAdmin(), + $request, + 'system', + 'admin_role.sync_permissions', + 'admin_role', + (string) $admin_role->id, + $before, + AdminRoleApiPresenter::item($admin_role), + ); + + return ApiResponse::success(AdminRoleApiPresenter::item($admin_role)); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminRoleStoreController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleStoreController.php new file mode 100644 index 0000000..ab993af --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleStoreController.php @@ -0,0 +1,48 @@ +validated('permission_slugs', []))); + + $role = DB::transaction(function () use ($request, $permissionSlugs): AdminRole { + $role = AdminRole::query()->create([ + 'slug' => $request->validated('slug'), + 'code' => $request->validated('slug'), + 'name' => $request->validated('name'), + 'description' => $request->validated('description'), + 'status' => $request->validated('status', 1), + 'is_system' => false, + 'sort_order' => 0, + ]); + $role->syncLegacyPermissionSlugs($permissionSlugs); + + return $role->fresh(); + }); + + AuditLogger::recordForAdmin( + $request->lotteryAdmin(), + $request, + 'system', + 'admin_role.create', + 'admin_role', + (string) $role->id, + null, + AdminRoleApiPresenter::item($role), + ); + + return ApiResponse::success(AdminRoleApiPresenter::item($role)); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminRoleUpdateController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleUpdateController.php new file mode 100644 index 0000000..5e5d725 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleUpdateController.php @@ -0,0 +1,46 @@ +has($field)) { + $payload[$field] = $request->validated($field); + } + } + if (isset($payload['slug'])) { + $payload['code'] = $payload['slug']; + } + + $admin_role->fill($payload); + $admin_role->save(); + $admin_role->refresh(); + + AuditLogger::recordForAdmin( + $request->lotteryAdmin(), + $request, + 'system', + 'admin_role.update', + 'admin_role', + (string) $admin_role->id, + $before, + AdminRoleApiPresenter::item($admin_role), + ); + + return ApiResponse::success(AdminRoleApiPresenter::item($admin_role)); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/User/Concerns/EnsuresSuperAdminActor.php b/app/Http/Controllers/Api/V1/Admin/User/Concerns/EnsuresSuperAdminActor.php new file mode 100644 index 0000000..33fa1fa --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/User/Concerns/EnsuresSuperAdminActor.php @@ -0,0 +1,28 @@ +lotteryAdmin(); + if (! $actor->isSuperAdmin()) { + return ApiResponse::error( + '仅超级管理员可管理角色', + ErrorCode::AdminForbidden->value, + null, + 403, + ); + } + + return null; + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php b/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php index 7f11ac1..1675faa 100644 --- a/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php +++ b/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php @@ -113,6 +113,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), '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/Requests/Admin/AdminRolePermissionSyncRequest.php b/app/Http/Requests/Admin/AdminRolePermissionSyncRequest.php new file mode 100644 index 0000000..c61340c --- /dev/null +++ b/app/Http/Requests/Admin/AdminRolePermissionSyncRequest.php @@ -0,0 +1,21 @@ + ['required', 'array'], + 'permission_slugs.*' => ['string', 'max:128'], + ]; + } +} diff --git a/app/Http/Requests/Admin/AdminRoleStoreRequest.php b/app/Http/Requests/Admin/AdminRoleStoreRequest.php new file mode 100644 index 0000000..02d5f64 --- /dev/null +++ b/app/Http/Requests/Admin/AdminRoleStoreRequest.php @@ -0,0 +1,25 @@ + ['required', 'string', 'max:64', 'regex:/^[a-z0-9_\\-]+$/', 'unique:admin_roles,slug'], + 'name' => ['required', 'string', 'max:128'], + 'description' => ['nullable', 'string', 'max:65535'], + 'status' => ['sometimes', 'integer', 'in:0,1'], + 'permission_slugs' => ['sometimes', 'array'], + 'permission_slugs.*' => ['string', 'max:128'], + ]; + } +} diff --git a/app/Http/Requests/Admin/AdminRoleUpdateRequest.php b/app/Http/Requests/Admin/AdminRoleUpdateRequest.php new file mode 100644 index 0000000..c3e4af0 --- /dev/null +++ b/app/Http/Requests/Admin/AdminRoleUpdateRequest.php @@ -0,0 +1,26 @@ +route('admin_role')?->id; + + return [ + 'slug' => ['sometimes', 'string', 'max:64', 'regex:/^[a-z0-9_\\-]+$/', Rule::unique('admin_roles', 'slug')->ignore($roleId)], + 'name' => ['sometimes', 'string', 'max:128'], + 'description' => ['nullable', 'string', 'max:65535'], + 'status' => ['sometimes', 'integer', 'in:0,1'], + ]; + } +} diff --git a/app/Models/AdminRole.php b/app/Models/AdminRole.php index a1eba66..5c11092 100644 --- a/app/Models/AdminRole.php +++ b/app/Models/AdminRole.php @@ -9,6 +9,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; final class AdminRole extends Model { + public const ROLE_SUPER_ADMIN = 'super_admin'; + protected $table = 'admin_roles'; protected static function booted(): void @@ -59,6 +61,25 @@ final class AdminRole extends Model */ public function legacyPermissionSlugs(): array { + if (DB::getSchemaBuilder()->hasTable('admin_role_legacy_permissions')) { + $slugs = DB::table('admin_role_legacy_permissions') + ->where('role_id', $this->id) + ->pluck('permission_slug') + ->all(); + + $out = []; + foreach ($slugs as $slug) { + if (is_string($slug) && $slug !== '') { + $out[$slug] = true; + } + } + + $keys = array_keys($out); + sort($keys); + + return $keys; + } + $codes = DB::table('admin_role_menu_actions as rma') ->join('admin_menu_actions as ma', 'ma.id', '=', 'rma.menu_action_id') ->where('rma.role_id', $this->id) @@ -68,4 +89,56 @@ final class AdminRole extends Model return AdminPermissionBridge::legacySlugsGrantedByMenuActionCodes($codes); } + + /** + * @param list $slugs + */ + public function syncLegacyPermissionSlugs(array $slugs): void + { + $legacySlugs = array_values(array_unique(array_filter( + $slugs, + static fn ($slug): bool => is_string($slug) && $slug !== '', + ))); + + $codes = []; + foreach ($legacySlugs 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(); + + DB::table('admin_role_menu_actions')->where('role_id', $this->id)->delete(); + foreach ($ids as $mid) { + DB::table('admin_role_menu_actions')->insert([ + 'role_id' => $this->id, + 'menu_action_id' => (int) $mid, + ]); + } + + if (DB::getSchemaBuilder()->hasTable('admin_role_legacy_permissions')) { + DB::table('admin_role_legacy_permissions')->where('role_id', $this->id)->delete(); + $now = now(); + foreach ($legacySlugs as $slug) { + DB::table('admin_role_legacy_permissions')->insert([ + 'role_id' => $this->id, + 'permission_slug' => $slug, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + } + + public function assignedUserCount(): int + { + return (int) DB::table('admin_user_site_roles') + ->where('role_id', $this->id) + ->distinct() + ->count('admin_user_id'); + } } diff --git a/app/Services/Wallet/LotteryTransferService.php b/app/Services/Wallet/LotteryTransferService.php index 86d0204..1e6e880 100644 --- a/app/Services/Wallet/LotteryTransferService.php +++ b/app/Services/Wallet/LotteryTransferService.php @@ -322,7 +322,11 @@ final class LotteryTransferService string $action, string $remark = '', ): void { - if ($order->status !== self::ST_PENDING_RECONCILE) { + $allowedStatuses = $action === 'manually_process' + ? [self::ST_PROCESSING, self::ST_FAILED, self::ST_PENDING_RECONCILE] + : [self::ST_PENDING_RECONCILE]; + + if (! in_array($order->status, $allowedStatuses, true)) { throw new WalletOperationException( 'order_not_pending_reconcile', ErrorCode::WalletExternalRejected->value, diff --git a/app/Support/AdminAuthProfile.php b/app/Support/AdminAuthProfile.php new file mode 100644 index 0000000..e789196 --- /dev/null +++ b/app/Support/AdminAuthProfile.php @@ -0,0 +1,39 @@ +, + * navigation: list + * }> + * } + */ + public static function fromAdmin(AdminUser $admin): array + { + $fresh = $admin->fresh(); + $permissionSlugs = $fresh->adminPermissionSlugs(); + + return [ + 'id' => $fresh->id, + 'username' => $fresh->username, + 'nickname' => $fresh->name, + 'email' => $fresh->email, + 'permissions' => $permissionSlugs, + 'navigation' => AdminAuthorizationRegistry::visibleNavigationItems($permissionSlugs), + ]; + } +} diff --git a/app/Support/AdminAuthorizationRegistry.php b/app/Support/AdminAuthorizationRegistry.php index a49e5c6..1bb4bca 100644 --- a/app/Support/AdminAuthorizationRegistry.php +++ b/app/Support/AdminAuthorizationRegistry.php @@ -8,55 +8,56 @@ final class AdminAuthorizationRegistry * 后台功能权限的单一注册表: * - `slug`:前端与后台用户权限管理仍使用的 legacy `prd.*` * - `permission_codes`:资源鉴权实际使用的 action code - * - `group_key`:权限目录中的展示分组 + * - `nav_segment`:权限目录按后台导航分组展示 * * @return list * }> */ public static function permissionDefinitions(): array { return [ - ['slug' => 'prd.users.manage', 'name' => '用户管理·可管理', 'group_key' => 'users_players', 'permission_codes' => ['service.players.manage']], - ['slug' => 'prd.users.view_finance', 'name' => '用户管理·财务查看', 'group_key' => 'users_players', 'permission_codes' => ['service.players.view', 'service.wallet.view']], - ['slug' => 'prd.users.view_cs', 'name' => '用户管理·客服单用户', 'group_key' => 'users_players', 'permission_codes' => ['service.players.view', 'service.tickets.view', 'service.wallet.view']], - ['slug' => 'prd.player_freeze.manage', 'name' => '冻结/解冻玩家·可管理', 'group_key' => 'users_players', 'permission_codes' => ['service.players.manage']], + ['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.play_switch.manage', 'name' => '玩法开关·可管理', 'group_key' => 'ops_config', 'permission_codes' => ['config.play.manage']], - ['slug' => 'prd.odds.manage', 'name' => '赔率配置·可管理', 'group_key' => 'ops_config', 'permission_codes' => ['config.odds.manage']], - ['slug' => 'prd.risk_cap.manage', 'name' => '封顶配置·可管理', 'group_key' => 'ops_config', 'permission_codes' => ['config.risk_cap.manage']], - ['slug' => 'prd.risk_cap.view', 'name' => '封顶配置·查看', 'group_key' => 'ops_config', 'permission_codes' => ['config.risk_cap.view']], - ['slug' => 'prd.rebate.manage', 'name' => '佣金/回水·可管理', 'group_key' => 'ops_config', 'permission_codes' => ['config.odds.manage']], - ['slug' => 'prd.rebate.view', 'name' => '佣金/回水·查看', 'group_key' => 'ops_config', 'permission_codes' => ['config.odds.manage']], - ['slug' => 'prd.jackpot.manage', 'name' => 'Jackpot 配置·可管理', 'group_key' => 'ops_config', 'permission_codes' => ['config.jackpot.manage']], - ['slug' => 'prd.jackpot.view', 'name' => 'Jackpot 配置·查看', 'group_key' => 'ops_config', 'permission_codes' => ['config.jackpot.view']], + ['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.player_freeze.manage', 'name' => '冻结/解冻玩家·可管理', 'nav_segment' => 'players', 'permission_codes' => ['service.players.freeze']], - ['slug' => 'prd.draw_result.manage', 'name' => '开奖结果录入·可管理', 'group_key' => 'draw_risk', 'permission_codes' => ['draw.results.view', 'draw.review.review', 'draw.review.publish', 'risk.monitor.view']], - ['slug' => 'prd.draw_result.view', 'name' => '开奖结果·查看', 'group_key' => 'draw_risk', 'permission_codes' => ['draw.results.view', 'risk.monitor.view']], - ['slug' => 'prd.draw_reopen.manage', 'name' => '开奖结果重开·可管理', 'group_key' => 'draw_risk', 'permission_codes' => ['draw.review.publish']], + ['slug' => 'prd.wallet_reconcile.manage', 'name' => '钱包对账·可管理', 'nav_segment' => 'wallet', 'permission_codes' => ['service.wallet.manage', 'service.reconcile.manage']], + ['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.payout.manage', 'name' => '派彩确认·可管理', 'group_key' => 'settlement', 'permission_codes' => ['settlement.batch.manage', 'settlement.batch.view']], - ['slug' => 'prd.payout.review', 'name' => '派彩确认·可审核', 'group_key' => 'settlement', 'permission_codes' => ['settlement.batch.review', 'settlement.batch.view']], - ['slug' => 'prd.payout.view', 'name' => '派彩确认·查看', 'group_key' => 'settlement', 'permission_codes' => ['settlement.batch.view']], + ['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_reopen.manage', 'name' => '开奖结果重开·可管理', 'nav_segment' => 'draws', 'permission_codes' => ['draw.review.publish']], - ['slug' => 'prd.wallet_reconcile.manage', 'name' => '钱包对账·可管理', 'group_key' => 'wallet', 'permission_codes' => ['service.wallet.manage', 'service.reconcile.manage']], - ['slug' => 'prd.wallet_reconcile.view', 'name' => '钱包对账·查看', 'group_key' => 'wallet', 'permission_codes' => ['service.wallet.view', 'service.reconcile.view']], - ['slug' => 'prd.wallet_reconcile.view_cs', 'name' => '钱包对账·客服单用户', 'group_key' => 'wallet', 'permission_codes' => ['service.wallet.view', 'service.reconcile.view']], - ['slug' => 'prd.wallet_adjust.manage', 'name' => '补单/冲正·可管理', 'group_key' => 'wallet', 'permission_codes' => ['service.wallet.manage']], + ['slug' => 'prd.play_switch.manage', 'name' => '玩法开关·可管理', 'nav_segment' => 'config', 'permission_codes' => ['config.play.manage']], + ['slug' => 'prd.odds.manage', 'name' => '赔率配置·可管理', 'nav_segment' => 'config', 'permission_codes' => ['config.odds.manage']], + ['slug' => 'prd.risk_cap.manage', 'name' => '封顶配置·可管理', 'nav_segment' => 'config', 'permission_codes' => ['config.risk_cap.manage']], + ['slug' => 'prd.risk_cap.view', 'name' => '封顶配置·查看', 'nav_segment' => 'config', 'permission_codes' => ['config.risk_cap.view']], + ['slug' => 'prd.rebate.manage', 'name' => '佣金/回水·可管理', 'nav_segment' => 'config', 'permission_codes' => ['config.odds.manage']], + ['slug' => 'prd.rebate.view', 'name' => '佣金/回水·查看', 'nav_segment' => 'config', 'permission_codes' => ['config.odds.manage']], + ['slug' => 'prd.jackpot.manage', 'name' => 'Jackpot 配置·可管理', 'nav_segment' => 'config', 'permission_codes' => ['config.jackpot.manage']], + ['slug' => 'prd.jackpot.view', 'name' => 'Jackpot 配置·查看', 'nav_segment' => 'config', 'permission_codes' => ['config.jackpot.view']], - ['slug' => 'prd.report.all', 'name' => '报表·全部', 'group_key' => 'reports', 'permission_codes' => ['service.reports.view', 'service.reports.export']], - ['slug' => 'prd.report.risk', 'name' => '报表·风控', 'group_key' => 'reports', 'permission_codes' => ['service.reports.view']], - ['slug' => 'prd.report.finance', 'name' => '报表·财务', 'group_key' => 'reports', 'permission_codes' => ['service.reports.view', 'service.reports.export']], - ['slug' => 'prd.report.player', 'name' => '报表·单用户', 'group_key' => 'reports', 'permission_codes' => ['service.reports.view']], + ['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']], + ['slug' => 'prd.payout.view', 'name' => '派彩确认·查看', 'nav_segment' => 'settlement', 'permission_codes' => ['settlement.batch.view']], - ['slug' => 'prd.audit.all', 'name' => '审计日志·全部', 'group_key' => 'audit', 'permission_codes' => ['service.audit.view']], - ['slug' => 'prd.audit.self', 'name' => '审计日志·自身相关', 'group_key' => 'audit', 'permission_codes' => ['service.audit.view']], - ['slug' => 'prd.audit.finance', 'name' => '审计日志·资金相关', 'group_key' => 'audit', 'permission_codes' => ['service.audit.view']], + ['slug' => 'prd.report.all', 'name' => '报表·全部', 'nav_segment' => 'reports', 'permission_codes' => ['service.reports.view', 'service.reports.export']], + ['slug' => 'prd.report.risk', 'name' => '报表·风控', 'nav_segment' => 'reports', 'permission_codes' => ['service.reports.view']], + ['slug' => 'prd.report.finance', 'name' => '报表·财务', 'nav_segment' => 'reports', 'permission_codes' => ['service.reports.view', 'service.reports.export']], + ['slug' => 'prd.report.player', 'name' => '报表·单用户', 'nav_segment' => 'reports', 'permission_codes' => ['service.reports.view']], - ['slug' => 'prd.admin_user.manage', 'name' => '后台用户权限管理·可管理', 'group_key' => 'system', 'permission_codes' => ['system.admin_user.manage']], + ['slug' => 'prd.audit.all', 'name' => '审计日志·全部', 'nav_segment' => 'audit', 'permission_codes' => ['service.audit.view']], + ['slug' => 'prd.audit.self', 'name' => '审计日志·自身相关', 'nav_segment' => 'audit', 'permission_codes' => ['service.audit.view']], + ['slug' => 'prd.audit.finance', 'name' => '审计日志·资金相关', 'nav_segment' => 'audit', 'permission_codes' => ['service.audit.view']], ]; } @@ -68,16 +69,31 @@ final class AdminAuthorizationRegistry */ public static function permissionGroupDefinitions(): array { - return [ - ['key' => 'users_players', 'label' => '用户与玩家'], - ['key' => 'ops_config', 'label' => '运营配置'], - ['key' => 'draw_risk', 'label' => '开奖与风控'], - ['key' => 'settlement', 'label' => '结算与派彩'], - ['key' => 'wallet', 'label' => '钱包与对账'], - ['key' => 'reports', 'label' => '报表'], - ['key' => 'audit', 'label' => '审计日志'], - ['key' => 'system', 'label' => '系统管理'], + $labels = [ + 'dashboard' => '仪表盘', + 'admin_users' => '管理列表', + 'admin_roles' => '角色管理', + 'players' => '玩家列表', + 'wallet' => '钱包流水', + 'draws' => '期号列表', + 'config' => '运营配置', + 'risk' => '风控', + 'settlement' => '结算', + 'jackpot' => 'Jackpot', + 'reconcile' => '对账', + 'tickets' => '玩家注单', + 'reports' => '报表导出', + 'audit' => '审计日志', + 'settings' => '系统设置', ]; + + return array_map( + static fn (array $item): array => [ + 'key' => $item['segment'], + 'label' => $labels[$item['segment']] ?? $item['label'], + ], + self::navigationDefinitions(), + ); } /** @@ -96,13 +112,13 @@ final class AdminAuthorizationRegistry return [ ['segment' => 'dashboard', 'label' => 'Dashboard', 'href' => '/admin'], ['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' => 'players', 'label' => 'Players', 'href' => '/admin/players', 'requiredAny' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs']], - ['segment' => 'wallet', 'label' => 'Wallet', 'href' => '/admin/wallet/transactions', 'activeMatchPrefix' => '/admin/wallet', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs']], + ['segment' => 'wallet', 'label' => 'Wallet', 'href' => '/admin/wallet/transactions', 'activeMatchPrefix' => '/admin/wallet', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.users.manage', 'prd.users.view_finance']], ['segment' => 'draws', 'label' => 'Draws', 'href' => '/admin/draws', 'requiredAny' => ['prd.draw_result.manage', 'prd.draw_result.view']], ['segment' => 'config', 'label' => 'Configuration', 'href' => '/admin/config', 'requiredAny' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.risk_cap.view', 'prd.rebate.manage', 'prd.rebate.view', 'prd.jackpot.manage', 'prd.jackpot.view']], ['segment' => 'risk', 'label' => 'Risk', 'href' => '/admin/risk', 'requiredAny' => ['prd.draw_result.view', 'prd.draw_result.manage']], ['segment' => 'settlement', 'label' => 'Settlement', 'href' => '/admin/settlement-batches', 'requiredAny' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view']], - ['segment' => 'jackpot', 'label' => 'Jackpot', 'href' => '/admin/jackpot/pools', 'activeMatchPrefix' => '/admin/jackpot', 'requiredAny' => ['prd.jackpot.manage', 'prd.jackpot.view']], ['segment' => 'reconcile', 'label' => 'Reconcile', 'href' => '/admin/reconcile', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']], ['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', 'prd.report.player']], ['segment' => 'reports', 'label' => 'Reports', 'href' => '/admin/reports', 'requiredAny' => ['prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player']], @@ -141,14 +157,9 @@ final class AdminAuthorizationRegistry */ public static function permissionMenuGroups(): array { - $permissionsByGroup = []; - foreach (self::permissionDefinitions() as $permission) { - $permissionsByGroup[$permission['group_key']][] = $permission['slug']; - } - $groups = []; foreach (self::permissionGroupDefinitions() as $group) { - $slugs = $permissionsByGroup[$group['key']] ?? []; + $slugs = self::permissionSlugsForNavigationSegment($group['key']); if ($slugs === []) { continue; } @@ -162,6 +173,38 @@ final class AdminAuthorizationRegistry return $groups; } + /** @return list */ + private static function permissionSlugsForNavigationSegment(string $segment): array + { + $explicit = [ + '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'], + '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'], + 'config' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.risk_cap.view', 'prd.rebate.manage', 'prd.rebate.view', 'prd.jackpot.manage', 'prd.jackpot.view'], + 'risk' => ['prd.draw_result.manage', 'prd.draw_result.view'], + 'settlement' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view'], + 'reconcile' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs'], + 'tickets' => ['prd.users.view_cs', 'prd.users.manage', 'prd.report.player'], + 'reports' => ['prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player'], + 'audit' => ['prd.audit.all', 'prd.audit.self', 'prd.audit.finance'], + ]; + + if (isset($explicit[$segment])) { + return $explicit[$segment]; + } + + $slugs = []; + foreach (self::permissionDefinitions() as $permission) { + if ($permission['nav_segment'] === $segment) { + $slugs[] = $permission['slug']; + } + } + + return array_values(array_unique($slugs)); + } + /** * @return list $resource['route_name'], 'auth_mode' => $resource['auth_mode'], 'is_audit_required' => $resource['is_audit_required'], - 'permission_codes' => self::permissionCodesForLegacySlugs($resource['legacy_permission_slugs'] ?? []), + 'permission_codes' => $resource['permission_codes'] ?? self::permissionCodesForLegacySlugs($resource['legacy_permission_slugs'] ?? []), ], self::resourceDefinitions(), ); @@ -276,6 +319,7 @@ 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.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, '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']], @@ -283,9 +327,14 @@ final class AdminAuthorizationRegistry ['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.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', 'prd.admin_role.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.admin-roles.index', 'module_code' => 'system', 'name' => '角色列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/admin-roles', 'route_name' => 'api.v1.admin.admin-roles.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.admin_role.manage']], + ['code' => 'admin.admin-roles.store', 'module_code' => 'system', 'name' => '创建角色', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/admin-roles', 'route_name' => 'api.v1.admin.admin-roles.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_role.manage']], + ['code' => 'admin.admin-roles.update', 'module_code' => 'system', 'name' => '更新角色', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/admin-roles/{admin_role}', 'route_name' => 'api.v1.admin.admin-roles.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_role.manage']], + ['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']], @@ -319,7 +368,7 @@ final class AdminAuthorizationRegistry ['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.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']], @@ -342,15 +391,15 @@ final class AdminAuthorizationRegistry ['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.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']], + ['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, 'permission_codes' => ['service.players.manage', 'service.players.view']], + ['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']], + ['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']], + ['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, 'permission_codes' => ['service.players.freeze']], + ['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.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']], diff --git a/app/Support/AdminRoleApiPresenter.php b/app/Support/AdminRoleApiPresenter.php new file mode 100644 index 0000000..6fb0743 --- /dev/null +++ b/app/Support/AdminRoleApiPresenter.php @@ -0,0 +1,24 @@ + */ + public static function item(AdminRole $role): array + { + return [ + 'id' => (int) $role->id, + 'slug' => $role->slug, + 'name' => $role->name, + 'description' => $role->description, + 'status' => (int) $role->status, + 'is_system' => (bool) $role->is_system, + 'sort_order' => (int) $role->sort_order, + 'permission_slugs' => $role->legacyPermissionSlugs(), + 'user_count' => $role->assignedUserCount(), + ]; + } +} 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 d718184..dd1fff9 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 @@ -270,6 +270,7 @@ return new class extends Migration ['parent_code' => 'service', 'menu_type' => 'page', 'code' => 'service.reports', 'name' => '报表导出', 'path' => '/admin/reports', 'route_name' => 'admin.reports.index', 'component' => 'service/reports', 'icon' => null, 'sort_order' => 50], ['parent_code' => 'service', 'menu_type' => 'page', 'code' => 'service.audit', 'name' => '审计日志', 'path' => '/admin/audit-logs', 'route_name' => 'admin.audit.index', 'component' => 'service/audit', 'icon' => null, 'sort_order' => 60], ['parent_code' => null, 'menu_type' => 'page', 'code' => 'system.admin_user', 'name' => '管理员权限', 'path' => '/admin/admin-users', 'route_name' => 'admin.system.admin-users', 'component' => 'system/admin-users', 'icon' => 'users-round', 'sort_order' => 70], + ['parent_code' => null, 'menu_type' => 'page', 'code' => 'system.admin_role', 'name' => '角色管理', 'path' => '/admin/admin-roles', 'route_name' => 'admin.system.admin-roles', 'component' => 'system/admin-roles', 'icon' => 'shield-check', 'sort_order' => 71], ]; $menuIds = []; @@ -313,6 +314,7 @@ return new class extends Migration ['menu_code' => 'settlement.batch', 'action_code' => 'manage', 'permission_code' => 'settlement.batch.manage', 'name' => '结算执行'], ['menu_code' => 'service.players', 'action_code' => 'view', 'permission_code' => 'service.players.view', 'name' => '玩家查询查看'], ['menu_code' => 'service.players', 'action_code' => 'manage', 'permission_code' => 'service.players.manage', 'name' => '玩家查询管理'], + ['menu_code' => 'service.players', 'action_code' => 'update', 'permission_code' => 'service.players.freeze', 'name' => '冻结解冻玩家'], ['menu_code' => 'service.tickets', 'action_code' => 'view', 'permission_code' => 'service.tickets.view', 'name' => '玩家注单查看'], ['menu_code' => 'service.wallet', 'action_code' => 'view', 'permission_code' => 'service.wallet.view', 'name' => '钱包流水查看'], ['menu_code' => 'service.wallet', 'action_code' => 'manage', 'permission_code' => 'service.wallet.manage', 'name' => '钱包流水管理'], @@ -322,6 +324,7 @@ return new class extends Migration ['menu_code' => 'service.reports', 'action_code' => 'export', 'permission_code' => 'service.reports.export', 'name' => '报表导出'], ['menu_code' => 'service.audit', 'action_code' => 'view', 'permission_code' => 'service.audit.view', 'name' => '审计查看'], ['menu_code' => 'system.admin_user', 'action_code' => 'manage', 'permission_code' => 'system.admin_user.manage', 'name' => '管理员权限管理'], + ['menu_code' => 'system.admin_role', 'action_code' => 'manage', 'permission_code' => 'system.admin_role.manage', 'name' => '角色权限管理'], ]; foreach ($menuActions as $row) { @@ -347,8 +350,8 @@ return new class extends Migration ['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, 'permission_codes' => ['settlement.batch.manage', 'settlement.batch.review']], ['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, 'permission_codes' => ['service.wallet.view', 'service.wallet.manage']], ['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, 'permission_codes' => ['service.wallet.view', 'service.wallet.manage']], - ['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.view', 'service.players.manage']], - ['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.tickets.view']], + ['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.reports.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.reports.view', 'service.reports.export']], ['code' => 'admin.reports.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.reports.export']], ['code' => 'admin.reconcile.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, 'permission_codes' => ['service.reconcile.view', 'service.reconcile.manage']], @@ -427,7 +430,7 @@ return new class extends Migration $legacyToNewPermissionMap = [ 'prd.users.manage' => ['service.players.manage'], 'prd.users.view_finance' => ['service.players.view', 'service.wallet.view'], - 'prd.users.view_cs' => ['service.players.view', 'service.tickets.view', 'service.wallet.view'], + 'prd.users.view_cs' => ['service.players.view', 'service.tickets.view'], 'prd.play_switch.manage' => ['config.play.manage'], 'prd.odds.manage' => ['config.odds.manage'], 'prd.risk_cap.manage' => ['config.risk_cap.manage'], @@ -452,7 +455,7 @@ return new class extends Migration 'prd.audit.self' => ['service.audit.view'], 'prd.audit.finance' => ['service.audit.view'], 'prd.admin_user.manage' => ['system.admin_user.manage'], - 'prd.player_freeze.manage' => ['service.players.manage'], + 'prd.player_freeze.manage' => ['service.players.freeze'], 'prd.wallet_adjust.manage' => ['service.wallet.manage'], 'prd.draw_reopen.manage' => ['draw.review.publish'], ]; diff --git a/database/migrations/2026_05_19_112752_seed_default_jackpot_pools.php b/database/migrations/2026_05_19_112752_seed_default_jackpot_pools.php new file mode 100644 index 0000000..1c7b063 --- /dev/null +++ b/database/migrations/2026_05_19_112752_seed_default_jackpot_pools.php @@ -0,0 +1,51 @@ +where('is_enabled', true) + ->where('is_bettable', true) + ->pluck('code') + ->filter(static fn ($code): bool => is_string($code) && trim($code) !== '') + ->map(static fn (string $code): string => strtoupper($code)) + ->unique() + ->values(); + + if ($currencyCodes->isEmpty()) { + $currencyCodes = collect([strtoupper((string) config('lottery.default_currency', 'NPR'))]); + } + + foreach ($currencyCodes as $currencyCode) { + $exists = DB::table('jackpot_pools')->where('currency_code', $currencyCode)->exists(); + if ($exists) { + continue; + } + + DB::table('jackpot_pools')->insert([ + 'currency_code' => $currencyCode, + 'current_amount' => 0, + 'contribution_rate' => '0.0200', + 'trigger_threshold' => 100_000_000, + 'payout_rate' => '0.5000', + 'force_trigger_draw_gap' => 100, + 'min_bet_amount' => 100, + 'status' => 0, + 'last_trigger_draw_id' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + public function down(): void + { + // 保留奖池配置与水位,避免回滚误删运营数据。 + } +}; diff --git a/database/migrations/2026_05_19_120000_create_admin_role_legacy_permissions_table.php b/database/migrations/2026_05_19_120000_create_admin_role_legacy_permissions_table.php new file mode 100644 index 0000000..e824128 --- /dev/null +++ b/database/migrations/2026_05_19_120000_create_admin_role_legacy_permissions_table.php @@ -0,0 +1,48 @@ +foreignId('role_id')->constrained('admin_roles')->cascadeOnDelete(); + $table->string('permission_slug', 128); + $table->timestamps(); + + $table->primary(['role_id', 'permission_slug'], 'pk_admin_role_legacy_permissions'); + }); + + $now = now(); + $roleCodes = DB::table('admin_role_menu_actions as rma') + ->join('admin_menu_actions as ma', 'ma.id', '=', 'rma.menu_action_id') + ->where('ma.status', 1) + ->select('rma.role_id', 'ma.permission_code') + ->get() + ->groupBy('role_id'); + + foreach ($roleCodes as $roleId => $rows) { + $slugs = AdminPermissionBridge::legacySlugsGrantedByMenuActionCodes( + $rows->pluck('permission_code')->all(), + ); + foreach ($slugs as $slug) { + DB::table('admin_role_legacy_permissions')->insert([ + 'role_id' => (int) $roleId, + 'permission_slug' => $slug, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + } + + public function down(): void + { + Schema::dropIfExists('admin_role_legacy_permissions'); + } +}; diff --git a/database/migrations/2026_05_19_121000_sync_admin_role_manage_permission.php b/database/migrations/2026_05_19_121000_sync_admin_role_manage_permission.php new file mode 100644 index 0000000..4d50fed --- /dev/null +++ b/database/migrations/2026_05_19_121000_sync_admin_role_manage_permission.php @@ -0,0 +1,124 @@ +where('code', 'manage')->value('id'); + $adminUserMenuId = (int) DB::table('admin_menus')->where('code', 'system.admin_user')->value('id'); + + if ($adminUserMenuId > 0) { + $adminUserMenu = DB::table('admin_menus')->where('id', $adminUserMenuId)->first(); + DB::table('admin_menus')->updateOrInsert( + ['code' => 'system.admin_role'], + [ + 'parent_id' => $adminUserMenu->parent_id, + 'menu_type' => 'page', + 'name' => '角色管理', + 'path' => '/admin/admin-roles', + 'route_name' => 'admin.system.admin-roles', + 'component' => 'system/admin-roles', + 'icon' => 'shield-check', + 'active_menu_code' => null, + 'sort_order' => ((int) $adminUserMenu->sort_order) + 1, + 'is_visible' => true, + 'is_cache' => false, + 'is_external' => false, + 'status' => 1, + 'meta_json' => null, + 'created_at' => $now, + 'updated_at' => $now, + ], + ); + } + + $menuId = (int) DB::table('admin_menus')->where('code', 'system.admin_role')->value('id'); + + if ($actionCatalogId > 0 && $menuId > 0) { + DB::table('admin_menu_actions')->updateOrInsert( + ['permission_code' => 'system.admin_role.manage'], + [ + 'menu_id' => $menuId, + 'action_id' => $actionCatalogId, + 'name' => '角色权限管理', + 'status' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ], + ); + } + + $menuActionIds = DB::table('admin_menu_actions')->pluck('id', 'permission_code'); + foreach (AdminAuthorizationRegistry::resources() as $resource) { + $resourceId = DB::table('admin_api_resources') + ->where('code', $resource['code']) + ->value('id'); + + if ($resourceId === null) { + continue; + } + + 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, + ]); + } + } + + $adminRoleSlug = 'prd.admin_role.manage'; + $adminUserSlug = 'prd.admin_user.manage'; + $roleIds = DB::table('admin_role_legacy_permissions') + ->where('permission_slug', $adminUserSlug) + ->pluck('role_id') + ->all(); + + foreach ($roleIds as $roleId) { + DB::table('admin_role_legacy_permissions')->updateOrInsert( + [ + 'role_id' => (int) $roleId, + 'permission_slug' => $adminRoleSlug, + ], + [ + 'created_at' => $now, + 'updated_at' => $now, + ], + ); + + foreach (AdminPermissionBridge::menuActionCodesForLegacy($adminRoleSlug) as $permissionCode) { + $menuActionId = $menuActionIds[$permissionCode] ?? null; + if ($menuActionId === null) { + continue; + } + + DB::table('admin_role_menu_actions')->updateOrInsert([ + 'role_id' => (int) $roleId, + 'menu_action_id' => (int) $menuActionId, + ]); + } + } + } + + public function down(): void + { + // 不回滚授权数据,避免删除线上已经显式授予的角色管理权限。 + } +}; diff --git a/database/migrations/2026_05_19_122000_sync_player_permission_resource_bindings.php b/database/migrations/2026_05_19_122000_sync_player_permission_resource_bindings.php new file mode 100644 index 0000000..9ecf7ca --- /dev/null +++ b/database/migrations/2026_05_19_122000_sync_player_permission_resource_bindings.php @@ -0,0 +1,82 @@ +where('code', 'service.players')->value('id'); + $updateActionId = (int) DB::table('admin_action_catalog')->where('code', 'update')->value('id'); + + if ($playersMenuId > 0 && $updateActionId > 0) { + DB::table('admin_menu_actions')->updateOrInsert( + ['permission_code' => 'service.players.freeze'], + [ + 'menu_id' => $playersMenuId, + 'action_id' => $updateActionId, + 'name' => '冻结解冻玩家', + 'status' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ], + ); + } + + $menuActionIds = DB::table('admin_menu_actions')->pluck('id', 'permission_code'); + + $playerResourceBindings = [ + 'admin.players.index' => ['service.players.manage', 'service.players.view'], + 'admin.players.store' => ['service.players.manage'], + 'admin.players.show' => ['service.players.manage', 'service.players.view'], + 'admin.players.update' => ['service.players.manage'], + 'admin.players.destroy' => ['service.players.manage'], + 'admin.players.freeze' => ['service.players.freeze'], + 'admin.players.unfreeze' => ['service.players.freeze'], + 'admin.players.wallets' => ['service.players.manage', 'service.wallet.view'], + 'admin.players.ticket-items' => ['service.players.manage', 'service.tickets.view'], + ]; + + foreach (AdminAuthorizationRegistry::resources() as $resource) { + if (($resource['module_code'] ?? null) !== 'player_service') { + continue; + } + + $resourceId = DB::table('admin_api_resources') + ->where('code', $resource['code']) + ->value('id'); + + if ($resourceId === null) { + continue; + } + + DB::table('admin_api_resource_bindings') + ->where('api_resource_id', (int) $resourceId) + ->delete(); + + $permissionCodes = $playerResourceBindings[$resource['code']] ?? $resource['permission_codes']; + foreach ($permissionCodes 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, + ]); + } + } + } + + public function down(): void + { + // 不回滚授权绑定,避免误删线上已调整的资源权限关系。 + } +}; diff --git a/routes/api/v1/admin/core.php b/routes/api/v1/admin/core.php index 570b733..df2e19c 100644 --- a/routes/api/v1/admin/core.php +++ b/routes/api/v1/admin/core.php @@ -1,6 +1,7 @@ middleware('admin.api-resource') ->name('api.v1.admin.dashboard'); +// 当前管理员摘要 +Route::get('auth/me', MeController::class) + ->middleware('admin.api-resource') + ->name('api.v1.admin.auth.me'); + // 审计日志 Route::middleware('admin.api-resource') ->get('audit-logs', AuditLogIndexController::class) diff --git a/routes/api/v1/admin/user.php b/routes/api/v1/admin/user.php index 2a0ad26..57d9b05 100644 --- a/routes/api/v1/admin/user.php +++ b/routes/api/v1/admin/user.php @@ -3,6 +3,11 @@ use Illuminate\Support\Facades\Route; use App\Http\Controllers\Api\V1\Admin\User\AdminUserShowController; use App\Http\Controllers\Api\V1\Admin\User\AdminUserIndexController; +use App\Http\Controllers\Api\V1\Admin\User\AdminRoleIndexController; +use App\Http\Controllers\Api\V1\Admin\User\AdminRoleStoreController; +use App\Http\Controllers\Api\V1\Admin\User\AdminRoleUpdateController; +use App\Http\Controllers\Api\V1\Admin\User\AdminRoleDestroyController; +use App\Http\Controllers\Api\V1\Admin\User\AdminRolePermissionSyncController; use App\Http\Controllers\Api\V1\Admin\User\AdminUserStoreController; use App\Http\Controllers\Api\V1\Admin\User\AdminUserUpdateController; use App\Http\Controllers\Api\V1\Admin\User\AdminUserDestroyController; @@ -31,4 +36,14 @@ Route::middleware('admin.api-resource') ->name('api.v1.admin.admin-users.permissions.sync'); Route::put('admin-users/{admin_user}/roles', AdminUserRoleSyncController::class) ->name('api.v1.admin.admin-users.roles.sync'); + Route::get('admin-roles', AdminRoleIndexController::class) + ->name('api.v1.admin.admin-roles.index'); + Route::post('admin-roles', AdminRoleStoreController::class) + ->name('api.v1.admin.admin-roles.store'); + Route::put('admin-roles/{admin_role}', AdminRoleUpdateController::class) + ->name('api.v1.admin.admin-roles.update'); + Route::delete('admin-roles/{admin_role}', AdminRoleDestroyController::class) + ->name('api.v1.admin.admin-roles.destroy'); + Route::put('admin-roles/{admin_role}/permissions', AdminRolePermissionSyncController::class) + ->name('api.v1.admin.admin-roles.permissions.sync'); }); diff --git a/tests/Feature/AdminAuthLoginTest.php b/tests/Feature/AdminAuthLoginTest.php index c5a3b55..84d645d 100644 --- a/tests/Feature/AdminAuthLoginTest.php +++ b/tests/Feature/AdminAuthLoginTest.php @@ -3,6 +3,7 @@ use App\Models\AdminUser; use App\Lottery\ErrorCode; use Illuminate\Support\Str; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Cache; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -13,6 +14,52 @@ test('admin ping requires authentication', function () { ->assertJsonPath('code', ErrorCode::AdminUnauthenticated->value); }); +test('admin auth me returns current admin profile', function () { + $admin = AdminUser::query()->create([ + 'username' => 'admin_me', + 'name' => '管理员本人', + 'email' => null, + 'password' => 'secret-strong', + 'status' => 0, + ]); + + $roleId = DB::table('admin_roles')->insertGetId([ + 'code' => 'super_admin', + 'slug' => 'super_admin', + 'name' => '超级管理员', + 'description' => null, + 'status' => 1, + 'is_system' => true, + 'sort_order' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $siteId = DB::table('admin_sites')->insertGetId([ + 'code' => 'default', + 'name' => '默认站点', + 'is_default' => true, + 'status' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('admin_user_site_roles')->insert([ + 'admin_user_id' => $admin->id, + 'site_id' => $siteId, + 'role_id' => $roleId, + 'granted_at' => now(), + ]); + + $token = $admin->createToken('admin-api', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/auth/me') + ->assertOk() + ->assertJsonPath('code', ErrorCode::Success->value) + ->assertJsonPath('data.admin.username', 'admin_me') + ->assertJsonPath('data.admin.navigation.0.segment', 'dashboard'); +}); + test('admin login returns bearer token when captcha passes validation', function () { AdminUser::query()->create([ 'username' => 'tester', diff --git a/tests/Feature/AdminPlayerManageApiTest.php b/tests/Feature/AdminPlayerManageApiTest.php index 1b15792..8e106d9 100644 --- a/tests/Feature/AdminPlayerManageApiTest.php +++ b/tests/Feature/AdminPlayerManageApiTest.php @@ -4,6 +4,7 @@ use App\Models\AuditLog; use App\Models\AdminUser; use App\Models\Player; use App\Models\PlayerWallet; +use App\Models\AdminRole; use App\Services\AuditLogger; use Illuminate\Support\Facades\Hash; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -24,6 +25,39 @@ function playerManageAdminToken(): string return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; } +function playerPermissionAdminToken(string $username, array $permissionSlugs): string +{ + $admin = AdminUser::query()->create([ + 'username' => $username, + 'name' => 'Player Permission Admin', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + $role = AdminRole::query()->create([ + 'slug' => 'role_'.$username, + 'name' => 'Role '.$username, + ]); + $role->syncLegacyPermissionSlugs($permissionSlugs); + + $admin->roles()->sync([ + (int) $role->id => [ + 'site_id' => AdminUser::defaultAdminSiteId(), + 'granted_at' => now(), + ], + ]); + + return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; +} + +function playerPermissionRequest($test, string $token) +{ + app('auth')->forgetGuards(); + + return $test->withHeader('Authorization', 'Bearer '.$token); +} + test('admin can freeze and unfreeze player with audit log', function (): void { $player = Player::query()->create([ 'site_code' => 'main', @@ -65,3 +99,84 @@ test('admin can freeze and unfreeze player with audit log', function (): void { expect(AuditLog::query()->where('module_code', 'player_manage')->count())->toBe(2); }); + +test('player manage permission gates write and freeze APIs separately from view permissions', function (): void { + $player = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'perm-1', + 'username' => 'perm_user', + 'nickname' => 'Perm', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $financeToken = playerPermissionAdminToken('player_finance_viewer', ['prd.users.view_finance']); + + playerPermissionRequest($this, $financeToken) + ->getJson('/api/v1/admin/players?per_page=10') + ->assertOk(); + + playerPermissionRequest($this, $financeToken) + ->getJson('/api/v1/admin/players/'.$player->id.'/wallets') + ->assertOk(); + + playerPermissionRequest($this, $financeToken) + ->getJson('/api/v1/admin/players/'.$player->id.'/ticket-items') + ->assertForbidden(); + + playerPermissionRequest($this, $financeToken) + ->postJson('/api/v1/admin/players', [ + 'site_code' => 'main', + 'site_player_id' => 'created-by-finance', + 'default_currency' => 'NPR', + ]) + ->assertForbidden(); + + playerPermissionRequest($this, $financeToken) + ->putJson('/api/v1/admin/players/'.$player->id, ['nickname' => 'blocked']) + ->assertForbidden(); + + playerPermissionRequest($this, $financeToken) + ->postJson('/api/v1/admin/players/'.$player->id.'/freeze') + ->assertForbidden(); + + $csToken = playerPermissionAdminToken('player_cs_viewer', ['prd.users.view_cs']); + + playerPermissionRequest($this, $csToken) + ->getJson('/api/v1/admin/players?per_page=10') + ->assertOk(); + + playerPermissionRequest($this, $csToken) + ->getJson('/api/v1/admin/players/'.$player->id.'/ticket-items') + ->assertOk(); + + playerPermissionRequest($this, $csToken) + ->getJson('/api/v1/admin/players/'.$player->id.'/wallets') + ->assertForbidden(); + + $freezeToken = playerPermissionAdminToken('player_freezer', ['prd.player_freeze.manage']); + + playerPermissionRequest($this, $freezeToken) + ->getJson('/api/v1/admin/players?per_page=10') + ->assertForbidden(); + + playerPermissionRequest($this, $freezeToken) + ->postJson('/api/v1/admin/players/'.$player->id.'/freeze') + ->assertOk() + ->assertJsonPath('data.status', 1); + + playerPermissionRequest($this, $freezeToken) + ->postJson('/api/v1/admin/players/'.$player->id.'/unfreeze') + ->assertOk() + ->assertJsonPath('data.status', 0); + + $manageToken = playerPermissionAdminToken('player_manager', ['prd.users.manage']); + + playerPermissionRequest($this, $manageToken) + ->getJson('/api/v1/admin/players/'.$player->id.'/wallets') + ->assertOk(); + + playerPermissionRequest($this, $manageToken) + ->getJson('/api/v1/admin/players/'.$player->id.'/ticket-items') + ->assertOk(); +}); diff --git a/tests/Feature/AdminSettlementJackpotApiTest.php b/tests/Feature/AdminSettlementJackpotApiTest.php index ce99a4a..c639fc1 100644 --- a/tests/Feature/AdminSettlementJackpotApiTest.php +++ b/tests/Feature/AdminSettlementJackpotApiTest.php @@ -28,29 +28,23 @@ test('admin settlement batches index is authenticated', function (): void { }); test('admin jackpot pools index returns rows', function (): void { - JackpotPool::query()->create([ - 'currency_code' => 'NPR', - 'current_amount' => 100, - '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, - ]); - $token = mintSettlementAdminToken(); $this->withHeader('Authorization', 'Bearer '.$token) ->getJson('/api/v1/admin/jackpot/pools') ->assertOk() ->assertJsonPath('data.items.0.currency_code', 'NPR') + ->assertJsonPath('data.items.0.contribution_rate', '0.0200') + ->assertJsonPath('data.items.0.trigger_threshold', 100000000) + ->assertJsonPath('data.items.0.payout_rate', '0.5000') + ->assertJsonPath('data.items.0.force_trigger_draw_gap', 100) + ->assertJsonPath('data.items.0.min_bet_amount', 100) + ->assertJsonPath('data.items.0.status', 0) ->assertJsonPath('data.items.0.combo_trigger_play_codes', []); }); test('admin can update jackpot combo trigger and manually burst pool', function (): void { - $pool = JackpotPool::query()->create([ - 'currency_code' => 'NPR', + $pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail(); + $pool->forceFill([ 'current_amount' => 1000, 'contribution_rate' => '0.01', 'trigger_threshold' => 1000, @@ -59,7 +53,7 @@ test('admin can update jackpot combo trigger and manually burst pool', function 'min_bet_amount' => 0, 'status' => 1, 'last_trigger_draw_id' => null, - ]); + ])->save(); $draw = Draw::query()->create([ 'draw_no' => '20260518-001', 'business_date' => '2026-05-18', diff --git a/tests/Feature/AdminUserPermissionApiTest.php b/tests/Feature/AdminUserPermissionApiTest.php index e63c292..3c4e96f 100644 --- a/tests/Feature/AdminUserPermissionApiTest.php +++ b/tests/Feature/AdminUserPermissionApiTest.php @@ -25,23 +25,7 @@ function makeAdminWithPermissions(string $username, array $permissionSlugs): str '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, - ]); - } + $role->syncLegacyPermissionSlugs($permissionSlugs); $siteId = AdminUser::defaultAdminSiteId(); $admin->roles()->sync([ @@ -129,7 +113,7 @@ test('admin can list users and sync direct permissions', function (): void { }); test('admin can sync user roles for default site', function (): void { - $token = makeAdminWithPermissions('rbac_role_editor', ['prd.admin_user.manage']); + $token = makeAdminWithPermissions('rbac_role_editor', ['prd.admin_user.manage', 'prd.admin_role.manage']); $r1 = AdminRole::query()->create(['slug' => 'role_sync_a', 'name' => 'Role A']); $r2 = AdminRole::query()->create(['slug' => 'role_sync_b', 'name' => 'Role B']); @@ -154,6 +138,104 @@ test('admin can sync user roles for default site', function (): void { expect($slugs)->toBe(['role_sync_a', 'role_sync_b']); }); +test('permission catalog groups permissions by admin navigation order', function (): void { + $token = makeAdminWithPermissions('nav_group_catalog', ['prd.admin_user.manage', 'prd.admin_role.manage']); + + $groups = $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/admin-user-permission-catalog') + ->assertOk() + ->json('data.permission_menu_groups'); + + expect(array_column($groups, 'key'))->toBe([ + 'admin_users', + 'admin_roles', + 'players', + 'wallet', + 'draws', + 'config', + 'risk', + 'settlement', + 'reconcile', + 'tickets', + 'reports', + 'audit', + ]); + expect($groups[0]['label'])->toBe('管理列表'); + expect(array_column($groups[0]['permissions'], 'slug'))->toBe(['prd.admin_user.manage']); + expect($groups[1]['label'])->toBe('角色管理'); + expect(array_column($groups[1]['permissions'], 'slug'))->toBe(['prd.admin_role.manage']); + + $groupsByKey = collect($groups)->keyBy('key'); + expect(array_column($groupsByKey['tickets']['permissions'], 'slug'))->toBe([ + 'prd.users.view_cs', + 'prd.users.manage', + 'prd.report.player', + ]); + expect(array_column($groupsByKey['config']['permissions'], 'slug'))->toContain( + 'prd.jackpot.manage', + 'prd.jackpot.view', + ); + expect(array_column($groupsByKey['reconcile']['permissions'], 'slug'))->toBe([ + 'prd.wallet_reconcile.manage', + 'prd.wallet_reconcile.view', + 'prd.wallet_reconcile.view_cs', + ]); +}); + +test('admin can repair role permissions from the full catalog after role creation', function (): void { + $token = makeAdminWithPermissions('role_permission_repairer', ['prd.admin_user.manage', 'prd.admin_role.manage']); + + $catalog = $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/admin-user-permission-catalog') + ->assertOk() + ->json('data'); + + $catalogSlugs = collect($catalog['permission_menu_groups']) + ->flatMap(static fn (array $group): array => array_column($group['permissions'], 'slug')) + ->unique() + ->values() + ->all(); + + expect($catalogSlugs) + ->toContain('prd.admin_user.manage') + ->toContain('prd.admin_role.manage') + ->toContain('prd.report.player') + ->toContain('prd.wallet_reconcile.manage'); + + $role = $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/admin-roles', [ + 'slug' => 'repairable_role', + 'name' => 'Repairable Role', + 'permission_slugs' => [], + ]) + ->assertOk() + ->assertJsonPath('data.permission_slugs', []) + ->json('data'); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/admin-roles/'.$role['id'].'/permissions', [ + 'permission_slugs' => ['prd.report.player', 'prd.wallet_reconcile.manage'], + ]) + ->assertOk() + ->assertJsonPath('data.slug', 'repairable_role') + ->assertJsonPath('data.permission_slugs', ['prd.report.player', 'prd.wallet_reconcile.manage']); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/admin-roles/'.$role['id'].'/permissions', [ + 'permission_slugs' => ['prd.admin_role.manage'], + ]) + ->assertOk() + ->assertJsonPath('data.permission_slugs', ['prd.admin_role.manage']); + + $persistedPermissions = $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/admin-roles') + ->assertOk() + ->json('data.items'); + + $persistedRole = collect($persistedPermissions)->firstWhere('slug', 'repairable_role'); + expect($persistedRole['permission_slugs'])->toBe(['prd.admin_role.manage']); +}); + test('admin can create update and delete users with crud rules', function (): void { $token = makeAdminWithPermissions('crud_actor', ['prd.admin_user.manage']); diff --git a/tests/Feature/AdminWalletApiTest.php b/tests/Feature/AdminWalletApiTest.php index 8dcbc44..c449ac2 100644 --- a/tests/Feature/AdminWalletApiTest.php +++ b/tests/Feature/AdminWalletApiTest.php @@ -109,6 +109,109 @@ test('admin filters abnormal transfer orders', function (): void { $resp->assertOk()->assertJsonPath('data.total', 2); }); +test('admin transfer order list exposes available reconcile actions by status', function (): void { + $token = makeAdminToken(); + + $player = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'action-player', + 'username' => null, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + foreach ( + [ + ['TI_processing', 'processing'], + ['TI_failed', 'failed'], + ['TI_wait', 'pending_reconcile'], + ['TI_done', 'success'], + ] as [$no, $st] + ) { + TransferOrder::query()->create([ + 'transfer_no' => $no, + 'player_id' => $player->id, + 'direction' => 'in', + 'currency_code' => 'NPR', + 'amount' => 100, + 'idempotent_key' => 'action-'.$no, + 'status' => $st, + 'external_request_payload' => null, + 'external_response_payload' => null, + 'external_ref_no' => null, + 'fail_reason' => null, + 'finished_at' => $st === 'success' ? now() : null, + ]); + } + + $items = $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/wallet/transfer-orders?per_page=10') + ->assertOk() + ->json('data.items'); + + $byNo = collect($items)->keyBy('transfer_no'); + + expect($byNo['TI_processing']['can_reverse'])->toBeFalse() + ->and($byNo['TI_processing']['can_manually_process'])->toBeTrue() + ->and($byNo['TI_failed']['can_reverse'])->toBeFalse() + ->and($byNo['TI_failed']['can_manually_process'])->toBeTrue() + ->and($byNo['TI_wait']['can_reverse'])->toBeTrue() + ->and($byNo['TI_wait']['can_manually_process'])->toBeTrue() + ->and($byNo['TI_done']['can_reverse'])->toBeFalse() + ->and($byNo['TI_done']['can_manually_process'])->toBeFalse(); +}); + +test('admin can manually process abnormal transfer orders except completed ones', function (): void { + $token = makeAdminToken(); + + $player = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'manual-player', + 'username' => null, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + foreach ( + [ + ['TI_processing_manual', 'processing'], + ['TI_failed_manual', 'failed'], + ['TI_success_manual', 'success'], + ] as [$no, $st] + ) { + TransferOrder::query()->create([ + 'transfer_no' => $no, + 'player_id' => $player->id, + 'direction' => 'in', + 'currency_code' => 'NPR', + 'amount' => 100, + 'idempotent_key' => 'manual-'.$no, + 'status' => $st, + 'external_request_payload' => null, + 'external_response_payload' => null, + 'external_ref_no' => null, + 'fail_reason' => null, + 'finished_at' => $st === 'success' ? now() : null, + ]); + } + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/wallet/transfer-orders/TI_processing_manual/manually-process') + ->assertOk() + ->assertJsonPath('data.status', 'manually_processed'); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/wallet/transfer-orders/TI_failed_manual/manually-process') + ->assertOk() + ->assertJsonPath('data.status', 'manually_processed'); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/wallet/transfer-orders/TI_success_manual/manually-process') + ->assertStatus(422); +}); + test('admin lists wallet transactions and filters abnormal', function (): void { $token = makeAdminToken();