feat(admin): 更新后台权限管理与同步逻辑,简化权限检查并优化文档
- 新增后台 RBAC 相关文档,提供权限目录与维护命令说明。 - 移除不必要的角色资源同步检查,简化权限审计命令。 - 更新权限描述与同步逻辑,确保一致性与可维护性。 - 统一权限注册表,替换过时的权限别名,增强代码可读性。
This commit is contained in:
@@ -7,6 +7,10 @@
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
||||
</p>
|
||||
|
||||
## 后台 RBAC
|
||||
|
||||
侧栏与 `prd.*` 权限目录见 [`docs/admin-rbac.md`](docs/admin-rbac.md)。维护命令:`php artisan lottery:admin-auth-sync --audit`。
|
||||
|
||||
## About Laravel
|
||||
|
||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
||||
|
||||
@@ -11,10 +11,9 @@ final class AuditAdminAuthorizationCommand extends Command
|
||||
{
|
||||
protected $signature = 'lottery:admin-auth-audit
|
||||
{--skip-route-coverage : 跳过受保护后台路由是否已注册 API 资源的检查}
|
||||
{--skip-resource-bindings : 跳过 permission_required 资源是否绑定动作权限的检查}
|
||||
{--skip-role-resource-sync : 跳过 role_menu_actions 与 role_api_resources 一致性检查}';
|
||||
{--skip-resource-bindings : 跳过 permission_required 资源是否绑定动作权限的检查}';
|
||||
|
||||
protected $description = '检查后台权限配置是否存在路由覆盖缺失、资源绑定缺失或角色资源漂移';
|
||||
protected $description = '检查后台权限配置是否存在路由覆盖缺失或资源绑定缺失';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
@@ -28,10 +27,6 @@ final class AuditAdminAuthorizationCommand extends Command
|
||||
$issues = array_merge($issues, $this->checkPermissionResourceBindings());
|
||||
}
|
||||
|
||||
if (! (bool) $this->option('skip-role-resource-sync')) {
|
||||
$issues = array_merge($issues, $this->checkRoleApiResourceSync());
|
||||
}
|
||||
|
||||
if ($issues === []) {
|
||||
$this->info('Admin authorization audit passed.');
|
||||
|
||||
@@ -116,70 +111,6 @@ final class AuditAdminAuthorizationCommand extends Command
|
||||
return $issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{type: string, message: string}>
|
||||
*/
|
||||
private function checkRoleApiResourceSync(): array
|
||||
{
|
||||
$expectedRows = DB::table('admin_role_menu_actions as rma')
|
||||
->join('admin_api_resource_bindings as arb', 'arb.menu_action_id', '=', 'rma.menu_action_id')
|
||||
->select('rma.role_id', 'arb.api_resource_id')
|
||||
->distinct()
|
||||
->get();
|
||||
|
||||
$expectedSet = [];
|
||||
foreach ($expectedRows as $row) {
|
||||
$expectedSet[$this->roleApiKey((int) $row->role_id, (int) $row->api_resource_id)] = true;
|
||||
}
|
||||
|
||||
$actualRows = DB::table('admin_role_api_resources')
|
||||
->select('role_id', 'api_resource_id')
|
||||
->get();
|
||||
|
||||
$actualSet = [];
|
||||
foreach ($actualRows as $row) {
|
||||
$actualSet[$this->roleApiKey((int) $row->role_id, (int) $row->api_resource_id)] = true;
|
||||
}
|
||||
|
||||
$roleSlugs = DB::table('admin_roles')->pluck('slug', 'id')->all();
|
||||
$resourceCodes = DB::table('admin_api_resources')->pluck('code', 'id')->all();
|
||||
$issues = [];
|
||||
|
||||
foreach (array_keys($expectedSet) as $key) {
|
||||
if (isset($actualSet[$key])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$roleId, $resourceId] = array_map('intval', explode(':', $key, 2));
|
||||
$issues[] = [
|
||||
'type' => 'role_resource_sync',
|
||||
'message' => sprintf(
|
||||
'Missing role-resource grant: role `%s` should include API resource `%s`.',
|
||||
(string) ($roleSlugs[$roleId] ?? $roleId),
|
||||
(string) ($resourceCodes[$resourceId] ?? $resourceId),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
foreach (array_keys($actualSet) as $key) {
|
||||
if (isset($expectedSet[$key])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$roleId, $resourceId] = array_map('intval', explode(':', $key, 2));
|
||||
$issues[] = [
|
||||
'type' => 'role_resource_sync',
|
||||
'message' => sprintf(
|
||||
'Extra role-resource grant: role `%s` has API resource `%s` without any supporting action binding.',
|
||||
(string) ($roleSlugs[$roleId] ?? $roleId),
|
||||
(string) ($resourceCodes[$resourceId] ?? $resourceId),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return $issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{name: string, method: string, uri: string}>
|
||||
*/
|
||||
@@ -222,9 +153,4 @@ final class AuditAdminAuthorizationCommand extends Command
|
||||
{
|
||||
return preg_replace('/^(api\.v1\.admin\.)+/', 'api.v1.admin.', $routeName) ?? $routeName;
|
||||
}
|
||||
|
||||
private function roleApiKey(int $roleId, int $apiResourceId): string
|
||||
{
|
||||
return $roleId.':'.$apiResourceId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ final class SyncAdminAuthorizationCommand extends Command
|
||||
protected $signature = 'lottery:admin-auth-sync
|
||||
{--audit : 同步完成后立即执行后台权限体检}';
|
||||
|
||||
protected $description = '根据后台统一注册表同步 admin_api_resources / bindings / role_api_resources';
|
||||
protected $description = '根据后台统一注册表同步 admin_api_resources 与 resource_bindings';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
@@ -69,25 +69,9 @@ final class SyncAdminAuthorizationCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
DB::table('admin_role_api_resources')->delete();
|
||||
|
||||
$roleResourceRows = DB::table('admin_role_menu_actions as rma')
|
||||
->join('admin_api_resource_bindings as arb', 'arb.menu_action_id', '=', 'rma.menu_action_id')
|
||||
->select('rma.role_id', 'arb.api_resource_id')
|
||||
->distinct()
|
||||
->get();
|
||||
|
||||
foreach ($roleResourceRows as $row) {
|
||||
DB::table('admin_role_api_resources')->insert([
|
||||
'role_id' => (int) $row->role_id,
|
||||
'api_resource_id' => (int) $row->api_resource_id,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'Admin authorization synced: %d resources, %d role-resource rows.',
|
||||
'Admin authorization synced: %d resources.',
|
||||
count(AdminAuthorizationRegistry::resources()),
|
||||
$roleResourceRows->count(),
|
||||
));
|
||||
|
||||
if ((bool) $this->option('audit')) {
|
||||
|
||||
@@ -37,15 +37,12 @@ final class AdminSettingController extends Controller
|
||||
public function update(AdminSettingUpdateRequest $request, string $key): JsonResponse
|
||||
{
|
||||
$setting = LotterySetting::query()->where('setting_key', $key)->first();
|
||||
if ($setting === null) {
|
||||
return ApiResponse::error('Setting not found', 404);
|
||||
}
|
||||
|
||||
LotterySettings::put(
|
||||
$key,
|
||||
$request->validated('value'),
|
||||
$setting->group_name,
|
||||
$setting->description_zh,
|
||||
$setting ? $setting->group_name : (explode('.', $key)[0] ?? 'general'),
|
||||
$setting ? $setting->description_zh : null,
|
||||
);
|
||||
|
||||
$fresh = LotterySetting::query()->where('setting_key', $key)->first();
|
||||
|
||||
@@ -17,10 +17,10 @@ final class AdminUserPermissionSyncController extends Controller
|
||||
public function __invoke(AdminUserPermissionSyncRequest $request, AdminUser $admin_user): JsonResponse
|
||||
{
|
||||
$input = $request->validated();
|
||||
$slugs = array_values(array_unique(array_values(array_filter(
|
||||
$slugs = AdminPermissionBridge::normalizeCanonicalLegacySlugs(array_values(array_filter(
|
||||
(array) ($input['permissions'] ?? $input['permission_slugs'] ?? []),
|
||||
static fn ($v) => is_string($v) && $v !== '',
|
||||
))));
|
||||
)));
|
||||
$siteId = AdminUser::defaultAdminSiteId();
|
||||
|
||||
$codes = [];
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Setting;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\LotterySetting;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* 玩家端/公开:读取 KV 配置(公开访问)
|
||||
*/
|
||||
final class SettingIndexController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$group = $request->query('group');
|
||||
|
||||
$query = LotterySetting::query()->orderBy('setting_key');
|
||||
|
||||
if (! empty($group)) {
|
||||
$query->where('group_name', $group);
|
||||
}
|
||||
|
||||
$items = $query->get()->map(fn (LotterySetting $s): array => [
|
||||
'key' => $s->setting_key,
|
||||
'value' => $s->value_json,
|
||||
'group' => $s->group_name,
|
||||
'description' => $s->description_zh,
|
||||
]);
|
||||
|
||||
return ApiResponse::success(['items' => $items]);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,12 @@ namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* 权限模块树节点(表 `admin_menus`)。
|
||||
*
|
||||
* 仅用于 {@see AdminMenuAction} 的分组,不驱动后台侧栏。
|
||||
* 侧栏见 {@see \App\Support\AdminAuthorizationRegistry::navigationDefinitions()}。
|
||||
*/
|
||||
final class AdminMenu extends Model
|
||||
{
|
||||
protected $table = 'admin_menus';
|
||||
|
||||
@@ -57,29 +57,12 @@ final class AdminRole extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* 由已授权的 menu_action 反推 `prd.*`(与 Registry 映射一致)。
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function legacyPermissionSlugs(): array
|
||||
{
|
||||
if (DB::getSchemaBuilder()->hasTable('admin_role_legacy_permissions')) {
|
||||
$slugs = DB::table('admin_role_legacy_permissions')
|
||||
->where('role_id', $this->id)
|
||||
->pluck('permission_slug')
|
||||
->all();
|
||||
|
||||
$out = [];
|
||||
foreach ($slugs as $slug) {
|
||||
if (is_string($slug) && $slug !== '') {
|
||||
$out[$slug] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$keys = array_keys($out);
|
||||
sort($keys);
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
$codes = DB::table('admin_role_menu_actions as rma')
|
||||
->join('admin_menu_actions as ma', 'ma.id', '=', 'rma.menu_action_id')
|
||||
->where('rma.role_id', $this->id)
|
||||
@@ -95,10 +78,7 @@ final class AdminRole extends Model
|
||||
*/
|
||||
public function syncLegacyPermissionSlugs(array $slugs): void
|
||||
{
|
||||
$legacySlugs = array_values(array_unique(array_filter(
|
||||
$slugs,
|
||||
static fn ($slug): bool => is_string($slug) && $slug !== '',
|
||||
)));
|
||||
$legacySlugs = AdminPermissionBridge::normalizeCanonicalLegacySlugs($slugs);
|
||||
|
||||
$codes = [];
|
||||
foreach ($legacySlugs as $slug) {
|
||||
@@ -119,19 +99,6 @@ final class AdminRole extends Model
|
||||
'menu_action_id' => (int) $mid,
|
||||
]);
|
||||
}
|
||||
|
||||
if (DB::getSchemaBuilder()->hasTable('admin_role_legacy_permissions')) {
|
||||
DB::table('admin_role_legacy_permissions')->where('role_id', $this->id)->delete();
|
||||
$now = now();
|
||||
foreach ($legacySlugs as $slug) {
|
||||
DB::table('admin_role_legacy_permissions')->insert([
|
||||
'role_id' => $this->id,
|
||||
'permission_slug' => $slug,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function assignedUserCount(): int
|
||||
|
||||
@@ -51,14 +51,9 @@ final class AdminAuthorizationRegistry
|
||||
['slug' => 'prd.payout.review', 'name' => '派彩确认·可审核', 'nav_segment' => 'settlement', 'permission_codes' => ['settlement.batch.review', 'settlement.batch.view']],
|
||||
['slug' => 'prd.payout.view', 'name' => '派彩确认·查看', 'nav_segment' => 'settlement', 'permission_codes' => ['settlement.batch.view']],
|
||||
|
||||
['slug' => 'prd.audit.all', 'name' => '审计日志·全部', 'nav_segment' => 'audit', 'permission_codes' => ['service.audit.view']],
|
||||
['slug' => 'prd.audit.self', 'name' => '审计日志·自身相关', 'nav_segment' => 'audit', 'permission_codes' => ['service.audit.view']],
|
||||
['slug' => 'prd.audit.finance', 'name' => '审计日志·资金相关', 'nav_segment' => 'audit', 'permission_codes' => ['service.audit.view']],
|
||||
['slug' => 'prd.audit.view', 'name' => '审计日志·查看', 'nav_segment' => 'audit', 'permission_codes' => ['service.audit.view']],
|
||||
|
||||
['slug' => 'prd.report.all', 'name' => '报表中心·全部', 'nav_segment' => 'reports', 'permission_codes' => ['service.report.view']],
|
||||
['slug' => 'prd.report.risk', 'name' => '报表中心·风控', 'nav_segment' => 'reports', 'permission_codes' => ['service.report.view']],
|
||||
['slug' => 'prd.report.finance', 'name' => '报表中心·财务', 'nav_segment' => 'reports', 'permission_codes' => ['service.report.view']],
|
||||
['slug' => 'prd.report.player', 'name' => '报表中心·玩家', 'nav_segment' => 'reports', 'permission_codes' => ['service.report.view']],
|
||||
['slug' => 'prd.report.view', 'name' => '报表中心·查看', 'nav_segment' => 'reports', 'permission_codes' => ['service.report.view']],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -129,13 +124,13 @@ final class AdminAuthorizationRegistry
|
||||
['segment' => 'wallet', 'label' => 'Wallet', 'href' => '/admin/wallet/transactions', 'activeMatchPrefix' => '/admin/wallet', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.wallet_adjust.manage', 'prd.users.manage', 'prd.users.view_finance']],
|
||||
['segment' => 'settlement', 'label' => 'Settlement', 'href' => '/admin/settlement-batches', 'requiredAny' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view']],
|
||||
['segment' => 'reconcile', 'label' => 'Reconcile', 'href' => '/admin/reconcile', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']],
|
||||
['segment' => 'reports', 'label' => 'Reports', 'href' => '/admin/reports', 'requiredAny' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.payout.manage', 'prd.payout.review', 'prd.payout.view', 'prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view', 'prd.audit.all', 'prd.audit.self', 'prd.audit.finance']],
|
||||
['segment' => 'reports', 'label' => 'Reports', 'href' => '/admin/reports', 'requiredAny' => ['prd.report.view']],
|
||||
['segment' => 'currencies', 'label' => 'Currencies', 'href' => '/admin/currencies', 'requiredAny' => ['prd.currency.manage']],
|
||||
// 权限与系统
|
||||
['segment' => 'admin_users', 'label' => 'Admin Users', 'href' => '/admin/admin-users', 'requiredAny' => ['prd.admin_user.manage']],
|
||||
['segment' => 'admin_roles', 'label' => 'Admin Roles', 'href' => '/admin/admin-roles', 'requiredAny' => ['prd.admin_role.manage']],
|
||||
['segment' => 'risk', 'label' => 'Risk', 'href' => '/admin/risk', 'requiredAny' => ['prd.draw_result.view', 'prd.draw_result.manage']],
|
||||
['segment' => 'audit', 'label' => 'Audit Logs', 'href' => '/admin/audit-logs', 'requiredAny' => ['prd.audit.all', 'prd.audit.self', 'prd.audit.finance']],
|
||||
['segment' => 'audit', 'label' => 'Audit Logs', 'href' => '/admin/audit-logs', 'requiredAny' => ['prd.audit.view']],
|
||||
['segment' => 'settings', 'label' => 'Settings', 'href' => '/admin/settings', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.currency.manage']],
|
||||
];
|
||||
}
|
||||
@@ -203,9 +198,9 @@ final class AdminAuthorizationRegistry
|
||||
'risk' => ['prd.draw_result.manage', 'prd.draw_result.view'],
|
||||
'settlement' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view'],
|
||||
'reconcile' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs'],
|
||||
'reports' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.payout.manage', 'prd.payout.review', 'prd.payout.view', 'prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view', 'prd.audit.all', 'prd.audit.self', 'prd.audit.finance', 'prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player'],
|
||||
'reports' => ['prd.report.view'],
|
||||
'tickets' => ['prd.users.view_cs', 'prd.users.manage'],
|
||||
'audit' => ['prd.audit.all', 'prd.audit.self', 'prd.audit.finance'],
|
||||
'audit' => ['prd.audit.view'],
|
||||
'settings' => [],
|
||||
];
|
||||
|
||||
@@ -338,7 +333,7 @@ final class AdminAuthorizationRegistry
|
||||
['code' => 'admin.ping', 'module_code' => 'system', 'name' => '后台连通性探测', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/ping', 'route_name' => 'api.v1.admin.ping', 'auth_mode' => 'login_only', 'is_audit_required' => false],
|
||||
['code' => 'admin.dashboard', 'module_code' => 'dashboard', 'name' => '后台仪表盘', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/dashboard', 'route_name' => 'api.v1.admin.dashboard', 'auth_mode' => 'login_only', 'is_audit_required' => false],
|
||||
['code' => 'admin.auth.me', 'module_code' => 'system', 'name' => '后台当前管理员摘要', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/auth/me', 'route_name' => 'api.v1.admin.auth.me', 'auth_mode' => 'login_only', 'is_audit_required' => false],
|
||||
['code' => 'admin.audit.index', 'module_code' => 'audit', 'name' => '审计日志查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/audit-logs', 'route_name' => 'api.v1.admin.audit-logs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.audit.all', 'prd.audit.self', 'prd.audit.finance']],
|
||||
['code' => 'admin.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, 'legacy_permission_slugs' => ['prd.admin_user.manage']],
|
||||
['code' => 'admin.admin-users.store', 'module_code' => 'system', 'name' => '创建管理员', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/admin-users', 'route_name' => 'api.v1.admin.admin-users.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_user.manage']],
|
||||
@@ -432,14 +427,14 @@ final class AdminAuthorizationRegistry
|
||||
['code' => 'admin.reconcile-jobs.items.index', 'module_code' => 'reconcile', 'name' => '对账任务明细', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reconcile-jobs/{reconcile_job}/items', 'route_name' => 'api.v1.admin.reconcile-jobs.items.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']],
|
||||
['code' => 'admin.reconcile-jobs.store', 'module_code' => 'reconcile', 'name' => '创建对账任务', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/reconcile-jobs', 'route_name' => 'api.v1.admin.reconcile-jobs.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage']],
|
||||
|
||||
['code' => 'admin.reports.daily-profit', 'module_code' => 'report', 'name' => '每日盈亏汇总', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/daily-profit', 'route_name' => 'api.v1.admin.reports.daily-profit', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.payout.manage', 'prd.payout.review', 'prd.payout.view', 'prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view', 'prd.audit.all', 'prd.audit.self', 'prd.audit.finance', 'prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player']],
|
||||
['code' => 'admin.reports.player-win-loss', 'module_code' => 'report', 'name' => '玩家输赢报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/player-win-loss', 'route_name' => 'api.v1.admin.reports.player-win-loss', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.payout.manage', 'prd.payout.review', 'prd.payout.view', 'prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view', 'prd.audit.all', 'prd.audit.self', 'prd.audit.finance', 'prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player']],
|
||||
['code' => 'admin.reports.play-dimension', 'module_code' => 'report', 'name' => '玩法维度报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/play-dimension', 'route_name' => 'api.v1.admin.reports.play-dimension', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.payout.manage', 'prd.payout.review', 'prd.payout.view', 'prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view', 'prd.audit.all', 'prd.audit.self', 'prd.audit.finance', 'prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player']],
|
||||
['code' => 'admin.reports.rebate-commission', 'module_code' => 'report', 'name' => '佣金回水报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/rebate-commission', 'route_name' => 'api.v1.admin.reports.rebate-commission', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.payout.manage', 'prd.payout.review', 'prd.payout.view', 'prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view', 'prd.audit.all', 'prd.audit.self', 'prd.audit.finance', 'prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player']],
|
||||
['code' => 'admin.report-jobs.index', 'module_code' => 'report', 'name' => '报表任务列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs', 'route_name' => 'api.v1.admin.report-jobs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player']],
|
||||
['code' => 'admin.report-jobs.store', 'module_code' => 'report', 'name' => '创建报表任务', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/report-jobs', 'route_name' => 'api.v1.admin.report-jobs.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player']],
|
||||
['code' => 'admin.report-jobs.show', 'module_code' => 'report', 'name' => '报表任务详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs/{report_job}', 'route_name' => 'api.v1.admin.report-jobs.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player']],
|
||||
['code' => 'admin.report-jobs.download', 'module_code' => 'report', 'name' => '下载报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs/{report_job}/download', 'route_name' => 'api.v1.admin.report-jobs.download', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player']],
|
||||
['code' => 'admin.reports.daily-profit', 'module_code' => 'report', 'name' => '每日盈亏汇总', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/daily-profit', 'route_name' => 'api.v1.admin.reports.daily-profit', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']],
|
||||
['code' => 'admin.reports.player-win-loss', 'module_code' => 'report', 'name' => '玩家输赢报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/player-win-loss', 'route_name' => 'api.v1.admin.reports.player-win-loss', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']],
|
||||
['code' => 'admin.reports.play-dimension', 'module_code' => 'report', 'name' => '玩法维度报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/play-dimension', 'route_name' => 'api.v1.admin.reports.play-dimension', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']],
|
||||
['code' => 'admin.reports.rebate-commission', 'module_code' => 'report', 'name' => '佣金回水报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/rebate-commission', 'route_name' => 'api.v1.admin.reports.rebate-commission', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']],
|
||||
['code' => 'admin.report-jobs.index', 'module_code' => 'report', 'name' => '报表任务列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs', 'route_name' => 'api.v1.admin.report-jobs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']],
|
||||
['code' => 'admin.report-jobs.store', 'module_code' => 'report', 'name' => '创建报表任务', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/report-jobs', 'route_name' => 'api.v1.admin.report-jobs.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.report.view']],
|
||||
['code' => 'admin.report-jobs.show', 'module_code' => 'report', 'name' => '报表任务详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs/{report_job}', 'route_name' => 'api.v1.admin.report-jobs.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']],
|
||||
['code' => 'admin.report-jobs.download', 'module_code' => 'report', 'name' => '下载报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs/{report_job}/download', 'route_name' => 'api.v1.admin.report-jobs.download', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']],
|
||||
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,6 +4,48 @@ namespace App\Support;
|
||||
|
||||
final class AdminPermissionBridge
|
||||
{
|
||||
/** @var array<string, string> */
|
||||
private const DEPRECATED_LEGACY_SLUG_ALIASES = [
|
||||
'prd.audit.all' => 'prd.audit.view',
|
||||
'prd.audit.self' => 'prd.audit.view',
|
||||
'prd.audit.finance' => 'prd.audit.view',
|
||||
'prd.report.all' => 'prd.report.view',
|
||||
'prd.report.risk' => 'prd.report.view',
|
||||
'prd.report.finance' => 'prd.report.view',
|
||||
'prd.report.player' => 'prd.report.view',
|
||||
];
|
||||
|
||||
/**
|
||||
* 将请求或历史数据中的 `prd.*` 归一为当前 Registry 目录中的 slug。
|
||||
*
|
||||
* @param list<string> $slugs
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function normalizeCanonicalLegacySlugs(array $slugs): array
|
||||
{
|
||||
$canonical = [];
|
||||
$known = array_fill_keys(self::allLegacySlugs(), true);
|
||||
|
||||
foreach ($slugs as $slug) {
|
||||
if (! is_string($slug) || $slug === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset(self::DEPRECATED_LEGACY_SLUG_ALIASES[$slug])) {
|
||||
$slug = self::DEPRECATED_LEGACY_SLUG_ALIASES[$slug];
|
||||
}
|
||||
|
||||
if (isset($known[$slug])) {
|
||||
$canonical[$slug] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$keys = array_keys($canonical);
|
||||
sort($keys);
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
/** @return array<string, list<string>> */
|
||||
public static function legacyMap(): array
|
||||
{
|
||||
|
||||
@@ -128,20 +128,6 @@ return new class extends Migration
|
||||
]);
|
||||
}
|
||||
|
||||
$roleResourceRows = DB::table('admin_role_menu_actions as rma')
|
||||
->join('admin_api_resource_bindings as arb', 'arb.menu_action_id', '=', 'rma.menu_action_id')
|
||||
->join('admin_api_resources as ar', 'ar.id', '=', 'arb.api_resource_id')
|
||||
->whereIn('ar.code', $reportResourceCodes)
|
||||
->select('rma.role_id', 'arb.api_resource_id')
|
||||
->distinct()
|
||||
->get();
|
||||
|
||||
foreach ($roleResourceRows as $row) {
|
||||
DB::table('admin_role_api_resources')->updateOrInsert([
|
||||
'role_id' => (int) $row->role_id,
|
||||
'api_resource_id' => (int) $row->api_resource_id,
|
||||
], []);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
@@ -159,7 +145,6 @@ return new class extends Migration
|
||||
|
||||
$resourceIds = DB::table('admin_api_resources')->whereIn('code', $codes)->pluck('id');
|
||||
foreach ($resourceIds as $resourceId) {
|
||||
DB::table('admin_role_api_resources')->where('api_resource_id', (int) $resourceId)->delete();
|
||||
DB::table('admin_api_resource_bindings')->where('api_resource_id', (int) $resourceId)->delete();
|
||||
DB::table('admin_api_resources')->where('id', (int) $resourceId)->delete();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
use App\Support\AdminAuthorizationRegistry;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/** @var list<string> */
|
||||
private const STALE_RESOURCE_CODES = [
|
||||
'admin.reports.index',
|
||||
'admin.reports.store',
|
||||
'admin.reconcile.index',
|
||||
'admin.reconcile.store',
|
||||
'admin.draws.publish',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
private const REPORT_RESOURCE_CODES = [
|
||||
'admin.reports.daily-profit',
|
||||
'admin.reports.player-win-loss',
|
||||
'admin.reports.play-dimension',
|
||||
'admin.reports.rebate-commission',
|
||||
'admin.report-jobs.index',
|
||||
'admin.report-jobs.store',
|
||||
'admin.report-jobs.show',
|
||||
'admin.report-jobs.download',
|
||||
];
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$now = Carbon::now();
|
||||
|
||||
$this->deleteStaleApiResources();
|
||||
$this->ensureReportViewOnRolesWithReportLegacy();
|
||||
$this->syncReportResourceBindings($now);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// 绑定收紧与角色补权为数据修复,不回滚以免再现漂移。
|
||||
}
|
||||
|
||||
private function deleteStaleApiResources(): void
|
||||
{
|
||||
$resourceIds = DB::table('admin_api_resources')
|
||||
->whereIn('code', self::STALE_RESOURCE_CODES)
|
||||
->pluck('id');
|
||||
|
||||
foreach ($resourceIds as $resourceId) {
|
||||
$id = (int) $resourceId;
|
||||
DB::table('admin_api_resource_bindings')->where('api_resource_id', $id)->delete();
|
||||
DB::table('admin_api_resources')->where('id', $id)->delete();
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureReportViewOnRolesWithReportLegacy(): void
|
||||
{
|
||||
$menuActionId = DB::table('admin_menu_actions')
|
||||
->where('permission_code', 'service.report.view')
|
||||
->where('status', 1)
|
||||
->value('id');
|
||||
|
||||
if ($menuActionId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$reportSlugs = ['prd.report.view', 'prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player'];
|
||||
$roleIds = DB::table('admin_role_legacy_permissions')
|
||||
->whereIn('permission_slug', $reportSlugs)
|
||||
->distinct()
|
||||
->pluck('role_id');
|
||||
|
||||
foreach ($roleIds as $roleId) {
|
||||
DB::table('admin_role_menu_actions')->updateOrInsert([
|
||||
'role_id' => (int) $roleId,
|
||||
'menu_action_id' => (int) $menuActionId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function syncReportResourceBindings(Carbon $now): void
|
||||
{
|
||||
$menuActionIds = DB::table('admin_menu_actions')->pluck('id', 'permission_code');
|
||||
$registryByCode = [];
|
||||
foreach (AdminAuthorizationRegistry::resources() as $resource) {
|
||||
$registryByCode[$resource['code']] = $resource;
|
||||
}
|
||||
|
||||
foreach (self::REPORT_RESOURCE_CODES as $code) {
|
||||
$resource = $registryByCode[$code] ?? null;
|
||||
if ($resource === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$resourceId = DB::table('admin_api_resources')->where('code', $code)->value('id');
|
||||
if ($resourceId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('admin_api_resource_bindings')
|
||||
->where('api_resource_id', (int) $resourceId)
|
||||
->delete();
|
||||
|
||||
foreach ($resource['permission_codes'] as $permissionCode) {
|
||||
$menuActionId = $menuActionIds[$permissionCode] ?? null;
|
||||
if ($menuActionId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('admin_api_resource_bindings')->insert([
|
||||
'api_resource_id' => (int) $resourceId,
|
||||
'menu_action_id' => (int) $menuActionId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 移除未接入业务或已由其它表替代的冗余表:
|
||||
* - system_jobs:从未使用
|
||||
* - admin_role_menus:侧栏改由 prd.* + Registry 驱动
|
||||
* - admin_role_api_resources:鉴权由 role_menu_actions + bindings 实时推导
|
||||
* - admin_*_data_scopes:数据范围未落地
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::dropIfExists('admin_user_data_scopes');
|
||||
Schema::dropIfExists('admin_role_data_scopes');
|
||||
Schema::dropIfExists('admin_data_scopes');
|
||||
Schema::dropIfExists('admin_role_api_resources');
|
||||
Schema::dropIfExists('admin_role_menus');
|
||||
Schema::dropIfExists('system_jobs');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// 冗余表删除为单向清理,不回滚。
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AdminRole;
|
||||
use App\Support\AdminPermissionBridge;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('admin_role_legacy_permissions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$roleIds = DB::table('admin_roles')->pluck('id');
|
||||
|
||||
foreach ($roleIds as $roleId) {
|
||||
$legacySlugs = DB::table('admin_role_legacy_permissions')
|
||||
->where('role_id', (int) $roleId)
|
||||
->pluck('permission_slug')
|
||||
->all();
|
||||
|
||||
$slugs = AdminPermissionBridge::normalizeCanonicalLegacySlugs(
|
||||
is_array($legacySlugs) ? $legacySlugs : [],
|
||||
);
|
||||
|
||||
if ($slugs === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$role = AdminRole::query()->find((int) $roleId);
|
||||
if ($role !== null) {
|
||||
$role->syncLegacyPermissionSlugs($slugs);
|
||||
}
|
||||
}
|
||||
|
||||
Schema::dropIfExists('admin_role_legacy_permissions');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// 单向清理:slug 已合并,权限以 admin_role_menu_actions 为准。
|
||||
}
|
||||
};
|
||||
@@ -5,7 +5,6 @@ namespace Database\Seeders;
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\AdminUser;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Support\AdminPermissionBridge;
|
||||
|
||||
/**
|
||||
@@ -16,27 +15,9 @@ use App\Support\AdminPermissionBridge;
|
||||
final class AdminRbacAndUserSeeder extends Seeder
|
||||
{
|
||||
/** @param list<string> $legacySlugs */
|
||||
private function syncRoleMenuActions(AdminRole $role, array $legacySlugs): void
|
||||
private function syncRolePermissions(AdminRole $role, array $legacySlugs): void
|
||||
{
|
||||
$codes = [];
|
||||
foreach ($legacySlugs as $slug) {
|
||||
$codes = array_merge($codes, AdminPermissionBridge::menuActionCodesForLegacy($slug));
|
||||
}
|
||||
$codes = array_values(array_unique($codes));
|
||||
|
||||
$ids = DB::table('admin_menu_actions')
|
||||
->whereIn('permission_code', $codes)
|
||||
->where('status', 1)
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
DB::table('admin_role_menu_actions')->where('role_id', $role->id)->delete();
|
||||
foreach ($ids as $mid) {
|
||||
DB::table('admin_role_menu_actions')->insert([
|
||||
'role_id' => $role->id,
|
||||
'menu_action_id' => (int) $mid,
|
||||
]);
|
||||
}
|
||||
$role->syncLegacyPermissionSlugs($legacySlugs);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
@@ -51,13 +32,13 @@ final class AdminRbacAndUserSeeder extends Seeder
|
||||
['slug' => AdminUser::ROLE_SUPER_ADMIN],
|
||||
['name' => '超级管理员'],
|
||||
);
|
||||
$this->syncRoleMenuActions($super, $this->allCatalogSlugs());
|
||||
$this->syncRolePermissions($super, $this->allCatalogSlugs());
|
||||
|
||||
$risk = AdminRole::query()->updateOrCreate(
|
||||
['slug' => 'risk_operator'],
|
||||
['name' => '风控运营员'],
|
||||
);
|
||||
$this->syncRoleMenuActions($risk, [
|
||||
$this->syncRolePermissions($risk, [
|
||||
'prd.play_switch.manage',
|
||||
'prd.odds.manage',
|
||||
'prd.risk_cap.manage',
|
||||
@@ -66,15 +47,16 @@ final class AdminRbacAndUserSeeder extends Seeder
|
||||
'prd.draw_result.manage',
|
||||
'prd.payout.review',
|
||||
'prd.wallet_reconcile.view',
|
||||
'prd.audit.self',
|
||||
'prd.audit.view',
|
||||
'prd.player_freeze.manage',
|
||||
'prd.report.view',
|
||||
]);
|
||||
|
||||
$finance = AdminRole::query()->updateOrCreate(
|
||||
['slug' => 'finance'],
|
||||
['name' => '财务/对账员'],
|
||||
);
|
||||
$this->syncRoleMenuActions($finance, [
|
||||
$this->syncRolePermissions($finance, [
|
||||
'prd.users.view_finance',
|
||||
'prd.risk_cap.view',
|
||||
'prd.rebate.view',
|
||||
@@ -83,17 +65,19 @@ final class AdminRbacAndUserSeeder extends Seeder
|
||||
'prd.payout.view',
|
||||
'prd.wallet_reconcile.manage',
|
||||
'prd.wallet_adjust.manage',
|
||||
'prd.audit.finance',
|
||||
'prd.audit.view',
|
||||
'prd.report.view',
|
||||
]);
|
||||
|
||||
$cs = AdminRole::query()->updateOrCreate(
|
||||
['slug' => 'customer_service'],
|
||||
['name' => '客服人员'],
|
||||
);
|
||||
$this->syncRoleMenuActions($cs, [
|
||||
$this->syncRolePermissions($cs, [
|
||||
'prd.users.view_cs',
|
||||
'prd.draw_result.view',
|
||||
'prd.wallet_reconcile.view_cs',
|
||||
'prd.report.view',
|
||||
]);
|
||||
|
||||
$username = 'admin';
|
||||
|
||||
@@ -88,5 +88,12 @@ final class LotterySettingsSeeder extends Seeder
|
||||
'settlement',
|
||||
'为 true 时结算派彩在毛赢基础上再乘 (1 - rebate_rate_snapshot);默认 false(实扣已含回水)',
|
||||
);
|
||||
|
||||
LotterySettings::put(
|
||||
'frontend.play_rules_html',
|
||||
'',
|
||||
'frontend',
|
||||
'玩家端玩法规则页面显示的自定义 HTML 富文本内容',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
35
docs/admin-rbac.md
Normal file
35
docs/admin-rbac.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# 后台 RBAC 与导航分工
|
||||
|
||||
## 单一真相源
|
||||
|
||||
| 能力 | 来源 | 说明 |
|
||||
|------|------|------|
|
||||
| 侧栏 / `auth/me` 的 `navigation` | `App\Support\AdminAuthorizationRegistry::navigationDefinitions()` | **代码注册表**,改菜单需发版 |
|
||||
| 角色可勾选的产品权限 `prd.*` | 同上 `permissionDefinitions()` | UI 展示名与 `permission_code` 映射 |
|
||||
| API 鉴权 `permission_code` | 库表 `admin_menu_actions` | 运行时校验 |
|
||||
| 路由资源 | 库表 `admin_api_resources` + `admin_api_resource_bindings` | 由 `php artisan lottery:admin-auth-sync` 从 Registry 同步 |
|
||||
|
||||
## `admin_menus` 不是侧栏配置
|
||||
|
||||
- 表 `admin_menus`:仅用于 **`admin_menu_actions` 的业务分组**(权限模块树)。
|
||||
- **不要**通过改 `admin_menus` 期望侧栏变化;侧栏只看 Registry + 用户拥有的 `prd.*`。
|
||||
|
||||
## 角色权限如何存储
|
||||
|
||||
- **权威数据**:`admin_role_menu_actions`(角色 ↔ 动作权限)。
|
||||
- **`prd.*` 展示**:由 `AdminPermissionBridge::legacySlugsGrantedByMenuActionCodes()` 从已授权动作**反推**,不单独落库。
|
||||
- 用户直接授权:`admin_user_menu_actions`(可选,与角色权限合并生效)。
|
||||
|
||||
## 维护命令
|
||||
|
||||
```bash
|
||||
php artisan lottery:admin-auth-sync --audit # 同步 API 资源与 bindings,并体检
|
||||
php artisan lottery:admin-auth-audit # 仅体检路由覆盖与 binding
|
||||
```
|
||||
|
||||
## 已废弃的 `prd.*`(请求体仍可传入,会自动归一)
|
||||
|
||||
| 旧 slug | 归一为 |
|
||||
|---------|--------|
|
||||
| `prd.audit.all` / `prd.audit.self` / `prd.audit.finance` | `prd.audit.view` |
|
||||
| `prd.report.all` / `prd.report.risk` / `prd.report.finance` / `prd.report.player` | `prd.report.view` |
|
||||
@@ -9,6 +9,7 @@ use App\Http\Controllers\Api\V1\Currency\CurrencyIndexController;
|
||||
use App\Http\Controllers\Api\V1\Jackpot\JackpotSummaryController;
|
||||
use App\Http\Controllers\Api\V1\Play\PlayEffectiveCatalogController;
|
||||
use App\Http\Controllers\Api\V1\Player\PingController as PlayerPingController;
|
||||
use App\Http\Controllers\Api\V1\Setting\SettingIndexController;
|
||||
|
||||
/**
|
||||
* 公开路由(无需登录)。
|
||||
@@ -39,3 +40,6 @@ Route::prefix('player')
|
||||
->group(function (): void {
|
||||
Route::get('ping', PlayerPingController::class)->name('ping');
|
||||
});
|
||||
|
||||
// 系统公共配置(如前端规则等)
|
||||
Route::get('settings', SettingIndexController::class)->name('api.v1.settings.index');
|
||||
|
||||
@@ -77,7 +77,7 @@ test('admin api resource middleware denies protected report resource without per
|
||||
});
|
||||
|
||||
test('admin api resource middleware allows protected report resource with mapped permission', function (): void {
|
||||
$token = mintAdminTokenWithLegacySlugs('resource_reporter', ['prd.report.player']);
|
||||
$token = mintAdminTokenWithLegacySlugs('resource_reporter', ['prd.report.view']);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/report-jobs')
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Database\Seeders\AdminRbacAndUserSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@@ -49,27 +48,3 @@ test('admin authorization sync can repair registry-backed api resources and pass
|
||||
|
||||
expect($bindingCount)->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('admin authorization audit detects role api resource drift', function (): void {
|
||||
$this->seed(AdminRbacAndUserSeeder::class);
|
||||
|
||||
$resourceId = DB::table('admin_api_resources')
|
||||
->where('code', 'admin.audit.index')
|
||||
->value('id');
|
||||
|
||||
$roleId = DB::table('admin_roles')
|
||||
->where('slug', 'finance')
|
||||
->value('id');
|
||||
|
||||
expect($resourceId)->not->toBeNull();
|
||||
expect($roleId)->not->toBeNull();
|
||||
|
||||
DB::table('admin_role_api_resources')
|
||||
->where('role_id', (int) $roleId)
|
||||
->where('api_resource_id', (int) $resourceId)
|
||||
->delete();
|
||||
|
||||
$this->artisan('lottery:admin-auth-audit --skip-route-coverage')
|
||||
->expectsOutputToContain('Missing role-resource grant')
|
||||
->assertExitCode(1);
|
||||
});
|
||||
|
||||
32
tests/Feature/AdminPermissionBridgeTest.php
Normal file
32
tests/Feature/AdminPermissionBridgeTest.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use App\Support\AdminPermissionBridge;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('normalizeCanonicalLegacySlugs maps deprecated audit and report slugs', function (): void {
|
||||
$normalized = AdminPermissionBridge::normalizeCanonicalLegacySlugs([
|
||||
'prd.audit.finance',
|
||||
'prd.report.player',
|
||||
'prd.users.manage',
|
||||
]);
|
||||
|
||||
expect($normalized)->toBe([
|
||||
'prd.audit.view',
|
||||
'prd.report.view',
|
||||
'prd.users.manage',
|
||||
]);
|
||||
});
|
||||
|
||||
test('legacy permission slugs are derived from role menu actions only', function (): void {
|
||||
$role = \App\Models\AdminRole::query()->create([
|
||||
'slug' => 'derived_only',
|
||||
'name' => 'Derived',
|
||||
]);
|
||||
|
||||
$role->syncLegacyPermissionSlugs(['prd.report.view']);
|
||||
|
||||
expect($role->fresh()->legacyPermissionSlugs())->toBe(['prd.report.view']);
|
||||
expect(\Illuminate\Support\Facades\Schema::hasTable('admin_role_legacy_permissions'))->toBeFalse();
|
||||
});
|
||||
@@ -154,7 +154,7 @@ test('reconcile job create with items and nested items index', function (): void
|
||||
test('admin without report permission receives 403 on report-jobs', function (): void {
|
||||
$role = AdminRole::query()->create(['slug' => 'auditor_test', 'name' => 'Auditor Test']);
|
||||
$ids = DB::table('admin_menu_actions')
|
||||
->whereIn('permission_code', AdminPermissionBridge::menuActionCodesForLegacy('prd.audit.finance'))
|
||||
->whereIn('permission_code', AdminPermissionBridge::menuActionCodesForLegacy('prd.audit.view'))
|
||||
->where('status', 1)
|
||||
->pluck('id');
|
||||
foreach ($ids as $mid) {
|
||||
|
||||
75
tests/Feature/AdminReportAuthorizationFixTest.php
Normal file
75
tests/Feature/AdminReportAuthorizationFixTest.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\AdminUser;
|
||||
use Database\Seeders\AdminRbacAndUserSeeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function makeFinanceReportAdminToken(): string
|
||||
{
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'finance_report_tester',
|
||||
'name' => 'Tester',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$role = AdminRole::query()->where('slug', 'finance')->firstOrFail();
|
||||
$siteId = AdminUser::defaultAdminSiteId();
|
||||
$admin->roles()->sync([
|
||||
(int) $role->id => [
|
||||
'site_id' => $siteId,
|
||||
'granted_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
}
|
||||
|
||||
test('finance role with report legacy can access report jobs after rbac seed', function (): void {
|
||||
$this->seed(AdminRbacAndUserSeeder::class);
|
||||
|
||||
$finance = AdminRole::query()->where('slug', 'finance')->firstOrFail();
|
||||
expect($finance->legacyPermissionSlugs())->toContain('prd.report.view');
|
||||
|
||||
$hasReportAction = DB::table('admin_role_menu_actions as rma')
|
||||
->join('admin_menu_actions as ma', 'ma.id', '=', 'rma.menu_action_id')
|
||||
->where('rma.role_id', $finance->id)
|
||||
->where('ma.permission_code', 'service.report.view')
|
||||
->exists();
|
||||
|
||||
expect($hasReportAction)->toBeTrue();
|
||||
|
||||
$token = makeFinanceReportAdminToken();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/report-jobs')
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
test('report api resources only bind service.report.view', function (): void {
|
||||
$this->seed(AdminRbacAndUserSeeder::class);
|
||||
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
|
||||
$codes = [
|
||||
'admin.reports.daily-profit',
|
||||
'admin.report-jobs.index',
|
||||
];
|
||||
|
||||
foreach ($codes as $code) {
|
||||
$bindings = DB::table('admin_api_resources as ar')
|
||||
->join('admin_api_resource_bindings as arb', 'arb.api_resource_id', '=', 'ar.id')
|
||||
->join('admin_menu_actions as ma', 'ma.id', '=', 'arb.menu_action_id')
|
||||
->where('ar.code', $code)
|
||||
->pluck('ma.permission_code')
|
||||
->all();
|
||||
|
||||
expect($bindings)->toBe(['service.report.view']);
|
||||
}
|
||||
});
|
||||
@@ -39,7 +39,7 @@ function makeAdminWithPermissions(string $username, array $permissionSlugs): str
|
||||
}
|
||||
|
||||
test('admin user permission apis require rbac permission', function (): void {
|
||||
$token = makeAdminWithPermissions('rbac_viewer', ['prd.report.player']);
|
||||
$token = makeAdminWithPermissions('rbac_viewer', ['prd.report.view']);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/admin-users')
|
||||
@@ -95,13 +95,13 @@ 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' => ['prd.report.player'],
|
||||
'permission_slugs' => ['prd.report.view'],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('code', ErrorCode::Success->value)
|
||||
->assertJsonFragment(['prd.report.player']);
|
||||
->assertJsonFragment(['prd.report.view']);
|
||||
|
||||
expect($target->fresh()->directLegacyPermissionSlugs())->toContain('prd.report.player');
|
||||
expect($target->fresh()->directLegacyPermissionSlugs())->toContain('prd.report.view');
|
||||
|
||||
$list = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/admin-users?keyword=target')
|
||||
@@ -109,7 +109,7 @@ test('admin can list users and sync direct permissions', function (): void {
|
||||
->json('data.items.0.effective_permissions');
|
||||
|
||||
expect($list)->toContain('prd.draw_result.view');
|
||||
expect($list)->toContain('prd.report.player');
|
||||
expect($list)->toContain('prd.report.view');
|
||||
});
|
||||
|
||||
test('admin can sync user roles for default site', function (): void {
|
||||
@@ -176,8 +176,7 @@ test('permission catalog groups permissions by admin navigation order', function
|
||||
'prd.users.manage',
|
||||
]);
|
||||
expect(array_column($groupsByKey['reports']['permissions'], 'slug'))->toContain(
|
||||
'prd.report.player',
|
||||
'prd.report.all',
|
||||
'prd.report.view',
|
||||
);
|
||||
expect(array_column($groupsByKey['jackpot']['permissions'], 'slug'))->toContain(
|
||||
'prd.jackpot.manage',
|
||||
@@ -207,7 +206,7 @@ test('admin can repair role permissions from the full catalog after role creatio
|
||||
expect($catalogSlugs)
|
||||
->toContain('prd.admin_user.manage')
|
||||
->toContain('prd.admin_role.manage')
|
||||
->toContain('prd.report.player')
|
||||
->toContain('prd.report.view')
|
||||
->toContain('prd.wallet_reconcile.manage');
|
||||
|
||||
$role = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
@@ -220,13 +219,15 @@ test('admin can repair role permissions from the full catalog after role creatio
|
||||
->assertJsonPath('data.permission_slugs', [])
|
||||
->json('data');
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
$repairResponse = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/admin-roles/'.$role['id'].'/permissions', [
|
||||
'permission_slugs' => ['prd.report.player', 'prd.wallet_reconcile.manage'],
|
||||
'permission_slugs' => ['prd.report.view', 'prd.wallet_reconcile.manage'],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.slug', 'repairable_role')
|
||||
->assertJsonPath('data.permission_slugs', ['prd.report.player', 'prd.wallet_reconcile.manage']);
|
||||
->assertJsonPath('data.slug', 'repairable_role');
|
||||
|
||||
expect($repairResponse->json('data.permission_slugs'))
|
||||
->toContain('prd.report.view', 'prd.wallet_reconcile.manage');
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/admin-roles/'.$role['id'].'/permissions', [
|
||||
|
||||
Reference in New Issue
Block a user