diff --git a/README.md b/README.md
index cd0e73d..9c30816 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,10 @@
+## 后台 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:
diff --git a/app/Console/Commands/AuditAdminAuthorizationCommand.php b/app/Console/Commands/AuditAdminAuthorizationCommand.php
index 60f4305..6c4b58f 100644
--- a/app/Console/Commands/AuditAdminAuthorizationCommand.php
+++ b/app/Console/Commands/AuditAdminAuthorizationCommand.php
@@ -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
- */
- 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
*/
@@ -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;
- }
}
diff --git a/app/Console/Commands/SyncAdminAuthorizationCommand.php b/app/Console/Commands/SyncAdminAuthorizationCommand.php
index 290091d..a8776a7 100644
--- a/app/Console/Commands/SyncAdminAuthorizationCommand.php
+++ b/app/Console/Commands/SyncAdminAuthorizationCommand.php
@@ -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')) {
diff --git a/app/Http/Controllers/Api/V1/Admin/AdminSettingController.php b/app/Http/Controllers/Api/V1/Admin/AdminSettingController.php
index 8b02d48..83bac13 100644
--- a/app/Http/Controllers/Api/V1/Admin/AdminSettingController.php
+++ b/app/Http/Controllers/Api/V1/Admin/AdminSettingController.php
@@ -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();
diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminUserPermissionSyncController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminUserPermissionSyncController.php
index 9b6718b..8a13ac9 100644
--- a/app/Http/Controllers/Api/V1/Admin/User/AdminUserPermissionSyncController.php
+++ b/app/Http/Controllers/Api/V1/Admin/User/AdminUserPermissionSyncController.php
@@ -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 = [];
diff --git a/app/Http/Controllers/Api/V1/Setting/SettingIndexController.php b/app/Http/Controllers/Api/V1/Setting/SettingIndexController.php
new file mode 100644
index 0000000..8ebcc40
--- /dev/null
+++ b/app/Http/Controllers/Api/V1/Setting/SettingIndexController.php
@@ -0,0 +1,35 @@
+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]);
+ }
+}
diff --git a/app/Models/AdminMenu.php b/app/Models/AdminMenu.php
index 446206e..cd23c00 100644
--- a/app/Models/AdminMenu.php
+++ b/app/Models/AdminMenu.php
@@ -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';
diff --git a/app/Models/AdminRole.php b/app/Models/AdminRole.php
index 5c11092..d98a950 100644
--- a/app/Models/AdminRole.php
+++ b/app/Models/AdminRole.php
@@ -57,29 +57,12 @@ final class AdminRole extends Model
}
/**
+ * 由已授权的 menu_action 反推 `prd.*`(与 Registry 映射一致)。
+ *
* @return list
*/
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
diff --git a/app/Support/AdminAuthorizationRegistry.php b/app/Support/AdminAuthorizationRegistry.php
index 6f6dd5e..abaa60b 100644
--- a/app/Support/AdminAuthorizationRegistry.php
+++ b/app/Support/AdminAuthorizationRegistry.php
@@ -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']],
];
}
diff --git a/app/Support/AdminPermissionBridge.php b/app/Support/AdminPermissionBridge.php
index 3b448de..fbf5d0c 100644
--- a/app/Support/AdminPermissionBridge.php
+++ b/app/Support/AdminPermissionBridge.php
@@ -4,6 +4,48 @@ namespace App\Support;
final class AdminPermissionBridge
{
+ /** @var array */
+ 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 $slugs
+ * @return list
+ */
+ 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> */
public static function legacyMap(): array
{
diff --git a/database/migrations/2026_05_22_100000_add_admin_report_module.php b/database/migrations/2026_05_22_100000_add_admin_report_module.php
index 0799573..b7f9894 100644
--- a/database/migrations/2026_05_22_100000_add_admin_report_module.php
+++ b/database/migrations/2026_05_22_100000_add_admin_report_module.php
@@ -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();
}
diff --git a/database/migrations/2026_05_22_110000_fix_admin_report_authorization.php b/database/migrations/2026_05_22_110000_fix_admin_report_authorization.php
new file mode 100644
index 0000000..9f9ff46
--- /dev/null
+++ b/database/migrations/2026_05_22_110000_fix_admin_report_authorization.php
@@ -0,0 +1,122 @@
+ */
+ private const STALE_RESOURCE_CODES = [
+ 'admin.reports.index',
+ 'admin.reports.store',
+ 'admin.reconcile.index',
+ 'admin.reconcile.store',
+ 'admin.draws.publish',
+ ];
+
+ /** @var list */
+ 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,
+ ]);
+ }
+ }
+ }
+
+};
diff --git a/database/migrations/2026_05_22_120000_drop_redundant_admin_and_system_tables.php b/database/migrations/2026_05_22_120000_drop_redundant_admin_and_system_tables.php
new file mode 100644
index 0000000..267f379
--- /dev/null
+++ b/database/migrations/2026_05_22_120000_drop_redundant_admin_and_system_tables.php
@@ -0,0 +1,29 @@
+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 为准。
+ }
+};
diff --git a/database/seeders/AdminRbacAndUserSeeder.php b/database/seeders/AdminRbacAndUserSeeder.php
index 1710c64..dbb5f1d 100644
--- a/database/seeders/AdminRbacAndUserSeeder.php
+++ b/database/seeders/AdminRbacAndUserSeeder.php
@@ -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 $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 */
@@ -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';
diff --git a/database/seeders/LotterySettingsSeeder.php b/database/seeders/LotterySettingsSeeder.php
index eb3cd28..fc67309 100644
--- a/database/seeders/LotterySettingsSeeder.php
+++ b/database/seeders/LotterySettingsSeeder.php
@@ -88,5 +88,12 @@ final class LotterySettingsSeeder extends Seeder
'settlement',
'为 true 时结算派彩在毛赢基础上再乘 (1 - rebate_rate_snapshot);默认 false(实扣已含回水)',
);
+
+ LotterySettings::put(
+ 'frontend.play_rules_html',
+ '',
+ 'frontend',
+ '玩家端玩法规则页面显示的自定义 HTML 富文本内容',
+ );
}
}
diff --git a/docs/admin-rbac.md b/docs/admin-rbac.md
new file mode 100644
index 0000000..559d962
--- /dev/null
+++ b/docs/admin-rbac.md
@@ -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` |
diff --git a/routes/api/v1/public.php b/routes/api/v1/public.php
index e4dc1e5..db649bb 100644
--- a/routes/api/v1/public.php
+++ b/routes/api/v1/public.php
@@ -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');
diff --git a/tests/Feature/AdminApiResourcePermissionMiddlewareTest.php b/tests/Feature/AdminApiResourcePermissionMiddlewareTest.php
index 5eb0818..d0ab1ac 100644
--- a/tests/Feature/AdminApiResourcePermissionMiddlewareTest.php
+++ b/tests/Feature/AdminApiResourcePermissionMiddlewareTest.php
@@ -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')
diff --git a/tests/Feature/AdminAuthorizationAuditCommandTest.php b/tests/Feature/AdminAuthorizationAuditCommandTest.php
index 2b1ee2f..53d6599 100644
--- a/tests/Feature/AdminAuthorizationAuditCommandTest.php
+++ b/tests/Feature/AdminAuthorizationAuditCommandTest.php
@@ -1,7 +1,6 @@
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);
-});
diff --git a/tests/Feature/AdminPermissionBridgeTest.php b/tests/Feature/AdminPermissionBridgeTest.php
new file mode 100644
index 0000000..d29392f
--- /dev/null
+++ b/tests/Feature/AdminPermissionBridgeTest.php
@@ -0,0 +1,32 @@
+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();
+});
diff --git a/tests/Feature/AdminPhase15OperationsTest.php b/tests/Feature/AdminPhase15OperationsTest.php
index 1cce4f1..f8e583b 100644
--- a/tests/Feature/AdminPhase15OperationsTest.php
+++ b/tests/Feature/AdminPhase15OperationsTest.php
@@ -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) {
diff --git a/tests/Feature/AdminReportAuthorizationFixTest.php b/tests/Feature/AdminReportAuthorizationFixTest.php
new file mode 100644
index 0000000..1c99ec3
--- /dev/null
+++ b/tests/Feature/AdminReportAuthorizationFixTest.php
@@ -0,0 +1,75 @@
+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']);
+ }
+});
diff --git a/tests/Feature/AdminUserPermissionApiTest.php b/tests/Feature/AdminUserPermissionApiTest.php
index 173340f..7c52e0a 100644
--- a/tests/Feature/AdminUserPermissionApiTest.php
+++ b/tests/Feature/AdminUserPermissionApiTest.php
@@ -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', [