diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminPermissionCatalogController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminPermissionCatalogController.php index 69c3cd6..4d34f81 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminPermissionCatalogController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminPermissionCatalogController.php @@ -3,42 +3,79 @@ namespace App\Http\Controllers\Api\V1\Admin\User; use App\Http\Controllers\Controller; -use App\Models\AdminPermission; use App\Models\AdminRole; use App\Support\ApiResponse; use Illuminate\Http\JsonResponse; +use Illuminate\Support\Facades\DB; /** GET /api/v1/admin/admin-user-permission-catalog */ final class AdminPermissionCatalogController extends Controller { public function __invoke(): JsonResponse { - $permissions = AdminPermission::query() - ->orderBy('slug') - ->get(['id', 'slug', 'name']); + /** @var list */ + $catalog = config('admin_permissions.catalog', []); + /** @var list}> $groupDefs */ + $groupDefs = config('admin_permissions.catalog_menu_groups', []); + + $catalogBySlug = collect($catalog)->keyBy('slug'); + + $permissions = collect($catalog)->values()->map(static fn (array $row, int $index): array => [ + 'id' => $index + 1, + 'slug' => $row['slug'], + 'name' => $row['name'], + ])->all(); + + $permissionIdBySlug = collect($permissions)->keyBy('slug')->map(static fn (array $row): int => $row['id'])->all(); + + $permissionMenuGroups = []; + foreach ($groupDefs as $g) { + $rows = []; + foreach ($g['slugs'] as $slug) { + $meta = $catalogBySlug->get($slug); + if ($meta === null) { + continue; + } + $id = $permissionIdBySlug[$slug] ?? null; + if ($id === null) { + continue; + } + $rows[] = [ + 'id' => (int) $id, + 'slug' => $slug, + 'name' => $meta['name'], + ]; + } + if ($rows !== []) { + $permissionMenuGroups[] = [ + 'key' => $g['key'], + 'label' => $g['label'], + 'permissions' => $rows, + ]; + } + } $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(), + 'permissions' => $permissions, + 'permission_menu_groups' => $permissionMenuGroups, + '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(), ]); } } diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminUserIndexController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminUserIndexController.php index 5bddb29..1495389 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminUserIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminUserIndexController.php @@ -18,7 +18,7 @@ final class AdminUserIndexController extends Controller $keyword = trim((string) $request->query('keyword', '')); $q = AdminUser::query() - ->with(['roles.permissions', 'permissions']) + ->with(['roles']) ->orderByDesc('id'); if ($keyword !== '') { @@ -54,11 +54,7 @@ final class AdminUserIndexController extends Controller '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(), + 'direct_permissions' => $user->directLegacyPermissionSlugs(), '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 index 5e7a49a..129eacc 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminUserPermissionSyncController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminUserPermissionSyncController.php @@ -3,11 +3,13 @@ namespace App\Http\Controllers\Api\V1\Admin\User; use App\Http\Controllers\Controller; -use App\Models\AdminPermission; use App\Models\AdminUser; +use App\Support\AdminPermissionBridge; use App\Support\ApiResponse; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; +use Illuminate\Validation\Rule; /** PUT /api/v1/admin/admin-users/{admin_user}/permissions */ final class AdminUserPermissionSyncController extends Controller @@ -17,28 +19,51 @@ final class AdminUserPermissionSyncController extends Controller /** @var array{permission_slugs:list} $data */ $data = validator($request->all(), [ 'permission_slugs' => ['required', 'array'], - 'permission_slugs.*' => ['string', 'max:128', 'distinct', 'exists:admin_permissions,slug'], + 'permission_slugs.*' => ['string', 'max:128', 'distinct', Rule::in(AdminPermissionBridge::allLegacySlugs())], ])->validate(); $slugs = array_values(array_unique($data['permission_slugs'])); - $permissionIds = AdminPermission::query() - ->whereIn('slug', $slugs) + $siteId = AdminUser::defaultAdminSiteId(); + + $codes = []; + foreach ($slugs as $slug) { + $codes = array_merge($codes, AdminPermissionBridge::menuActionCodesForLegacy($slug)); + } + $codes = array_values(array_unique($codes)); + + $menuActionIds = DB::table('admin_menu_actions') + ->whereIn('permission_code', $codes) + ->where('status', 1) ->pluck('id') ->all(); - $admin_user->permissions()->sync($permissionIds); - $admin_user->load(['roles.permissions', 'permissions']); + DB::transaction(function () use ($admin_user, $siteId, $menuActionIds): void { + DB::table('admin_user_menu_actions') + ->where('admin_user_id', $admin_user->id) + ->where(function ($q) use ($siteId): void { + $q->where('site_id', $siteId)->orWhereNull('site_id'); + }) + ->delete(); + + $now = now(); + foreach ($menuActionIds as $mid) { + DB::table('admin_user_menu_actions')->insert([ + 'admin_user_id' => $admin_user->id, + 'site_id' => $siteId, + 'menu_action_id' => (int) $mid, + 'granted_at' => $now, + ]); + } + }); + + $admin_user->load('roles'); 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(), + 'direct_permissions' => $admin_user->directLegacyPermissionSlugs(), 'effective_permissions' => $admin_user->adminPermissionSlugs(), ]); } diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminUserRoleSyncController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminUserRoleSyncController.php new file mode 100644 index 0000000..2114993 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminUserRoleSyncController.php @@ -0,0 +1,60 @@ +} $data */ + $data = validator($request->all(), [ + 'role_slugs' => ['required', 'array'], + 'role_slugs.*' => ['string', 'max:64', 'distinct', Rule::exists('admin_roles', 'slug')], + ])->validate(); + + $slugs = array_values(array_unique($data['role_slugs'])); + $siteId = AdminUser::defaultAdminSiteId(); + + $roleIds = DB::table('admin_roles') + ->whereIn('slug', $slugs) + ->pluck('id') + ->all(); + + DB::transaction(function () use ($admin_user, $siteId, $roleIds): void { + DB::table('admin_user_site_roles') + ->where('admin_user_id', $admin_user->id) + ->where('site_id', $siteId) + ->delete(); + + $now = now(); + foreach ($roleIds as $rid) { + DB::table('admin_user_site_roles')->insert([ + 'admin_user_id' => $admin_user->id, + 'site_id' => $siteId, + 'role_id' => (int) $rid, + 'granted_at' => $now, + ]); + } + }); + + $admin_user->load('roles'); + + return ApiResponse::success([ + 'id' => (int) $admin_user->id, + 'username' => $admin_user->username, + 'nickname' => $admin_user->name, + 'roles' => $admin_user->adminRoleSlugs(), + 'direct_permissions' => $admin_user->directLegacyPermissionSlugs(), + 'effective_permissions' => $admin_user->adminPermissionSlugs(), + ]); + } +} diff --git a/app/Http/Middleware/EnsureAdminPermission.php b/app/Http/Middleware/EnsureAdminPermission.php index f08cdf4..b5c84f0 100644 --- a/app/Http/Middleware/EnsureAdminPermission.php +++ b/app/Http/Middleware/EnsureAdminPermission.php @@ -10,7 +10,7 @@ use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; /** - * 后台 RBAC:在 {@see EnsureAdminApi} 之后校验 `admin_permissions.slug`。 + * 后台 RBAC:在 {@see EnsureAdminApi} 之后校验 `prd.*` 等功能权限 slug(与 {@see AdminUser::hasAdminPermission} 一致)。 * 路由参数支持 `slug` 或 `slug1|slug2`(满足其一即可)。 */ class EnsureAdminPermission diff --git a/app/Models/AdminMenu.php b/app/Models/AdminMenu.php new file mode 100644 index 0000000..6de1306 --- /dev/null +++ b/app/Models/AdminMenu.php @@ -0,0 +1,10 @@ + */ + public function menu(): BelongsTo + { + return $this->belongsTo(AdminMenu::class, 'menu_id'); + } +} diff --git a/app/Models/AdminPermission.php b/app/Models/AdminPermission.php deleted file mode 100644 index 17b20e7..0000000 --- a/app/Models/AdminPermission.php +++ /dev/null @@ -1,27 +0,0 @@ - */ - public function roles(): BelongsToMany - { - return $this->belongsToMany( - AdminRole::class, - 'admin_role_permissions', - 'permission_id', - 'role_id', - ); - } -} diff --git a/app/Models/AdminRole.php b/app/Models/AdminRole.php index 0c5a5df..941e89a 100644 --- a/app/Models/AdminRole.php +++ b/app/Models/AdminRole.php @@ -2,26 +2,44 @@ namespace App\Models; +use App\Support\AdminPermissionBridge; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Support\Facades\DB; class AdminRole extends Model { protected $table = 'admin_roles'; + protected static function booted(): void + { + static::creating(function (AdminRole $role): void { + if (($role->code ?? '') === '' && is_string($role->slug) && $role->slug !== '') { + $role->code = $role->slug; + } + }); + } + protected $fillable = [ 'slug', 'name', + 'code', + 'description', + 'status', + 'is_system', + 'sort_order', ]; - /** @return BelongsToMany */ - public function permissions(): BelongsToMany + /** + * @return BelongsToMany + */ + public function menuActions(): BelongsToMany { return $this->belongsToMany( - AdminPermission::class, - 'admin_role_permissions', + AdminMenuAction::class, + 'admin_role_menu_actions', 'role_id', - 'permission_id', + 'menu_action_id', ); } @@ -30,9 +48,24 @@ class AdminRole extends Model { return $this->belongsToMany( AdminUser::class, - 'admin_user_roles', + 'admin_user_site_roles', 'role_id', 'admin_user_id', - ); + )->withPivot(['site_id', 'granted_at']); + } + + /** + * @return list + */ + public function legacyPermissionSlugs(): array + { + $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) + ->where('ma.status', 1) + ->pluck('ma.permission_code') + ->all(); + + return AdminPermissionBridge::legacySlugsGrantedByMenuActionCodes($codes); } } diff --git a/app/Models/AdminUser.php b/app/Models/AdminUser.php index 2d0d2b4..1fc6d2f 100644 --- a/app/Models/AdminUser.php +++ b/app/Models/AdminUser.php @@ -2,9 +2,11 @@ namespace App\Models; +use App\Support\AdminPermissionBridge; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Facades\DB; use Laravel\Sanctum\HasApiTokens; class AdminUser extends Authenticatable @@ -38,76 +40,159 @@ class AdminUser extends Authenticatable ]; } - /** @return BelongsToMany */ + public static function defaultAdminSiteId(): int + { + static $cached = null; + if ($cached !== null) { + return $cached; + } + $id = DB::table('admin_sites')->where('is_default', true)->value('id'); + if ($id === null) { + $id = DB::table('admin_sites')->orderBy('id')->value('id'); + } + if ($id === null) { + throw new \RuntimeException('No admin_sites row found.'); + } + $cached = (int) $id; + + return $cached; + } + + /** + * 用户在各站点上的角色(多站点 RBAC)。 + * + * @return BelongsToMany + */ public function roles(): BelongsToMany { return $this->belongsToMany( AdminRole::class, - 'admin_user_roles', + 'admin_user_site_roles', 'admin_user_id', 'role_id', - ); + )->withPivot(['site_id', 'granted_at']); } - /** @return BelongsToMany */ - public function permissions(): BelongsToMany + public function isSuperAdmin(): bool { - 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', 'permissions']); - - foreach ($this->roles as $role) { - if ($role->slug === self::ROLE_SUPER_ADMIN) { - return true; - } - foreach ($role->permissions as $permission) { - if ($permission->slug === $slug) { - return true; - } - } + if ($this->relationLoaded('roles')) { + return $this->roles->contains('slug', self::ROLE_SUPER_ADMIN); } - foreach ($this->permissions as $permission) { - if ($permission->slug === $slug) { - return true; - } - } - - return false; + return $this->roles()->where('admin_roles.slug', self::ROLE_SUPER_ADMIN)->exists(); } /** + * 仅来自「直接授权」的 menu_action.permission_code(默认站点,含 site_id 为 null 的历史行)。 + * * @return list */ - public function adminPermissionSlugs(): array + public function directMenuActionPermissionCodes(): array { - $this->loadMissing(['roles.permissions', 'permissions']); - if ($this->roles->contains('slug', self::ROLE_SUPER_ADMIN)) { - return AdminPermission::query()->orderBy('slug')->pluck('slug')->all(); - } + $siteId = self::defaultAdminSiteId(); + $rows = DB::table('admin_user_menu_actions as uma') + ->join('admin_menu_actions as ma', 'ma.id', '=', 'uma.menu_action_id') + ->where('uma.admin_user_id', $this->id) + ->where(function ($q) use ($siteId): void { + $q->where('uma.site_id', $siteId)->orWhereNull('uma.site_id'); + }) + ->where('ma.status', 1) + ->pluck('ma.permission_code') + ->all(); $out = []; - foreach ($this->roles as $role) { - foreach ($role->permissions as $permission) { - $out[$permission->slug] = true; + foreach ($rows as $code) { + if (is_string($code) && $code !== '') { + $out[$code] = true; } } - foreach ($this->permissions as $permission) { - $out[$permission->slug] = true; - } return array_keys($out); } + /** + * 直接授权对应的 `prd.*` 展示列表(与 {@see self::directMenuActionPermissionCodes()} 桥接)。 + * + * @return list + */ + public function directLegacyPermissionSlugs(): array + { + return AdminPermissionBridge::legacySlugsGrantedByMenuActionCodes($this->directMenuActionPermissionCodes()); + } + + /** + * 角色 + 直接授权合并后的 menu_action.permission_code。 + * + * @return list + */ + public function effectiveMenuActionPermissionCodes(): array + { + if ($this->isSuperAdmin()) { + $codes = DB::table('admin_menu_actions')->where('status', 1)->pluck('permission_code')->all(); + $out = []; + foreach ($codes as $c) { + if (is_string($c) && $c !== '') { + $out[$c] = true; + } + } + + return array_keys($out); + } + + $fromRoles = DB::table('admin_user_site_roles as usr') + ->join('admin_role_menu_actions as rma', 'rma.role_id', '=', 'usr.role_id') + ->join('admin_menu_actions as ma', 'ma.id', '=', 'rma.menu_action_id') + ->where('usr.admin_user_id', $this->id) + ->where('ma.status', 1) + ->pluck('ma.permission_code') + ->all(); + + $merged = []; + foreach (array_merge($fromRoles, $this->directMenuActionPermissionCodes()) as $c) { + if (is_string($c) && $c !== '') { + $merged[$c] = true; + } + } + + return array_keys($merged); + } + + /** 是否具备指定权限:`prd.*` 走 legacy_map;否则按 permission_code 精确匹配。含 `super_admin` 全放行。 */ + public function hasAdminPermission(string $slug): bool + { + if ($this->isSuperAdmin()) { + return true; + } + + $effective = $this->effectiveMenuActionPermissionCodes(); + if ($slug !== '' && in_array($slug, $effective, true)) { + return true; + } + + if (! str_starts_with($slug, 'prd.')) { + return false; + } + + $needed = AdminPermissionBridge::menuActionCodesForLegacy($slug); + if ($needed === []) { + return false; + } + + return count(array_intersect($needed, $effective)) > 0; + } + + /** + * @return list 与 Next 侧栏、`admin.permission` 中间件一致的 `prd.*` slug 列表 + */ + public function adminPermissionSlugs(): array + { + if ($this->isSuperAdmin()) { + return AdminPermissionBridge::allLegacySlugs(); + } + + return AdminPermissionBridge::legacySlugsGrantedByMenuActionCodes($this->effectiveMenuActionPermissionCodes()); + } + /** * @return list */ @@ -118,6 +203,7 @@ class AdminUser extends Authenticatable return $this->roles ->pluck('slug') ->filter(static fn ($slug): bool => is_string($slug) && $slug !== '') + ->unique() ->values() ->all(); } diff --git a/app/Support/AdminPermissionBridge.php b/app/Support/AdminPermissionBridge.php new file mode 100644 index 0000000..3b448de --- /dev/null +++ b/app/Support/AdminPermissionBridge.php @@ -0,0 +1,67 @@ +> */ + public static function legacyMap(): array + { + /** @var array> */ + return config('admin_permissions.legacy_map', []); + } + + /** @return list */ + public static function allLegacySlugs(): array + { + $keys = array_keys(self::legacyMap()); + sort($keys); + + return $keys; + } + + /** @return list */ + public static function menuActionCodesForLegacy(string $legacySlug): array + { + return array_values(array_unique(self::legacyMap()[$legacySlug] ?? [])); + } + + /** + * 若管理员拥有的任意 menu_action.permission_code 落在某 `prd.*` 映射集合内,则视为拥有该 `prd.*` + *(与路由中间件「满足其一」及 Next 侧栏 `requiredAny` 语义一致)。 + * + * @param list $menuActionCodes + * @return list + */ + public static function legacySlugsGrantedByMenuActionCodes(array $menuActionCodes): array + { + if ($menuActionCodes === []) { + return []; + } + + $set = []; + foreach ($menuActionCodes as $code) { + if (is_string($code) && $code !== '') { + $set[$code] = true; + } + } + if ($set === []) { + return []; + } + + $out = []; + foreach (self::legacyMap() as $legacySlug => $requiredCodes) { + foreach ($requiredCodes as $code) { + if (isset($set[$code])) { + $out[$legacySlug] = true; + break; + } + } + } + + $keys = array_keys($out); + sort($keys); + + return $keys; + } +} diff --git a/config/admin_permissions.php b/config/admin_permissions.php new file mode 100644 index 0000000..5b5b864 --- /dev/null +++ b/config/admin_permissions.php @@ -0,0 +1,165 @@ +> + */ +$legacyMap = [ + '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.play_switch.manage' => ['config.play.manage'], + 'prd.odds.manage' => ['config.odds.manage'], + 'prd.risk_cap.manage' => ['config.risk_cap.manage'], + 'prd.risk_cap.view' => ['config.risk_cap.view'], + 'prd.rebate.manage' => ['config.odds.manage'], + 'prd.rebate.view' => ['config.odds.manage'], + 'prd.jackpot.manage' => ['config.jackpot.manage'], + 'prd.jackpot.view' => ['config.jackpot.view'], + 'prd.draw_result.manage' => ['draw.results.view', 'draw.review.review', 'draw.review.publish', 'risk.monitor.view'], + 'prd.draw_result.view' => ['draw.results.view', 'risk.monitor.view'], + 'prd.payout.manage' => ['settlement.batch.manage', 'settlement.batch.view'], + 'prd.payout.review' => ['settlement.batch.review', 'settlement.batch.view'], + 'prd.payout.view' => ['settlement.batch.view'], + 'prd.wallet_reconcile.manage' => ['service.wallet.manage', 'service.reconcile.manage'], + 'prd.wallet_reconcile.view' => ['service.wallet.view', 'service.reconcile.view'], + 'prd.wallet_reconcile.view_cs' => ['service.wallet.view', 'service.reconcile.view'], + 'prd.report.all' => ['service.reports.view', 'service.reports.export'], + 'prd.report.risk' => ['service.reports.view'], + 'prd.report.finance' => ['service.reports.view', 'service.reports.export'], + 'prd.report.player' => ['service.reports.view'], + 'prd.audit.all' => ['service.audit.view'], + '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.wallet_adjust.manage' => ['service.wallet.manage'], + 'prd.draw_reopen.manage' => ['draw.review.publish'], +]; + +$catalog = [ + ['slug' => 'prd.users.manage', 'name' => '用户管理·可管理'], + ['slug' => 'prd.users.view_finance', 'name' => '用户管理·财务查看'], + ['slug' => 'prd.users.view_cs', 'name' => '用户管理·客服单用户'], + ['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' => '开奖结果录入·可管理'], + ['slug' => 'prd.draw_result.view', 'name' => '开奖结果·查看'], + ['slug' => 'prd.draw_reopen.manage', 'name' => '开奖结果重开·可管理'], + ['slug' => 'prd.payout.manage', 'name' => '派彩确认·可管理'], + ['slug' => 'prd.payout.review', 'name' => '派彩确认·可审核'], + ['slug' => 'prd.payout.view', 'name' => '派彩确认·查看'], + ['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' => '补单/冲正·可管理'], + ['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' => '审计日志·全部'], + ['slug' => 'prd.audit.self', 'name' => '审计日志·自身相关'], + ['slug' => 'prd.audit.finance', 'name' => '审计日志·资金相关'], + ['slug' => 'prd.player_freeze.manage', 'name' => '冻结/解冻玩家·可管理'], + ['slug' => 'prd.admin_user.manage', 'name' => '后台用户权限管理·可管理'], +]; + +/** + * 后台「直接权限」勾选:一级菜单/业务域 → 下属 prd.*(与侧栏模块大致对应,纯展示分组)。 + * + * @var list}> + */ +$catalogMenuGroups = [ + [ + 'key' => 'users_players', + 'label' => '用户与玩家', + 'slugs' => [ + 'prd.users.manage', + 'prd.users.view_finance', + 'prd.users.view_cs', + 'prd.player_freeze.manage', + ], + ], + [ + 'key' => 'ops_config', + 'label' => '运营配置', + 'slugs' => [ + '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', + ], + ], + [ + 'key' => 'draw_risk', + 'label' => '开奖与风控', + 'slugs' => [ + 'prd.draw_result.manage', + 'prd.draw_result.view', + 'prd.draw_reopen.manage', + ], + ], + [ + 'key' => 'settlement', + 'label' => '结算与派彩', + 'slugs' => [ + 'prd.payout.manage', + 'prd.payout.review', + 'prd.payout.view', + ], + ], + [ + 'key' => 'wallet', + 'label' => '钱包与对账', + 'slugs' => [ + 'prd.wallet_reconcile.manage', + 'prd.wallet_reconcile.view', + 'prd.wallet_reconcile.view_cs', + 'prd.wallet_adjust.manage', + ], + ], + [ + 'key' => 'reports', + 'label' => '报表', + 'slugs' => [ + 'prd.report.all', + 'prd.report.risk', + 'prd.report.finance', + 'prd.report.player', + ], + ], + [ + 'key' => 'audit', + 'label' => '审计日志', + 'slugs' => [ + 'prd.audit.all', + 'prd.audit.self', + 'prd.audit.finance', + ], + ], + [ + 'key' => 'system', + 'label' => '系统管理', + 'slugs' => [ + 'prd.admin_user.manage', + ], + ], +]; + +return [ + 'legacy_map' => $legacyMap, + 'catalog' => $catalog, + 'catalog_menu_groups' => $catalogMenuGroups, +]; 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 new file mode 100644 index 0000000..4b63cae --- /dev/null +++ b/database/migrations/2026_05_13_100000_rebuild_admin_authorization_system.php @@ -0,0 +1,742 @@ +createTables(); + $this->seedInitialData(); + $this->migrateLegacyAssignments(); + $this->dropLegacyTables(); + } + + public function down(): void + { + $this->recreateLegacyTables(); + $this->migrateBackToLegacyTables(); + + Schema::dropIfExists('admin_user_site_roles'); + Schema::dropIfExists('admin_user_menu_actions'); + Schema::dropIfExists('admin_user_data_scopes'); + Schema::dropIfExists('admin_role_menu_actions'); + Schema::dropIfExists('admin_role_api_resources'); + Schema::dropIfExists('admin_role_menus'); + Schema::dropIfExists('admin_role_data_scopes'); + Schema::dropIfExists('admin_api_resource_bindings'); + Schema::dropIfExists('admin_api_resources'); + Schema::dropIfExists('admin_menu_actions'); + Schema::dropIfExists('admin_action_catalog'); + Schema::dropIfExists('admin_menus'); + Schema::dropIfExists('admin_data_scopes'); + Schema::dropIfExists('admin_sites'); + } + + private function createTables(): void + { + Schema::create('admin_sites', function (Blueprint $table): void { + $table->id(); + $table->string('code', 64)->unique(); + $table->string('name', 128); + $table->string('currency_code', 16)->default('NPR'); + $table->unsignedTinyInteger('status')->default(1)->comment('1=enabled,0=disabled'); + $table->boolean('is_default')->default(false); + $table->json('extra_json')->nullable(); + $table->timestamps(); + }); + + Schema::table('admin_roles', function (Blueprint $table): void { + $table->string('code', 64)->nullable()->after('id'); + $table->text('description')->nullable()->after('name'); + $table->unsignedTinyInteger('status')->default(1)->comment('1=enabled,0=disabled')->after('description'); + $table->boolean('is_system')->default(false)->after('status'); + $table->unsignedInteger('sort_order')->default(0)->after('is_system'); + }); + + DB::table('admin_roles')->update([ + 'code' => DB::raw('slug'), + 'status' => 1, + 'is_system' => true, + 'sort_order' => 0, + ]); + + Schema::table('admin_roles', function (Blueprint $table): void { + $table->string('code', 64)->nullable(false)->change(); + $table->unique('code'); + }); + + Schema::create('admin_menus', function (Blueprint $table): void { + $table->id(); + $table->foreignId('parent_id')->nullable()->constrained('admin_menus')->nullOnDelete(); + $table->string('menu_type', 24)->comment('directory|menu|page'); + $table->string('code', 128)->unique(); + $table->string('name', 128); + $table->string('path', 255)->nullable(); + $table->string('route_name', 255)->nullable(); + $table->string('component', 255)->nullable(); + $table->string('icon', 128)->nullable(); + $table->string('active_menu_code', 128)->nullable(); + $table->unsignedInteger('sort_order')->default(0); + $table->boolean('is_visible')->default(true); + $table->boolean('is_cache')->default(false); + $table->boolean('is_external')->default(false); + $table->unsignedTinyInteger('status')->default(1)->comment('1=enabled,0=disabled'); + $table->json('meta_json')->nullable(); + $table->timestamps(); + + $table->index(['parent_id', 'sort_order'], 'idx_admin_menus_parent_sort'); + }); + + Schema::create('admin_action_catalog', function (Blueprint $table): void { + $table->id(); + $table->string('code', 64)->unique(); + $table->string('name', 64); + $table->unsignedInteger('sort_order')->default(0); + $table->unsignedTinyInteger('status')->default(1)->comment('1=enabled,0=disabled'); + $table->timestamps(); + }); + + Schema::create('admin_menu_actions', function (Blueprint $table): void { + $table->id(); + $table->foreignId('menu_id')->constrained('admin_menus')->cascadeOnDelete(); + $table->foreignId('action_id')->constrained('admin_action_catalog')->cascadeOnDelete(); + $table->string('permission_code', 128)->unique(); + $table->string('name', 128); + $table->unsignedTinyInteger('status')->default(1)->comment('1=enabled,0=disabled'); + $table->timestamps(); + + $table->unique(['menu_id', 'action_id'], 'uk_admin_menu_actions_menu_action'); + $table->index(['menu_id', 'status'], 'idx_admin_menu_actions_menu_status'); + }); + + Schema::create('admin_api_resources', function (Blueprint $table): void { + $table->id(); + $table->string('code', 128)->unique(); + $table->string('module_code', 64); + $table->string('name', 128); + $table->string('http_method', 16); + $table->string('uri_pattern', 255); + $table->string('route_name', 255)->nullable(); + $table->string('auth_mode', 24)->default('permission_required')->comment('login_only|permission_required|internal_only'); + $table->boolean('is_audit_required')->default(false); + $table->unsignedTinyInteger('status')->default(1)->comment('1=enabled,0=disabled'); + $table->json('meta_json')->nullable(); + $table->timestamps(); + + $table->index(['module_code', 'status'], 'idx_admin_api_resources_module_status'); + }); + + Schema::create('admin_api_resource_bindings', function (Blueprint $table): void { + $table->id(); + $table->foreignId('api_resource_id')->constrained('admin_api_resources')->cascadeOnDelete(); + $table->foreignId('menu_action_id')->constrained('admin_menu_actions')->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['api_resource_id', 'menu_action_id'], 'uk_admin_api_bindings_api_action'); + }); + + Schema::create('admin_data_scopes', function (Blueprint $table): void { + $table->id(); + $table->string('code', 64)->unique(); + $table->string('name', 128); + $table->string('scope_type', 32)->comment('all_sites|site_only|site_all_data|site_single_player|self_only'); + $table->string('module_code', 64)->nullable(); + $table->text('description')->nullable(); + $table->unsignedTinyInteger('status')->default(1)->comment('1=enabled,0=disabled'); + $table->timestamps(); + }); + + Schema::create('admin_role_menus', function (Blueprint $table): void { + $table->foreignId('role_id')->constrained('admin_roles')->cascadeOnDelete(); + $table->foreignId('menu_id')->constrained('admin_menus')->cascadeOnDelete(); + $table->primary(['role_id', 'menu_id']); + }); + + Schema::create('admin_role_menu_actions', function (Blueprint $table): void { + $table->foreignId('role_id')->constrained('admin_roles')->cascadeOnDelete(); + $table->foreignId('menu_action_id')->constrained('admin_menu_actions')->cascadeOnDelete(); + $table->primary(['role_id', 'menu_action_id']); + }); + + Schema::create('admin_role_api_resources', function (Blueprint $table): void { + $table->foreignId('role_id')->constrained('admin_roles')->cascadeOnDelete(); + $table->foreignId('api_resource_id')->constrained('admin_api_resources')->cascadeOnDelete(); + $table->primary(['role_id', 'api_resource_id']); + }); + + Schema::create('admin_role_data_scopes', function (Blueprint $table): void { + $table->id(); + $table->foreignId('role_id')->constrained('admin_roles')->cascadeOnDelete(); + $table->foreignId('site_id')->nullable()->constrained('admin_sites')->nullOnDelete(); + $table->foreignId('data_scope_id')->constrained('admin_data_scopes')->cascadeOnDelete(); + $table->string('module_code', 64)->nullable(); + $table->json('constraint_json')->nullable(); + $table->timestamps(); + + $table->unique(['role_id', 'site_id', 'data_scope_id', 'module_code'], 'uk_admin_role_data_scopes'); + }); + + Schema::create('admin_user_site_roles', function (Blueprint $table): void { + $table->foreignId('admin_user_id')->constrained('admin_users')->cascadeOnDelete(); + $table->foreignId('site_id')->constrained('admin_sites')->cascadeOnDelete(); + $table->foreignId('role_id')->constrained('admin_roles')->cascadeOnDelete(); + $table->timestamp('granted_at')->nullable(); + $table->primary(['admin_user_id', 'site_id', 'role_id'], 'pk_admin_user_site_roles'); + }); + + Schema::create('admin_user_menu_actions', function (Blueprint $table): void { + $table->foreignId('admin_user_id')->constrained('admin_users')->cascadeOnDelete(); + $table->foreignId('site_id')->nullable()->constrained('admin_sites')->nullOnDelete(); + $table->foreignId('menu_action_id')->constrained('admin_menu_actions')->cascadeOnDelete(); + $table->timestamp('granted_at')->nullable(); + $table->primary(['admin_user_id', 'site_id', 'menu_action_id'], 'pk_admin_user_menu_actions'); + }); + + Schema::create('admin_user_data_scopes', function (Blueprint $table): void { + $table->id(); + $table->foreignId('admin_user_id')->constrained('admin_users')->cascadeOnDelete(); + $table->foreignId('site_id')->nullable()->constrained('admin_sites')->nullOnDelete(); + $table->foreignId('data_scope_id')->constrained('admin_data_scopes')->cascadeOnDelete(); + $table->string('module_code', 64)->nullable(); + $table->json('constraint_json')->nullable(); + $table->timestamps(); + + $table->unique(['admin_user_id', 'site_id', 'data_scope_id', 'module_code'], 'uk_admin_user_data_scopes'); + }); + } + + private function seedInitialData(): void + { + $now = Carbon::now(); + + DB::table('admin_sites')->insert([ + 'code' => 'default_site', + 'name' => '默认站点', + 'currency_code' => 'NPR', + 'status' => 1, + 'is_default' => true, + 'extra_json' => json_encode(['source' => 'migration'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'created_at' => $now, + 'updated_at' => $now, + ]); + + DB::table('admin_action_catalog')->insert([ + ['code' => 'view', 'name' => '查看', 'sort_order' => 10, 'status' => 1, 'created_at' => $now, 'updated_at' => $now], + ['code' => 'create', 'name' => '新增', 'sort_order' => 20, 'status' => 1, 'created_at' => $now, 'updated_at' => $now], + ['code' => 'update', 'name' => '编辑', 'sort_order' => 30, 'status' => 1, 'created_at' => $now, 'updated_at' => $now], + ['code' => 'delete', 'name' => '删除', 'sort_order' => 40, 'status' => 1, 'created_at' => $now, 'updated_at' => $now], + ['code' => 'review', 'name' => '审核', 'sort_order' => 50, 'status' => 1, 'created_at' => $now, 'updated_at' => $now], + ['code' => 'publish', 'name' => '发布', 'sort_order' => 60, 'status' => 1, 'created_at' => $now, 'updated_at' => $now], + ['code' => 'export', 'name' => '导出', 'sort_order' => 70, 'status' => 1, 'created_at' => $now, 'updated_at' => $now], + ['code' => 'manage', 'name' => '管理', 'sort_order' => 80, 'status' => 1, 'created_at' => $now, 'updated_at' => $now], + ]); + + DB::table('admin_data_scopes')->insert([ + ['code' => 'all_sites', 'name' => '全站点', 'scope_type' => 'all_sites', 'module_code' => null, 'description' => '可访问所有站点数据', 'status' => 1, 'created_at' => $now, 'updated_at' => $now], + ['code' => 'site_only', 'name' => '指定站点', 'scope_type' => 'site_only', 'module_code' => null, 'description' => '仅限授权站点登录和访问', 'status' => 1, 'created_at' => $now, 'updated_at' => $now], + ['code' => 'site_all_data', 'name' => '站点内全部数据', 'scope_type' => 'site_all_data', 'module_code' => null, 'description' => '可访问站点内全部业务数据', 'status' => 1, 'created_at' => $now, 'updated_at' => $now], + ['code' => 'site_single_player', 'name' => '站点内单玩家', 'scope_type' => 'site_single_player', 'module_code' => 'player_service', 'description' => '仅限按指定玩家处理客诉与查单', 'status' => 1, 'created_at' => $now, 'updated_at' => $now], + ['code' => 'self_only', 'name' => '仅本人相关', 'scope_type' => 'self_only', 'module_code' => 'audit', 'description' => '仅可查看与自身相关的数据', 'status' => 1, 'created_at' => $now, 'updated_at' => $now], + ]); + + $this->seedMenuTree($now); + $this->seedApiResources($now); + } + + private function seedMenuTree(Carbon $now): void + { + $menus = [ + ['parent_code' => null, 'menu_type' => 'menu', 'code' => 'dashboard', 'name' => '仪表盘', 'path' => '/admin', 'route_name' => 'admin.dashboard', 'component' => 'dashboard/index', 'icon' => 'layout-dashboard', 'sort_order' => 10], + ['parent_code' => null, 'menu_type' => 'directory', 'code' => 'draw', 'name' => '开奖管理', 'path' => '/admin/draws', 'route_name' => null, 'component' => null, 'icon' => 'dice-5', 'sort_order' => 20], + ['parent_code' => 'draw', 'menu_type' => 'page', 'code' => 'draw.results', 'name' => '开奖结果', 'path' => '/admin/draws', 'route_name' => 'admin.draws.index', 'component' => 'draw/results', 'icon' => null, 'sort_order' => 10], + ['parent_code' => 'draw', 'menu_type' => 'page', 'code' => 'draw.review', 'name' => '开奖审核', 'path' => '/admin/draws/review', 'route_name' => 'admin.draws.review', 'component' => 'draw/review', 'icon' => null, 'sort_order' => 20], + ['parent_code' => null, 'menu_type' => 'directory', 'code' => 'config', 'name' => '运营配置', 'path' => '/admin/config', 'route_name' => null, 'component' => null, 'icon' => 'sliders-horizontal', 'sort_order' => 30], + ['parent_code' => 'config', 'menu_type' => 'page', 'code' => 'config.play', 'name' => '玩法开关', 'path' => '/admin/config/play-switches', 'route_name' => 'admin.config.play', 'component' => 'config/play', 'icon' => null, 'sort_order' => 10], + ['parent_code' => 'config', 'menu_type' => 'page', 'code' => 'config.odds', 'name' => '赔率配置', 'path' => '/admin/config/odds', 'route_name' => 'admin.config.odds', 'component' => 'config/odds', 'icon' => null, 'sort_order' => 20], + ['parent_code' => 'config', 'menu_type' => 'page', 'code' => 'config.risk_cap', 'name' => '封顶配置', 'path' => '/admin/config/play-limits', 'route_name' => 'admin.config.risk_cap', 'component' => 'config/risk-cap', 'icon' => null, 'sort_order' => 30], + ['parent_code' => 'config', 'menu_type' => 'page', 'code' => 'config.jackpot', 'name' => 'Jackpot 配置', 'path' => '/admin/jackpot/pools', 'route_name' => 'admin.jackpot.pools', 'component' => 'config/jackpot', 'icon' => null, 'sort_order' => 40], + ['parent_code' => null, 'menu_type' => 'page', 'code' => 'risk.monitor', 'name' => '风控监控', 'path' => '/admin/risk', 'route_name' => 'admin.risk.monitor', 'component' => 'risk/monitor', 'icon' => 'shield-alert', 'sort_order' => 40], + ['parent_code' => null, 'menu_type' => 'page', 'code' => 'settlement.batch', 'name' => '结算批次', 'path' => '/admin/settlement-batches', 'route_name' => 'admin.settlement.batches', 'component' => 'settlement/batches', 'icon' => 'receipt-text', 'sort_order' => 50], + ['parent_code' => null, 'menu_type' => 'directory', 'code' => 'service', 'name' => '客服财务', 'path' => '/admin/service-desk', 'route_name' => null, 'component' => null, 'icon' => 'hand-helping', 'sort_order' => 60], + ['parent_code' => 'service', 'menu_type' => 'page', 'code' => 'service.players', 'name' => '玩家查询', 'path' => '/admin/players', 'route_name' => 'admin.players.index', 'component' => 'service/players', 'icon' => null, 'sort_order' => 10], + ['parent_code' => 'service', 'menu_type' => 'page', 'code' => 'service.tickets', 'name' => '玩家注单', 'path' => '/admin/tickets', 'route_name' => 'admin.tickets.index', 'component' => 'service/tickets', 'icon' => null, 'sort_order' => 20], + ['parent_code' => 'service', 'menu_type' => 'page', 'code' => 'service.wallet', 'name' => '钱包流水', 'path' => '/admin/wallet/transactions', 'route_name' => 'admin.wallet.transactions', 'component' => 'service/wallet', 'icon' => null, 'sort_order' => 30], + ['parent_code' => 'service', 'menu_type' => 'page', 'code' => 'service.reconcile', 'name' => '对账管理', 'path' => '/admin/reconcile', 'route_name' => 'admin.reconcile.index', 'component' => 'service/reconcile', 'icon' => null, 'sort_order' => 40], + ['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], + ]; + + $menuIds = []; + foreach ($menus as $menu) { + $menuIds[$menu['code']] = DB::table('admin_menus')->insertGetId([ + 'parent_id' => $menu['parent_code'] === null ? null : $menuIds[$menu['parent_code']], + 'menu_type' => $menu['menu_type'], + 'code' => $menu['code'], + 'name' => $menu['name'], + 'path' => $menu['path'], + 'route_name' => $menu['route_name'], + 'component' => $menu['component'], + 'icon' => $menu['icon'], + 'active_menu_code' => null, + 'sort_order' => $menu['sort_order'], + 'is_visible' => true, + 'is_cache' => false, + 'is_external' => false, + 'status' => 1, + 'meta_json' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + $actionIds = DB::table('admin_action_catalog')->pluck('id', 'code'); + $menuActions = [ + ['menu_code' => 'dashboard', 'action_code' => 'view', 'permission_code' => 'dashboard.view', 'name' => '仪表盘查看'], + ['menu_code' => 'draw.results', 'action_code' => 'view', 'permission_code' => 'draw.results.view', 'name' => '开奖结果查看'], + ['menu_code' => 'draw.review', 'action_code' => 'review', 'permission_code' => 'draw.review.review', 'name' => '开奖审核'], + ['menu_code' => 'draw.review', 'action_code' => 'publish', 'permission_code' => 'draw.review.publish', 'name' => '开奖发布'], + ['menu_code' => 'config.play', 'action_code' => 'manage', 'permission_code' => 'config.play.manage', 'name' => '玩法开关管理'], + ['menu_code' => 'config.odds', 'action_code' => 'manage', 'permission_code' => 'config.odds.manage', 'name' => '赔率配置管理'], + ['menu_code' => 'config.risk_cap', 'action_code' => 'view', 'permission_code' => 'config.risk_cap.view', 'name' => '封顶配置查看'], + ['menu_code' => 'config.risk_cap', 'action_code' => 'manage', 'permission_code' => 'config.risk_cap.manage', 'name' => '封顶配置管理'], + ['menu_code' => 'config.jackpot', 'action_code' => 'view', 'permission_code' => 'config.jackpot.view', 'name' => 'Jackpot 查看'], + ['menu_code' => 'config.jackpot', 'action_code' => 'manage', 'permission_code' => 'config.jackpot.manage', 'name' => 'Jackpot 管理'], + ['menu_code' => 'risk.monitor', 'action_code' => 'view', 'permission_code' => 'risk.monitor.view', 'name' => '风控监控查看'], + ['menu_code' => 'settlement.batch', 'action_code' => 'view', 'permission_code' => 'settlement.batch.view', 'name' => '结算查看'], + ['menu_code' => 'settlement.batch', 'action_code' => 'review', 'permission_code' => 'settlement.batch.review', 'name' => '结算审核'], + ['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.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' => '钱包流水管理'], + ['menu_code' => 'service.reconcile', 'action_code' => 'view', 'permission_code' => 'service.reconcile.view', 'name' => '对账查看'], + ['menu_code' => 'service.reconcile', 'action_code' => 'manage', 'permission_code' => 'service.reconcile.manage', 'name' => '对账管理'], + ['menu_code' => 'service.reports', 'action_code' => 'view', 'permission_code' => 'service.reports.view', 'name' => '报表查看'], + ['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' => '管理员权限管理'], + ]; + + foreach ($menuActions as $row) { + DB::table('admin_menu_actions')->insert([ + 'menu_id' => $menuIds[$row['menu_code']], + 'action_id' => $actionIds[$row['action_code']], + 'permission_code' => $row['permission_code'], + 'name' => $row['name'], + 'status' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + private function seedApiResources(Carbon $now): void + { + $resources = [ + ['code' => 'admin.dashboard', 'module_code' => 'dashboard', 'name' => '后台仪表盘', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/dashboard', 'route_name' => 'api.v1.admin.dashboard', 'auth_mode' => 'login_only', 'is_audit_required' => false, 'permission_codes' => ['dashboard.view']], + ['code' => 'admin.draws.index', 'module_code' => 'draw', 'name' => '期开奖列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws', 'route_name' => 'api.v1.admin.draws.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['draw.results.view']], + ['code' => 'admin.draws.show', 'module_code' => 'draw', 'name' => '期开奖详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}', 'route_name' => 'api.v1.admin.draws.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['draw.results.view']], + ['code' => 'admin.draws.publish', 'module_code' => 'draw', 'name' => '开奖发布', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/result-batches/{batch}/publish', 'route_name' => 'api.v1.admin.draws.result-batches.publish', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['draw.review.publish']], + ['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.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']], + ['code' => 'admin.reconcile.store', 'module_code' => 'reconcile', 'name' => '创建对账任务', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/reconcile-jobs', 'route_name' => 'api.v1.admin.reconcile-jobs.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.reconcile.manage']], + ['code' => 'admin.audit.index', 'module_code' => 'audit', 'name' => '审计日志查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/audit-logs', 'route_name' => 'api.v1.admin.audit-logs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.audit.view']], + ['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, 'permission_codes' => ['system.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, 'permission_codes' => ['system.admin_user.manage']], + ['code' => 'admin.admin-users.permissions.sync', 'module_code' => 'system', 'name' => '管理员权限同步', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/admin-users/{admin_user}/permissions', 'route_name' => 'api.v1.admin.admin-users.permissions.sync', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['system.admin_user.manage']], + ]; + + $menuActionIds = DB::table('admin_menu_actions')->pluck('id', 'permission_code'); + foreach ($resources as $resource) { + $resourceId = DB::table('admin_api_resources')->insertGetId([ + 'code' => $resource['code'], + 'module_code' => $resource['module_code'], + 'name' => $resource['name'], + 'http_method' => $resource['http_method'], + 'uri_pattern' => $resource['uri_pattern'], + 'route_name' => $resource['route_name'], + 'auth_mode' => $resource['auth_mode'], + 'is_audit_required' => $resource['is_audit_required'], + 'status' => 1, + 'meta_json' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + foreach ($resource['permission_codes'] as $permissionCode) { + if (! isset($menuActionIds[$permissionCode])) { + continue; + } + DB::table('admin_api_resource_bindings')->insert([ + 'api_resource_id' => $resourceId, + 'menu_action_id' => $menuActionIds[$permissionCode], + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + } + + private function migrateLegacyAssignments(): void + { + $now = Carbon::now(); + $defaultSiteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + + $legacyRoles = DB::table('admin_roles') + ->select('id', 'code', 'slug', 'name') + ->get(); + + $legacyRoleAssignments = DB::table('admin_user_roles')->get(); + foreach ($legacyRoleAssignments as $row) { + $legacyRoleId = (int) $row->role_id; + DB::table('admin_user_site_roles')->insert([ + 'admin_user_id' => (int) $row->admin_user_id, + 'site_id' => $defaultSiteId, + 'role_id' => $legacyRoleId, + 'granted_at' => $now, + ]); + } + + $legacyPermissionById = DB::table('admin_permissions')->pluck('slug', 'id'); + $legacyRolePermissions = DB::table('admin_role_permissions')->get()->groupBy('role_id'); + $legacyUserPermissions = DB::table('admin_user_permissions')->get()->groupBy('admin_user_id'); + $menuActionIds = DB::table('admin_menu_actions')->pluck('id', 'permission_code'); + $apiResourceIdsByPermission = DB::table('admin_api_resource_bindings') + ->join('admin_menu_actions', 'admin_menu_actions.id', '=', 'admin_api_resource_bindings.menu_action_id') + ->select('admin_menu_actions.permission_code', 'admin_api_resource_bindings.api_resource_id') + ->get() + ->groupBy('permission_code') + ->map(static fn ($rows) => $rows->pluck('api_resource_id')->all()); + $menuIdsByPermission = DB::table('admin_menu_actions') + ->join('admin_menus', 'admin_menus.id', '=', 'admin_menu_actions.menu_id') + ->pluck('admin_menus.id', 'admin_menu_actions.permission_code'); + + $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.play_switch.manage' => ['config.play.manage'], + 'prd.odds.manage' => ['config.odds.manage'], + 'prd.risk_cap.manage' => ['config.risk_cap.manage'], + 'prd.risk_cap.view' => ['config.risk_cap.view'], + 'prd.rebate.manage' => ['config.odds.manage'], + 'prd.rebate.view' => ['config.odds.manage'], + 'prd.jackpot.manage' => ['config.jackpot.manage'], + 'prd.jackpot.view' => ['config.jackpot.view'], + 'prd.draw_result.manage' => ['draw.results.view', 'draw.review.review', 'draw.review.publish', 'risk.monitor.view'], + 'prd.draw_result.view' => ['draw.results.view', 'risk.monitor.view'], + 'prd.payout.manage' => ['settlement.batch.manage', 'settlement.batch.view'], + 'prd.payout.review' => ['settlement.batch.review', 'settlement.batch.view'], + 'prd.payout.view' => ['settlement.batch.view'], + 'prd.wallet_reconcile.manage' => ['service.wallet.manage', 'service.reconcile.manage'], + 'prd.wallet_reconcile.view' => ['service.wallet.view', 'service.reconcile.view'], + 'prd.wallet_reconcile.view_cs' => ['service.wallet.view', 'service.reconcile.view'], + 'prd.report.all' => ['service.reports.view', 'service.reports.export'], + 'prd.report.risk' => ['service.reports.view'], + 'prd.report.finance' => ['service.reports.view', 'service.reports.export'], + 'prd.report.player' => ['service.reports.view'], + 'prd.audit.all' => ['service.audit.view'], + '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.wallet_adjust.manage' => ['service.wallet.manage'], + 'prd.draw_reopen.manage' => ['draw.review.publish'], + ]; + + foreach ($legacyRoles as $role) { + $roleId = (int) $role->id; + + $grantedPermissions = ['dashboard.view' => true]; + foreach ($legacyRolePermissions->get((int) $role->id, collect()) as $pivot) { + $permissionId = (int) $pivot->permission_id; + $legacySlug = $legacyPermissionById[$permissionId] ?? null; + if (! is_string($legacySlug)) { + continue; + } + foreach ($legacyToNewPermissionMap[$legacySlug] ?? [] as $permissionCode) { + $grantedPermissions[$permissionCode] = true; + } + } + + foreach (array_keys($grantedPermissions) as $permissionCode) { + if (isset($menuIdsByPermission[$permissionCode])) { + $this->grantMenuWithAncestors($roleId, (int) $menuIdsByPermission[$permissionCode]); + } + if (isset($menuActionIds[$permissionCode])) { + DB::table('admin_role_menu_actions')->updateOrInsert([ + 'role_id' => $roleId, + 'menu_action_id' => (int) $menuActionIds[$permissionCode], + ]); + } + foreach ($apiResourceIdsByPermission[$permissionCode] ?? [] as $apiResourceId) { + DB::table('admin_role_api_resources')->updateOrInsert([ + 'role_id' => $roleId, + 'api_resource_id' => (int) $apiResourceId, + ]); + } + } + + $roleCode = (string) ($role->code ?: $role->slug); + $this->assignRoleDataScopes($roleId, $roleCode, $defaultSiteId, $now); + } + + foreach ($legacyUserPermissions as $adminUserId => $pivots) { + $grantedPermissions = []; + foreach ($pivots as $pivot) { + $permissionId = (int) $pivot->permission_id; + $legacySlug = $legacyPermissionById[$permissionId] ?? null; + if (! is_string($legacySlug)) { + continue; + } + foreach ($legacyToNewPermissionMap[$legacySlug] ?? [] as $permissionCode) { + $grantedPermissions[$permissionCode] = true; + } + } + + foreach (array_keys($grantedPermissions) as $permissionCode) { + if (! isset($menuActionIds[$permissionCode])) { + continue; + } + DB::table('admin_user_menu_actions')->updateOrInsert([ + 'admin_user_id' => (int) $adminUserId, + 'site_id' => $defaultSiteId, + 'menu_action_id' => (int) $menuActionIds[$permissionCode], + ], [ + 'granted_at' => $now, + ]); + } + } + } + + private function assignRoleDataScopes(int $roleId, string $roleCode, int $siteId, Carbon $now): void + { + $dataScopeIds = DB::table('admin_data_scopes')->pluck('id', 'code'); + + $rows = match ($roleCode) { + 'super_admin' => [ + ['scope_code' => 'all_sites', 'module_code' => null], + ['scope_code' => 'site_all_data', 'module_code' => null], + ], + 'risk_operator' => [ + ['scope_code' => 'site_only', 'module_code' => null], + ['scope_code' => 'site_all_data', 'module_code' => 'risk'], + ['scope_code' => 'self_only', 'module_code' => 'audit'], + ], + 'finance' => [ + ['scope_code' => 'site_only', 'module_code' => null], + ['scope_code' => 'site_all_data', 'module_code' => 'wallet'], + ['scope_code' => 'site_all_data', 'module_code' => 'settlement'], + ['scope_code' => 'site_all_data', 'module_code' => 'report'], + ['scope_code' => 'site_all_data', 'module_code' => 'reconcile'], + ], + 'customer_service' => [ + ['scope_code' => 'site_only', 'module_code' => null], + ['scope_code' => 'site_single_player', 'module_code' => 'player_service'], + ], + default => [ + ['scope_code' => 'site_only', 'module_code' => null], + ], + }; + + foreach ($rows as $row) { + $scopeId = $dataScopeIds[$row['scope_code']] ?? null; + if ($scopeId === null) { + continue; + } + DB::table('admin_role_data_scopes')->insert([ + 'role_id' => $roleId, + 'site_id' => $row['scope_code'] === 'all_sites' ? null : $siteId, + 'data_scope_id' => (int) $scopeId, + 'module_code' => $row['module_code'], + 'constraint_json' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + private function grantMenuWithAncestors(int $roleId, int $menuId): void + { + $currentMenuId = $menuId; + + while ($currentMenuId > 0) { + DB::table('admin_role_menus')->updateOrInsert([ + 'role_id' => $roleId, + 'menu_id' => $currentMenuId, + ]); + + $parentId = DB::table('admin_menus')->where('id', $currentMenuId)->value('parent_id'); + $currentMenuId = $parentId === null ? 0 : (int) $parentId; + } + } + + private function dropLegacyTables(): void + { + Schema::dropIfExists('admin_user_permissions'); + Schema::dropIfExists('admin_user_roles'); + Schema::dropIfExists('admin_role_permissions'); + Schema::dropIfExists('admin_permissions'); + } + + private function recreateLegacyTables(): void + { + Schema::table('admin_roles', function (Blueprint $table): void { + $table->dropUnique(['code']); + $table->dropColumn(['code', 'description', 'status', 'is_system', 'sort_order']); + }); + + Schema::create('admin_permissions', function (Blueprint $table): void { + $table->id(); + $table->string('slug', 128)->unique(); + $table->string('name', 128); + $table->timestamps(); + }); + + Schema::create('admin_role_permissions', function (Blueprint $table): void { + $table->foreignId('role_id')->constrained('admin_roles')->cascadeOnDelete(); + $table->foreignId('permission_id')->constrained('admin_permissions')->cascadeOnDelete(); + $table->primary(['role_id', 'permission_id']); + }); + + Schema::create('admin_user_roles', function (Blueprint $table): void { + $table->foreignId('admin_user_id')->constrained('admin_users')->cascadeOnDelete(); + $table->foreignId('role_id')->constrained('admin_roles')->cascadeOnDelete(); + $table->primary(['admin_user_id', 'role_id']); + }); + + Schema::create('admin_user_permissions', function (Blueprint $table): void { + $table->foreignId('admin_user_id')->constrained('admin_users')->cascadeOnDelete(); + $table->foreignId('permission_id')->constrained('admin_permissions')->cascadeOnDelete(); + $table->primary(['admin_user_id', 'permission_id']); + }); + } + + private function migrateBackToLegacyTables(): void + { + $now = Carbon::now(); + + $legacyPermissions = [ + ['slug' => 'prd.users.manage', 'name' => '用户管理·可管理'], + ['slug' => 'prd.users.view_finance', 'name' => '用户管理·财务查看'], + ['slug' => 'prd.users.view_cs', 'name' => '用户管理·客服单用户'], + ['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' => '开奖结果录入·可管理'], + ['slug' => 'prd.draw_result.view', 'name' => '开奖结果·查看'], + ['slug' => 'prd.draw_reopen.manage', 'name' => '开奖结果重开·可管理'], + ['slug' => 'prd.payout.manage', 'name' => '派彩确认·可管理'], + ['slug' => 'prd.payout.review', 'name' => '派彩确认·可审核'], + ['slug' => 'prd.payout.view', 'name' => '派彩确认·查看'], + ['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' => '补单/冲正·可管理'], + ['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' => '审计日志·全部'], + ['slug' => 'prd.audit.self', 'name' => '审计日志·自身相关'], + ['slug' => 'prd.audit.finance', 'name' => '审计日志·资金相关'], + ['slug' => 'prd.player_freeze.manage', 'name' => '冻结/解冻玩家·可管理'], + ['slug' => 'prd.admin_user.manage', 'name' => '后台用户权限管理·可管理'], + ]; + + foreach ($legacyPermissions as $permission) { + DB::table('admin_permissions')->insert([ + 'slug' => $permission['slug'], + 'name' => $permission['name'], + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + $permissionIds = DB::table('admin_permissions')->pluck('id', 'slug'); + $roleCodeMap = DB::table('admin_roles')->pluck('id', 'slug'); + + $rolePermissionMap = [ + 'super_admin' => array_keys($permissionIds->all()), + 'risk_operator' => [ + 'prd.play_switch.manage', + 'prd.odds.manage', + 'prd.risk_cap.manage', + 'prd.rebate.manage', + 'prd.jackpot.manage', + 'prd.draw_result.manage', + 'prd.payout.review', + 'prd.wallet_reconcile.view', + 'prd.report.risk', + 'prd.audit.self', + 'prd.player_freeze.manage', + ], + 'finance' => [ + 'prd.users.view_finance', + 'prd.risk_cap.view', + 'prd.rebate.view', + 'prd.jackpot.view', + 'prd.draw_result.view', + 'prd.payout.view', + 'prd.wallet_reconcile.manage', + 'prd.wallet_adjust.manage', + 'prd.report.finance', + 'prd.audit.finance', + ], + 'customer_service' => [ + 'prd.users.view_cs', + 'prd.draw_result.view', + 'prd.wallet_reconcile.view_cs', + 'prd.report.player', + ], + ]; + + foreach ($rolePermissionMap as $roleCode => $permissionSlugs) { + $roleId = $roleCodeMap[$roleCode] ?? null; + if ($roleId === null) { + continue; + } + + foreach ($permissionSlugs as $slug) { + $permissionId = $permissionIds[$slug] ?? null; + if ($permissionId === null) { + continue; + } + DB::table('admin_role_permissions')->insert([ + 'role_id' => (int) $roleId, + 'permission_id' => (int) $permissionId, + ]); + } + } + + $userRoles = DB::table('admin_user_site_roles') + ->select('admin_user_id', 'role_id') + ->distinct() + ->get(); + + foreach ($userRoles as $row) { + DB::table('admin_user_roles')->insert([ + 'admin_user_id' => (int) $row->admin_user_id, + 'role_id' => (int) $row->role_id, + ]); + } + } +}; diff --git a/database/seeders/AdminRbacAndUserSeeder.php b/database/seeders/AdminRbacAndUserSeeder.php index f9fd7ea..ff4d224 100644 --- a/database/seeders/AdminRbacAndUserSeeder.php +++ b/database/seeders/AdminRbacAndUserSeeder.php @@ -2,102 +2,62 @@ namespace Database\Seeders; -use App\Models\AdminPermission; use App\Models\AdminRole; use App\Models\AdminUser; +use App\Support\AdminPermissionBridge; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\DB; /** - * 后台 RBAC:与 {@see AdminUser::ROLE_SUPER_ADMIN} 及 PRD 对齐。 - * - * - 角色 slug:`01-产品文档.md` §3 + `04-领域字典与编码规范.md` §11 - * - 权限点 slug:`01-产品文档.md` §8「功能」行 → `prd.{功能键}.{动作}`,路由中间件引用同表 + * 后台 RBAC:与 {@see AdminUser::ROLE_SUPER_ADMIN} 及 `config/admin_permissions.php` 对齐。 * * 演示账号 **admin** / **123456**(仅限非 production)。 */ class AdminRbacAndUserSeeder extends Seeder { - /** @return list */ - private function permissionDefinitions(): array + /** @param list $legacySlugs */ + private function syncRoleMenuActions(AdminRole $role, array $legacySlugs): void { - return [ - ['slug' => 'prd.users.manage', 'name' => '用户管理·可管理'], - ['slug' => 'prd.users.view_finance', 'name' => '用户管理·财务查看'], - ['slug' => 'prd.users.view_cs', 'name' => '用户管理·客服单用户'], + $codes = []; + foreach ($legacySlugs as $slug) { + $codes = array_merge($codes, AdminPermissionBridge::menuActionCodesForLegacy($slug)); + } + $codes = array_values(array_unique($codes)); - ['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 配置·查看'], + $ids = DB::table('admin_menu_actions') + ->whereIn('permission_code', $codes) + ->where('status', 1) + ->pluck('id') + ->all(); - ['slug' => 'prd.draw_result.manage', 'name' => '开奖结果录入·可管理'], - ['slug' => 'prd.draw_result.view', 'name' => '开奖结果·查看'], - ['slug' => 'prd.draw_reopen.manage', 'name' => '开奖结果重开·可管理'], - - ['slug' => 'prd.payout.manage', 'name' => '派彩确认·可管理'], - ['slug' => 'prd.payout.review', 'name' => '派彩确认·可审核'], - ['slug' => 'prd.payout.view', 'name' => '派彩确认·查看'], - - ['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' => '补单/冲正·可管理'], - - ['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' => '审计日志·全部'], - ['slug' => 'prd.audit.self', 'name' => '审计日志·自身相关'], - ['slug' => 'prd.audit.finance', 'name' => '审计日志·资金相关'], - - ['slug' => 'prd.player_freeze.manage', 'name' => '冻结/解冻玩家·可管理'], - ['slug' => 'prd.admin_user.manage', 'name' => '后台用户权限管理·可管理'], - ]; + DB::table('admin_role_menu_actions')->where('role_id', $role->id)->delete(); + foreach ($ids as $mid) { + DB::table('admin_role_menu_actions')->insert([ + 'role_id' => $role->id, + 'menu_action_id' => (int) $mid, + ]); + } } - /** @param list $slugs */ - private function syncRolePermissions(AdminRole $role, array $slugs): void + /** @return list */ + private function allCatalogSlugs(): array { - $ids = AdminPermission::query()->whereIn('slug', $slugs)->pluck('id')->all(); - $role->permissions()->sync($ids); + return AdminPermissionBridge::allLegacySlugs(); } public function run(): void { - foreach ($this->permissionDefinitions() as $row) { - AdminPermission::query()->updateOrCreate( - ['slug' => $row['slug']], - ['name' => $row['name']], - ); - } - - $legacySlugs = [ - 'admin.dashboard', 'admin.players.read', 'admin.wallet.read', 'admin.draws.read', - 'admin.draws.publish', 'admin.settlement.run', 'admin.settlement.read', 'admin.jackpot.read', - 'admin.jackpot.write', 'admin.config.read', 'admin.config.write', 'admin.audit.read', - 'admin.reports.manage', 'admin.reconcile.manage', - ]; - AdminPermission::query()->whereIn('slug', $legacySlugs)->delete(); - $super = AdminRole::query()->updateOrCreate( ['slug' => AdminUser::ROLE_SUPER_ADMIN], ['name' => '超级管理员'], ); - $this->syncRolePermissions($super, array_column($this->permissionDefinitions(), 'slug')); + $this->syncRoleMenuActions($super, $this->allCatalogSlugs()); $risk = AdminRole::query()->updateOrCreate( ['slug' => 'risk_operator'], ['name' => '风控运营员'], ); - $this->syncRolePermissions($risk, [ + $this->syncRoleMenuActions($risk, [ 'prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', @@ -115,7 +75,7 @@ class AdminRbacAndUserSeeder extends Seeder ['slug' => 'finance'], ['name' => '财务/对账员'], ); - $this->syncRolePermissions($finance, [ + $this->syncRoleMenuActions($finance, [ 'prd.users.view_finance', 'prd.risk_cap.view', 'prd.rebate.view', @@ -132,7 +92,7 @@ class AdminRbacAndUserSeeder extends Seeder ['slug' => 'customer_service'], ['name' => '客服人员'], ); - $this->syncRolePermissions($cs, [ + $this->syncRoleMenuActions($cs, [ 'prd.users.view_cs', 'prd.draw_result.view', 'prd.wallet_reconcile.view_cs', @@ -152,10 +112,13 @@ class AdminRbacAndUserSeeder extends Seeder /** @var AdminUser $admin */ $admin = AdminUser::query()->where('username', $username)->firstOrFail(); - $admin->roles()->sync([(int) $super->getKey()]); - - DB::table('admin_user_roles')->where('admin_user_id', $admin->id) - ->whereNotIn('role_id', [(int) $super->getKey()]) - ->delete(); + $siteId = AdminUser::defaultAdminSiteId(); + $superId = (int) $super->getKey(); + $admin->roles()->sync([ + $superId => [ + 'site_id' => $siteId, + 'granted_at' => now(), + ], + ]); } } diff --git a/routes/api.php b/routes/api.php index 0de4ab6..380c6c6 100644 --- a/routes/api.php +++ b/routes/api.php @@ -50,6 +50,7 @@ use App\Http\Controllers\Api\V1\Admin\Settlement\AdminSettlementBatchShowControl 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\User\AdminUserRoleSyncController; 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; @@ -318,6 +319,8 @@ Route::prefix('v1')->group(function (): void { ->name('admin-users.permission-catalog'); Route::put('admin-users/{admin_user}/permissions', AdminUserPermissionSyncController::class) ->name('admin-users.permissions.sync'); + Route::put('admin-users/{admin_user}/roles', AdminUserRoleSyncController::class) + ->name('admin-users.roles.sync'); }); }); }); diff --git a/tests/Feature/AdminPhase15OperationsTest.php b/tests/Feature/AdminPhase15OperationsTest.php index d2c0e36..7185351 100644 --- a/tests/Feature/AdminPhase15OperationsTest.php +++ b/tests/Feature/AdminPhase15OperationsTest.php @@ -1,14 +1,15 @@ create(['slug' => 'auditor_test', 'name' => 'Auditor Test']); - $perm = AdminPermission::query()->create(['slug' => 'prd.audit.finance', 'name' => '§8 审计日志·资金相关']); - $role->permissions()->sync([(int) $perm->getKey()]); + $ids = DB::table('admin_menu_actions') + ->whereIn('permission_code', AdminPermissionBridge::menuActionCodesForLegacy('prd.audit.finance')) + ->where('status', 1) + ->pluck('id'); + foreach ($ids as $mid) { + DB::table('admin_role_menu_actions')->insert([ + 'role_id' => $role->id, + 'menu_action_id' => (int) $mid, + ]); + } $user = AdminUser::query()->create([ 'username' => 'auditor_only', @@ -100,7 +109,13 @@ test('admin without report permission receives 403 on report-jobs', function (): 'password' => Hash::make('pw-audit'), 'status' => 0, ]); - $user->roles()->sync([(int) $role->getKey()]); + $siteId = AdminUser::defaultAdminSiteId(); + $user->roles()->sync([ + (int) $role->id => [ + 'site_id' => $siteId, + 'granted_at' => now(), + ], + ]); $token = $user->createToken('test', ['*'], now()->addDay())->plainTextToken; diff --git a/tests/Feature/AdminUserPermissionApiTest.php b/tests/Feature/AdminUserPermissionApiTest.php index b7ceafd..37fe206 100644 --- a/tests/Feature/AdminUserPermissionApiTest.php +++ b/tests/Feature/AdminUserPermissionApiTest.php @@ -1,10 +1,11 @@ 'Role '.$username, ]); + $codes = []; foreach ($permissionSlugs as $slug) { - $permission = AdminPermission::query()->firstOrCreate( - ['slug' => $slug], - ['name' => $slug], - ); - $role->permissions()->syncWithoutDetaching([(int) $permission->id]); + $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, + ]); } - $admin->roles()->syncWithoutDetaching([(int) $role->id]); + $siteId = AdminUser::defaultAdminSiteId(); + $admin->roles()->sync([ + (int) $role->id => [ + 'site_id' => $siteId, + 'granted_at' => now(), + ], + ]); 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) @@ -49,10 +64,6 @@ test('admin user permission apis require rbac permission', function (): void { }); 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([ @@ -63,14 +74,33 @@ test('admin can list users and sync direct permissions', function (): void { 'status' => 0, ]); $targetRole = AdminRole::query()->create(['slug' => 'target_role', 'name' => 'Target Role']); - $targetRole->permissions()->sync([(int) $draw->id]); - $target->roles()->sync([(int) $targetRole->id]); + + $drawCodes = AdminPermissionBridge::menuActionCodesForLegacy('prd.draw_result.view'); + $drawIds = DB::table('admin_menu_actions') + ->whereIn('permission_code', $drawCodes) + ->where('status', 1) + ->pluck('id') + ->all(); + foreach ($drawIds as $mid) { + DB::table('admin_role_menu_actions')->insert([ + 'role_id' => $targetRole->id, + 'menu_action_id' => (int) $mid, + ]); + } + + $siteId = AdminUser::defaultAdminSiteId(); + $target->roles()->sync([ + (int) $targetRole->id => [ + 'site_id' => $siteId, + 'granted_at' => now(), + ], + ]); $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'); + ->assertJsonFragment(['slug' => 'prd.admin_user.manage']); $this->withHeader('Authorization', 'Bearer '.$token) ->getJson('/api/v1/admin/admin-users?keyword=target') @@ -81,22 +111,45 @@ test('admin can list users and sync direct permissions', function (): void { $this->withHeader('Authorization', 'Bearer '.$token) ->putJson('/api/v1/admin/admin-users/'.$target->id.'/permissions', [ - 'permission_slugs' => [$report->slug], + 'permission_slugs' => ['prd.report.player'], ]) ->assertOk() ->assertJsonPath('code', ErrorCode::Success->value) - ->assertJsonPath('data.direct_permissions.0', 'prd.report.player'); + ->assertJsonFragment(['prd.report.player']); - expect( - $target->fresh()->permissions()->pluck('slug')->sort()->values()->all() - )->toBe([$report->slug]); + expect($target->fresh()->directLegacyPermissionSlugs())->toContain('prd.report.player'); $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'); + expect($list)->toContain('prd.draw_result.view'); + expect($list)->toContain('prd.report.player'); +}); + +test('admin can sync user roles for default site', function (): void { + $token = makeAdminWithPermissions('rbac_role_editor', ['prd.admin_user.manage']); + + $r1 = AdminRole::query()->create(['slug' => 'role_sync_a', 'name' => 'Role A']); + $r2 = AdminRole::query()->create(['slug' => 'role_sync_b', 'name' => 'Role B']); + + $target = AdminUser::query()->create([ + 'username' => 'role_target', + 'name' => 'Role Target', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/admin-users/'.$target->id.'/roles', [ + 'role_slugs' => ['role_sync_b', 'role_sync_a'], + ]) + ->assertOk() + ->assertJsonPath('code', ErrorCode::Success->value); + + $slugs = $target->fresh()->adminRoleSlugs(); + sort($slugs); + expect($slugs)->toBe(['role_sync_a', 'role_sync_b']); }); diff --git a/tests/Pest.php b/tests/Pest.php index 2afd163..68a98f4 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -52,13 +52,22 @@ function grantSuperAdminRole(AdminUser $admin): void $now = now(); DB::table('admin_roles')->updateOrInsert( ['slug' => AdminUser::ROLE_SUPER_ADMIN], - ['name' => 'Super Admin', 'created_at' => $now, 'updated_at' => $now], + [ + 'name' => 'Super Admin', + 'code' => AdminUser::ROLE_SUPER_ADMIN, + 'created_at' => $now, + 'updated_at' => $now, + ], ); $rid = (int) DB::table('admin_roles')->where('slug', AdminUser::ROLE_SUPER_ADMIN)->value('id'); - if (! DB::table('admin_user_roles')->where('admin_user_id', $admin->id)->where('role_id', $rid)->exists()) { - DB::table('admin_user_roles')->insert([ + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + + DB::table('admin_user_site_roles')->updateOrInsert( + [ 'admin_user_id' => $admin->id, + 'site_id' => $siteId, 'role_id' => $rid, - ]); - } + ], + ['granted_at' => $now], + ); }