feat: 重构管理员权限管理,移除 AdminPermission 模型,整合权限与角色管理逻辑,优化 API 接口以支持角色与权限的同步,增强数据库填充器以对齐权限配置

This commit is contained in:
2026-05-13 10:40:07 +08:00
parent 3c92bef774
commit edd863764b
18 changed files with 1486 additions and 224 deletions

View File

@@ -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<array{slug: string, name: string}> */
$catalog = config('admin_permissions.catalog', []);
/** @var list<array{key: string, label: string, slugs: list<string>}> $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(),
]);
}
}

View File

@@ -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(),
];
}

View File

@@ -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<string>} $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(),
]);
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\User;
use App\Http\Controllers\Controller;
use App\Models\AdminUser;
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}/roles */
final class AdminUserRoleSyncController extends Controller
{
public function __invoke(Request $request, AdminUser $admin_user): JsonResponse
{
/** @var array{role_slugs:list<string>} $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(),
]);
}
}

View File

@@ -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

10
app/Models/AdminMenu.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class AdminMenu extends Model
{
protected $table = 'admin_menus';
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AdminMenuAction extends Model
{
protected $table = 'admin_menu_actions';
protected $fillable = [
'menu_id',
'action_id',
'permission_code',
'name',
'status',
];
/** @return BelongsTo<AdminMenu, AdminMenuAction> */
public function menu(): BelongsTo
{
return $this->belongsTo(AdminMenu::class, 'menu_id');
}
}

View File

@@ -1,27 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class AdminPermission extends Model
{
protected $table = 'admin_permissions';
protected $fillable = [
'slug',
'name',
];
/** @return BelongsToMany<AdminRole, AdminPermission> */
public function roles(): BelongsToMany
{
return $this->belongsToMany(
AdminRole::class,
'admin_role_permissions',
'permission_id',
'role_id',
);
}
}

View File

@@ -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<AdminPermission, AdminRole> */
public function permissions(): BelongsToMany
/**
* @return BelongsToMany<AdminMenuAction, AdminRole>
*/
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<string>
*/
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);
}
}

View File

@@ -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<AdminRole, AdminUser> */
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<AdminRole, AdminUser>
*/
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<AdminPermission, AdminUser> */
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<string>
*/
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<string>
*/
public function directLegacyPermissionSlugs(): array
{
return AdminPermissionBridge::legacySlugsGrantedByMenuActionCodes($this->directMenuActionPermissionCodes());
}
/**
* 角色 + 直接授权合并后的 menu_action.permission_code。
*
* @return list<string>
*/
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<string> Next 侧栏、`admin.permission` 中间件一致的 `prd.*` slug 列表
*/
public function adminPermissionSlugs(): array
{
if ($this->isSuperAdmin()) {
return AdminPermissionBridge::allLegacySlugs();
}
return AdminPermissionBridge::legacySlugsGrantedByMenuActionCodes($this->effectiveMenuActionPermissionCodes());
}
/**
* @return list<string>
*/
@@ -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();
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Support;
final class AdminPermissionBridge
{
/** @return array<string, list<string>> */
public static function legacyMap(): array
{
/** @var array<string, list<string>> */
return config('admin_permissions.legacy_map', []);
}
/** @return list<string> */
public static function allLegacySlugs(): array
{
$keys = array_keys(self::legacyMap());
sort($keys);
return $keys;
}
/** @return list<string> */
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<string> $menuActionCodes
* @return list<string>
*/
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;
}
}