From 3c92bef7749e79c57c243a368ca190b54860f8fb Mon Sep 17 00:00:00 2001 From: kang Date: Mon, 11 May 2026 17:54:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E6=9D=83=E9=99=90=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E6=9B=B4=E6=96=B0=20AdminUser=20=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E4=BB=A5=E6=94=AF=E6=8C=81=E6=9D=83=E9=99=90=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=EF=BC=8C=E6=89=A9=E5=B1=95=20API=20=E8=B7=AF=E7=94=B1=E4=BB=A5?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=90=8E=E5=8F=B0=E7=94=A8=E6=88=B7=E5=8F=8A?= =?UTF-8?q?=E5=85=B6=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../User/AdminPermissionCatalogController.php | 44 ++++++++ .../Admin/User/AdminUserIndexController.php | 65 +++++++++++ .../AdminUserPermissionSyncController.php | 45 ++++++++ app/Models/AdminUser.php | 38 ++++++- ...00_create_admin_user_permissions_table.php | 22 ++++ database/seeders/AdminRbacAndUserSeeder.php | 59 +++++----- routes/api.php | 12 +++ tests/Feature/AdminUserPermissionApiTest.php | 102 ++++++++++++++++++ 8 files changed, 356 insertions(+), 31 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/Admin/User/AdminPermissionCatalogController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/User/AdminUserIndexController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/User/AdminUserPermissionSyncController.php create mode 100644 database/migrations/2026_05_11_173000_create_admin_user_permissions_table.php create mode 100644 tests/Feature/AdminUserPermissionApiTest.php diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminPermissionCatalogController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminPermissionCatalogController.php new file mode 100644 index 0000000..69c3cd6 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminPermissionCatalogController.php @@ -0,0 +1,44 @@ +orderBy('slug') + ->get(['id', 'slug', 'name']); + + $roles = AdminRole::query() + ->with(['permissions:id,slug', 'users:id']) + ->orderBy('slug') + ->get(['id', 'slug', 'name']); + + return ApiResponse::success([ + 'permissions' => $permissions->map(static fn (AdminPermission $permission): array => [ + 'id' => (int) $permission->id, + 'slug' => $permission->slug, + 'name' => $permission->name, + ])->values()->all(), + 'roles' => $roles->map(static fn (AdminRole $role): array => [ + 'id' => (int) $role->id, + 'slug' => $role->slug, + 'name' => $role->name, + 'permission_slugs' => $role->permissions + ->pluck('slug') + ->filter(static fn ($slug): bool => is_string($slug) && $slug !== '') + ->values() + ->all(), + 'user_count' => $role->users->count(), + ])->values()->all(), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminUserIndexController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminUserIndexController.php new file mode 100644 index 0000000..5bddb29 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminUserIndexController.php @@ -0,0 +1,65 @@ +integer('per_page', 25), 1), 100); + $page = max((int) $request->integer('page', 1), 1); + $keyword = trim((string) $request->query('keyword', '')); + + $q = AdminUser::query() + ->with(['roles.permissions', 'permissions']) + ->orderByDesc('id'); + + if ($keyword !== '') { + $q->where(function ($sub) use ($keyword): void { + $sub->where('username', 'like', '%'.$keyword.'%') + ->orWhere('name', 'like', '%'.$keyword.'%') + ->orWhere('email', 'like', '%'.$keyword.'%'); + }); + } + + $paginator = $q->paginate($perPage, ['*'], 'page', $page); + + return ApiResponse::success([ + 'items' => collect($paginator->items())->map( + fn (AdminUser $user): array => $this->row($user) + )->all(), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } + + /** @return array */ + private function row(AdminUser $user): array + { + return [ + 'id' => (int) $user->id, + 'username' => $user->username, + 'nickname' => $user->name, + 'email' => $user->email, + 'status' => (int) $user->status, + 'roles' => $user->adminRoleSlugs(), + 'direct_permissions' => $user->permissions + ->pluck('slug') + ->filter(static fn ($slug): bool => is_string($slug) && $slug !== '') + ->values() + ->all(), + 'effective_permissions' => $user->adminPermissionSlugs(), + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminUserPermissionSyncController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminUserPermissionSyncController.php new file mode 100644 index 0000000..5e7a49a --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminUserPermissionSyncController.php @@ -0,0 +1,45 @@ +} $data */ + $data = validator($request->all(), [ + 'permission_slugs' => ['required', 'array'], + 'permission_slugs.*' => ['string', 'max:128', 'distinct', 'exists:admin_permissions,slug'], + ])->validate(); + + $slugs = array_values(array_unique($data['permission_slugs'])); + $permissionIds = AdminPermission::query() + ->whereIn('slug', $slugs) + ->pluck('id') + ->all(); + + $admin_user->permissions()->sync($permissionIds); + $admin_user->load(['roles.permissions', 'permissions']); + + return ApiResponse::success([ + 'id' => (int) $admin_user->id, + 'username' => $admin_user->username, + 'nickname' => $admin_user->name, + 'roles' => $admin_user->adminRoleSlugs(), + 'direct_permissions' => $admin_user->permissions + ->pluck('slug') + ->filter(static fn ($slug): bool => is_string($slug) && $slug !== '') + ->values() + ->all(), + 'effective_permissions' => $admin_user->adminPermissionSlugs(), + ]); + } +} diff --git a/app/Models/AdminUser.php b/app/Models/AdminUser.php index 17d1c4d..2d0d2b4 100644 --- a/app/Models/AdminUser.php +++ b/app/Models/AdminUser.php @@ -49,10 +49,21 @@ class AdminUser extends Authenticatable ); } + /** @return BelongsToMany */ + public function permissions(): BelongsToMany + { + return $this->belongsToMany( + AdminPermission::class, + 'admin_user_permissions', + 'admin_user_id', + 'permission_id', + ); + } + /** 是否具备指定权限(含 `super_admin` 角色全放行)。 */ public function hasAdminPermission(string $slug): bool { - $this->loadMissing(['roles.permissions']); + $this->loadMissing(['roles.permissions', 'permissions']); foreach ($this->roles as $role) { if ($role->slug === self::ROLE_SUPER_ADMIN) { @@ -65,6 +76,12 @@ class AdminUser extends Authenticatable } } + foreach ($this->permissions as $permission) { + if ($permission->slug === $slug) { + return true; + } + } + return false; } @@ -73,7 +90,7 @@ class AdminUser extends Authenticatable */ public function adminPermissionSlugs(): array { - $this->loadMissing(['roles.permissions']); + $this->loadMissing(['roles.permissions', 'permissions']); if ($this->roles->contains('slug', self::ROLE_SUPER_ADMIN)) { return AdminPermission::query()->orderBy('slug')->pluck('slug')->all(); } @@ -84,7 +101,24 @@ class AdminUser extends Authenticatable $out[$permission->slug] = true; } } + foreach ($this->permissions as $permission) { + $out[$permission->slug] = true; + } return array_keys($out); } + + /** + * @return list + */ + public function adminRoleSlugs(): array + { + $this->loadMissing('roles'); + + return $this->roles + ->pluck('slug') + ->filter(static fn ($slug): bool => is_string($slug) && $slug !== '') + ->values() + ->all(); + } } diff --git a/database/migrations/2026_05_11_173000_create_admin_user_permissions_table.php b/database/migrations/2026_05_11_173000_create_admin_user_permissions_table.php new file mode 100644 index 0000000..5a4acd7 --- /dev/null +++ b/database/migrations/2026_05_11_173000_create_admin_user_permissions_table.php @@ -0,0 +1,22 @@ +foreignId('admin_user_id')->constrained('admin_users')->cascadeOnDelete(); + $table->foreignId('permission_id')->constrained('admin_permissions')->cascadeOnDelete(); + $table->primary(['admin_user_id', 'permission_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('admin_user_permissions'); + } +}; diff --git a/database/seeders/AdminRbacAndUserSeeder.php b/database/seeders/AdminRbacAndUserSeeder.php index d66a026..f9fd7ea 100644 --- a/database/seeders/AdminRbacAndUserSeeder.php +++ b/database/seeders/AdminRbacAndUserSeeder.php @@ -22,43 +22,44 @@ class AdminRbacAndUserSeeder extends Seeder private function permissionDefinitions(): array { return [ - ['slug' => 'prd.users.manage', 'name' => '§8 用户管理·可管理'], - ['slug' => 'prd.users.view_finance', 'name' => '§8 用户管理·财务查看'], - ['slug' => 'prd.users.view_cs', 'name' => '§8 用户管理·客服单用户'], + ['slug' => 'prd.users.manage', 'name' => '用户管理·可管理'], + ['slug' => 'prd.users.view_finance', 'name' => '用户管理·财务查看'], + ['slug' => 'prd.users.view_cs', 'name' => '用户管理·客服单用户'], - ['slug' => 'prd.play_switch.manage', 'name' => '§8 玩法开关·可管理'], - ['slug' => 'prd.odds.manage', 'name' => '§8 赔率配置·可管理'], - ['slug' => 'prd.risk_cap.manage', 'name' => '§8 封顶配置·可管理'], - ['slug' => 'prd.risk_cap.view', 'name' => '§8 封顶配置·查看'], - ['slug' => 'prd.rebate.manage', 'name' => '§8 佣金/回水·可管理'], - ['slug' => 'prd.rebate.view', 'name' => '§8 佣金/回水·查看'], - ['slug' => 'prd.jackpot.manage', 'name' => '§8 Jackpot 配置·可管理'], - ['slug' => 'prd.jackpot.view', 'name' => '§8 Jackpot 配置·查看'], + ['slug' => 'prd.play_switch.manage', 'name' => '玩法开关·可管理'], + ['slug' => 'prd.odds.manage', 'name' => '赔率配置·可管理'], + ['slug' => 'prd.risk_cap.manage', 'name' => '封顶配置·可管理'], + ['slug' => 'prd.risk_cap.view', 'name' => '封顶配置·查看'], + ['slug' => 'prd.rebate.manage', 'name' => '佣金/回水·可管理'], + ['slug' => 'prd.rebate.view', 'name' => '佣金/回水·查看'], + ['slug' => 'prd.jackpot.manage', 'name' => 'Jackpot 配置·可管理'], + ['slug' => 'prd.jackpot.view', 'name' => 'Jackpot 配置·查看'], - ['slug' => 'prd.draw_result.manage', 'name' => '§8 开奖结果录入·可管理'], - ['slug' => 'prd.draw_result.view', 'name' => '§8 开奖结果·查看'], - ['slug' => 'prd.draw_reopen.manage', 'name' => '§8 开奖结果重开·可管理'], + ['slug' => 'prd.draw_result.manage', 'name' => '开奖结果录入·可管理'], + ['slug' => 'prd.draw_result.view', 'name' => '开奖结果·查看'], + ['slug' => 'prd.draw_reopen.manage', 'name' => '开奖结果重开·可管理'], - ['slug' => 'prd.payout.manage', 'name' => '§8 派彩确认·可管理'], - ['slug' => 'prd.payout.review', 'name' => '§8 派彩确认·可审核'], - ['slug' => 'prd.payout.view', 'name' => '§8 派彩确认·查看'], + ['slug' => 'prd.payout.manage', 'name' => '派彩确认·可管理'], + ['slug' => 'prd.payout.review', 'name' => '派彩确认·可审核'], + ['slug' => 'prd.payout.view', 'name' => '派彩确认·查看'], - ['slug' => 'prd.wallet_reconcile.manage', 'name' => '§8 钱包对账·可管理'], - ['slug' => 'prd.wallet_reconcile.view', 'name' => '§8 钱包对账·查看'], - ['slug' => 'prd.wallet_reconcile.view_cs', 'name' => '§8 钱包对账·客服单用户'], + ['slug' => 'prd.wallet_reconcile.manage', 'name' => '钱包对账·可管理'], + ['slug' => 'prd.wallet_reconcile.view', 'name' => '钱包对账·查看'], + ['slug' => 'prd.wallet_reconcile.view_cs', 'name' => '钱包对账·客服单用户'], - ['slug' => 'prd.wallet_adjust.manage', 'name' => '§8 补单/冲正·可管理'], + ['slug' => 'prd.wallet_adjust.manage', 'name' => '补单/冲正·可管理'], - ['slug' => 'prd.report.all', 'name' => '§8 报表·全部'], - ['slug' => 'prd.report.risk', 'name' => '§8 报表·风控'], - ['slug' => 'prd.report.finance', 'name' => '§8 报表·财务'], - ['slug' => 'prd.report.player', 'name' => '§8 报表·单用户'], + ['slug' => 'prd.report.all', 'name' => '报表·全部'], + ['slug' => 'prd.report.risk', 'name' => '报表·风控'], + ['slug' => 'prd.report.finance', 'name' => '报表·财务'], + ['slug' => 'prd.report.player', 'name' => '报表·单用户'], - ['slug' => 'prd.audit.all', 'name' => '§8 审计日志·全部'], - ['slug' => 'prd.audit.self', 'name' => '§8 审计日志·自身相关'], - ['slug' => 'prd.audit.finance', 'name' => '§8 审计日志·资金相关'], + ['slug' => 'prd.audit.all', 'name' => '审计日志·全部'], + ['slug' => 'prd.audit.self', 'name' => '审计日志·自身相关'], + ['slug' => 'prd.audit.finance', 'name' => '审计日志·资金相关'], - ['slug' => 'prd.player_freeze.manage', 'name' => '§8 冻结/解冻玩家·可管理'], + ['slug' => 'prd.player_freeze.manage', 'name' => '冻结/解冻玩家·可管理'], + ['slug' => 'prd.admin_user.manage', 'name' => '后台用户权限管理·可管理'], ]; } diff --git a/routes/api.php b/routes/api.php index a827c85..0de4ab6 100644 --- a/routes/api.php +++ b/routes/api.php @@ -47,6 +47,9 @@ use App\Http\Controllers\Api\V1\Admin\Risk\AdminRiskPoolShowController; use App\Http\Controllers\Api\V1\Admin\Settlement\AdminSettlementBatchDetailsController; use App\Http\Controllers\Api\V1\Admin\Settlement\AdminSettlementBatchIndexController; use App\Http\Controllers\Api\V1\Admin\Settlement\AdminSettlementBatchShowController; +use App\Http\Controllers\Api\V1\Admin\User\AdminPermissionCatalogController; +use App\Http\Controllers\Api\V1\Admin\User\AdminUserIndexController; +use App\Http\Controllers\Api\V1\Admin\User\AdminUserPermissionSyncController; use App\Http\Controllers\Api\V1\Admin\Wallet\TransferOrderListController; use App\Http\Controllers\Api\V1\Admin\Wallet\WalletTransactionListController; use App\Http\Controllers\Api\V1\Draw\DrawCurrentController; @@ -307,6 +310,15 @@ Route::prefix('v1')->group(function (): void { Route::middleware('admin.permission:prd.wallet_reconcile.manage')->group(function (): void { Route::post('reconcile-jobs', ReconcileJobStoreController::class)->name('reconcile-jobs.store'); }); + + /** 后台账号与权限分配:仅可管理账户执行。 */ + Route::middleware('admin.permission:prd.admin_user.manage')->group(function (): void { + Route::get('admin-users', AdminUserIndexController::class)->name('admin-users.index'); + Route::get('admin-user-permission-catalog', AdminPermissionCatalogController::class) + ->name('admin-users.permission-catalog'); + Route::put('admin-users/{admin_user}/permissions', AdminUserPermissionSyncController::class) + ->name('admin-users.permissions.sync'); + }); }); }); }); diff --git a/tests/Feature/AdminUserPermissionApiTest.php b/tests/Feature/AdminUserPermissionApiTest.php new file mode 100644 index 0000000..b7ceafd --- /dev/null +++ b/tests/Feature/AdminUserPermissionApiTest.php @@ -0,0 +1,102 @@ +create([ + 'username' => $username, + 'name' => 'Tester', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + $role = AdminRole::query()->create([ + 'slug' => 'role_'.$username, + 'name' => 'Role '.$username, + ]); + + foreach ($permissionSlugs as $slug) { + $permission = AdminPermission::query()->firstOrCreate( + ['slug' => $slug], + ['name' => $slug], + ); + $role->permissions()->syncWithoutDetaching([(int) $permission->id]); + } + + $admin->roles()->syncWithoutDetaching([(int) $role->id]); + + return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; +} + +test('admin user permission apis require rbac permission', function (): void { + AdminPermission::query()->create(['slug' => 'prd.admin_user.manage', 'name' => 'admin manage']); + + $token = makeAdminWithPermissions('rbac_viewer', ['prd.report.player']); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/admin-users') + ->assertForbidden() + ->assertJsonPath('code', ErrorCode::AdminForbidden->value); +}); + +test('admin can list users and sync direct permissions', function (): void { + $manage = AdminPermission::query()->create(['slug' => 'prd.admin_user.manage', 'name' => 'admin manage']); + $report = AdminPermission::query()->create(['slug' => 'prd.report.player', 'name' => 'report player']); + $draw = AdminPermission::query()->create(['slug' => 'prd.draw_result.view', 'name' => 'draw view']); + + $token = makeAdminWithPermissions('rbac_manager', ['prd.admin_user.manage']); + + $target = AdminUser::query()->create([ + 'username' => 'target_user', + 'name' => 'Target User', + 'email' => 'target@example.com', + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + $targetRole = AdminRole::query()->create(['slug' => 'target_role', 'name' => 'Target Role']); + $targetRole->permissions()->sync([(int) $draw->id]); + $target->roles()->sync([(int) $targetRole->id]); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/admin-user-permission-catalog') + ->assertOk() + ->assertJsonPath('code', ErrorCode::Success->value) + ->assertJsonPath('data.permissions.0.slug', 'prd.admin_user.manage'); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/admin-users?keyword=target') + ->assertOk() + ->assertJsonPath('code', ErrorCode::Success->value) + ->assertJsonPath('data.items.0.username', 'target_user') + ->assertJsonPath('data.items.0.roles.0', 'target_role'); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/admin-users/'.$target->id.'/permissions', [ + 'permission_slugs' => [$report->slug], + ]) + ->assertOk() + ->assertJsonPath('code', ErrorCode::Success->value) + ->assertJsonPath('data.direct_permissions.0', 'prd.report.player'); + + expect( + $target->fresh()->permissions()->pluck('slug')->sort()->values()->all() + )->toBe([$report->slug]); + + $list = $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/admin-users?keyword=target') + ->assertOk() + ->json('data.items.0.effective_permissions'); + + expect($list)->toContain($draw->slug); + expect($list)->toContain($report->slug); + expect($manage->slug)->toBe('prd.admin_user.manage'); +});