From fc023242ce19b54100e8a368ab3808bd839d27b9 Mon Sep 17 00:00:00 2001 From: kang Date: Mon, 11 May 2026 16:21:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E6=9D=83=E9=99=90=E7=AE=A1=E7=90=86=EF=BC=8C=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20RBAC=20=E6=94=AF=E6=8C=81=EF=BC=8C=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=20AdminUser=20=E6=A8=A1=E5=9E=8B=E4=BB=A5=E5=A4=84?= =?UTF-8?q?=E7=90=86=E8=A7=92=E8=89=B2=E5=92=8C=E6=9D=83=E9=99=90=EF=BC=8C?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=99=BB=E5=BD=95=E6=8E=A5=E5=8F=A3=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E6=9D=83=E9=99=90=E4=BF=A1=E6=81=AF=EF=BC=8C=E6=89=A9?= =?UTF-8?q?=E5=B1=95=E6=95=B0=E6=8D=AE=E5=BA=93=E5=A1=AB=E5=85=85=E5=99=A8?= =?UTF-8?q?=E4=BB=A5=E5=90=8C=E6=AD=A5=E8=A7=92=E8=89=B2=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Admin/Audit/AuditLogIndexController.php | 67 +++++ .../Api/V1/Admin/Auth/LoginController.php | 1 + .../AdminDrawFinanceSummaryController.php | 70 +++++ .../AdminPlayerTicketItemsIndexController.php | 74 ++++++ .../ReconcileItemIndexController.php | 44 ++++ .../Reconcile/ReconcileJobIndexController.php | 54 ++++ .../Reconcile/ReconcileJobShowController.php | 28 ++ .../Reconcile/ReconcileJobStoreController.php | 49 ++++ .../Reports/ReportJobIndexController.php | 51 ++++ .../Admin/Reports/ReportJobShowController.php | 29 +++ .../Reports/ReportJobStoreController.php | 41 +++ app/Http/Middleware/EnsureAdminPermission.php | 48 ++++ app/Lottery/ErrorCode.php | 3 + app/Models/AdminPermission.php | 27 ++ app/Models/AdminRole.php | 38 +++ app/Models/AdminUser.php | 53 ++++ app/Models/ReconcileItem.php | 34 +++ app/Models/ReconcileJob.php | 45 ++++ app/Models/ReportJob.php | 37 +++ .../Admin/AdminReconcileJobService.php | 84 ++++++ app/Services/Admin/AdminReportJobService.php | 55 ++++ bootstrap/app.php | 2 + ..._admin_user_id_to_reconcile_jobs_table.php | 25 ++ database/seeders/AdminRbacAndUserSeeder.php | 170 +++++++++--- lang/en/admin.php | 1 + lang/ne/admin.php | 1 + lang/zh/admin.php | 1 + routes/api.php | 241 ++++++++++++------ tests/Feature/AdminAuthLoginTest.php | 2 +- tests/Feature/AdminCsFinanceApisTest.php | 195 ++++++++++++++ tests/Feature/AdminDrawApiTest.php | 1 + tests/Feature/AdminPhase15OperationsTest.php | 115 +++++++++ tests/Feature/AdminRiskPoolApiTest.php | 1 + .../Feature/AdminSettlementJackpotApiTest.php | 1 + tests/Feature/AdminWalletApiTest.php | 1 + tests/Feature/DrawPipelineTest.php | 1 + .../OperationalConfigAcceptanceTest.php | 1 + tests/Feature/OperationalConfigApiTest.php | 1 + tests/Pest.php | 18 +- 39 files changed, 1587 insertions(+), 123 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/Admin/Audit/AuditLogIndexController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawFinanceSummaryController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerTicketItemsIndexController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileItemIndexController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileJobIndexController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileJobShowController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileJobStoreController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Reports/ReportJobIndexController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Reports/ReportJobShowController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Reports/ReportJobStoreController.php create mode 100644 app/Http/Middleware/EnsureAdminPermission.php create mode 100644 app/Models/AdminPermission.php create mode 100644 app/Models/AdminRole.php create mode 100644 app/Models/ReconcileItem.php create mode 100644 app/Models/ReconcileJob.php create mode 100644 app/Models/ReportJob.php create mode 100644 app/Services/Admin/AdminReconcileJobService.php create mode 100644 app/Services/Admin/AdminReportJobService.php create mode 100644 database/migrations/2026_05_11_120000_add_admin_user_id_to_reconcile_jobs_table.php create mode 100644 tests/Feature/AdminCsFinanceApisTest.php create mode 100644 tests/Feature/AdminPhase15OperationsTest.php diff --git a/app/Http/Controllers/Api/V1/Admin/Audit/AuditLogIndexController.php b/app/Http/Controllers/Api/V1/Admin/Audit/AuditLogIndexController.php new file mode 100644 index 0000000..0161f9f --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Audit/AuditLogIndexController.php @@ -0,0 +1,67 @@ +integer('per_page', 25), 1), 100); + $page = max((int) $request->integer('page', 1), 1); + $module = trim((string) $request->query('module_code', '')); + $action = trim((string) $request->query('action_code', '')); + $operatorType = trim((string) $request->query('operator_type', '')); + + $q = AuditLog::query()->orderByDesc('id'); + + if ($module !== '') { + $q->where('module_code', $module); + } + if ($action !== '') { + $q->where('action_code', $action); + } + if ($operatorType !== '') { + $q->where('operator_type', $operatorType); + } + + $paginator = $q->paginate($perPage, ['*'], 'page', $page); + + return ApiResponse::success([ + 'items' => collect($paginator->items())->map(fn (AuditLog $r) => $this->row($r))->all(), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } + + /** @return array */ + private function row(AuditLog $r): array + { + return [ + 'id' => (int) $r->id, + 'operator_type' => $r->operator_type, + 'operator_id' => (int) $r->operator_id, + 'module_code' => $r->module_code, + 'action_code' => $r->action_code, + 'target_type' => $r->target_type, + 'target_id' => $r->target_id, + 'before_json' => $r->before_json, + 'after_json' => $r->after_json, + 'ip' => $r->ip, + 'user_agent' => $r->user_agent, + 'created_at' => $r->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Auth/LoginController.php b/app/Http/Controllers/Api/V1/Admin/Auth/LoginController.php index 418aa59..1755d96 100644 --- a/app/Http/Controllers/Api/V1/Admin/Auth/LoginController.php +++ b/app/Http/Controllers/Api/V1/Admin/Auth/LoginController.php @@ -87,6 +87,7 @@ final class LoginController 'username' => $admin->username, 'nickname' => $admin->name, 'email' => $admin->email, + 'permissions' => $admin->fresh()->adminPermissionSlugs(), ], ]); } diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawFinanceSummaryController.php b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawFinanceSummaryController.php new file mode 100644 index 0000000..62e3eb4 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawFinanceSummaryController.php @@ -0,0 +1,70 @@ +id; + + $totalBetMinor = (int) TicketOrder::query()->where('draw_id', $drawId)->sum('total_actual_deduct'); + $orderCount = (int) TicketOrder::query()->where('draw_id', $drawId)->count(); + $itemCount = (int) TicketItem::query()->where('draw_id', $drawId)->count(); + + $currencyCode = (string) (TicketOrder::query() + ->where('draw_id', $drawId) + ->value('currency_code') ?? ''); + + $totalWinMinor = (int) TicketItem::query()->where('draw_id', $drawId)->sum('win_amount'); + $totalJackpotWinMinor = (int) TicketItem::query()->where('draw_id', $drawId)->sum('jackpot_win_amount'); + $totalPayoutMinor = $totalWinMinor + $totalJackpotWinMinor; + $approxHouseGrossMinor = $totalBetMinor - $totalPayoutMinor; + + $batches = SettlementBatch::query() + ->where('draw_id', $drawId) + ->orderByDesc('id') + ->limit(30) + ->get(['id', 'status', 'total_ticket_count', 'total_win_count', 'total_payout_amount', 'total_jackpot_payout_amount', 'finished_at']); + + $batchRows = $batches->map(static function (SettlementBatch $b): array { + return [ + 'id' => (int) $b->id, + 'status' => $b->status, + 'total_ticket_count' => (int) $b->total_ticket_count, + 'total_win_count' => (int) $b->total_win_count, + 'total_payout_amount' => (int) $b->total_payout_amount, + 'total_jackpot_payout_amount' => (int) $b->total_jackpot_payout_amount, + 'finished_at' => $b->finished_at?->toIso8601String(), + ]; + })->values()->all(); + + return ApiResponse::success([ + 'draw_id' => $drawId, + 'draw_no' => $draw->draw_no, + 'draw_status' => $draw->status, + 'currency_code' => $currencyCode !== '' ? $currencyCode : null, + 'order_count' => $orderCount, + 'ticket_item_count' => $itemCount, + 'total_bet_minor' => $totalBetMinor, + 'total_win_payout_minor' => $totalWinMinor, + 'total_jackpot_win_minor' => $totalJackpotWinMinor, + 'total_payout_minor' => $totalPayoutMinor, + 'approx_house_gross_minor' => $approxHouseGrossMinor, + 'settlement_batches' => $batchRows, + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerTicketItemsIndexController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerTicketItemsIndexController.php new file mode 100644 index 0000000..6347132 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerTicketItemsIndexController.php @@ -0,0 +1,74 @@ +query(), [ + 'page' => ['sometimes', 'integer', 'min:1'], + 'per_page' => ['sometimes', 'integer', 'min:1', 'max:50'], + 'draw_no' => ['sometimes', 'nullable', 'string', 'max:32'], + ])->validate(); + + $perPage = max(1, min(50, (int) ($validated['per_page'] ?? 20))); + $page = max(1, (int) ($validated['page'] ?? 1)); + $drawNo = isset($validated['draw_no']) ? trim((string) $validated['draw_no']) : ''; + + $query = TicketItem::query() + ->where('ticket_items.player_id', $player->id) + ->with([ + 'draw:id,draw_no,business_date', + 'order:id,order_no,currency_code,created_at', + ]) + ->orderByDesc('ticket_items.id'); + + if ($drawNo !== '') { + $query->whereHas('draw', fn ($q) => $q->where('draw_no', $drawNo)); + } + + $paginator = $query->paginate(perPage: $perPage, page: $page, columns: ['*']); + + $items = collect($paginator->items())->map(function (TicketItem $row): array { + return [ + 'ticket_no' => $row->ticket_no, + 'order_no' => $row->order?->order_no, + 'draw_no' => $row->draw?->draw_no, + 'currency_code' => $row->order?->currency_code, + 'play_code' => $row->play_code, + 'original_number' => $row->original_number, + 'total_bet_amount' => (int) $row->total_bet_amount, + 'actual_deduct_amount' => (int) $row->actual_deduct_amount, + 'status' => $row->status, + 'fail_reason_code' => $row->fail_reason_code, + 'fail_reason_text' => $row->fail_reason_text, + 'win_amount' => (int) $row->win_amount, + 'jackpot_win_amount' => (int) $row->jackpot_win_amount, + 'placed_at' => $row->order?->created_at?->toIso8601String(), + 'updated_at' => $row->updated_at?->toIso8601String(), + ]; + })->values()->all(); + + return ApiResponse::success([ + 'player_id' => (int) $player->id, + 'items' => $items, + 'total' => $paginator->total(), + 'page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'last_page' => $paginator->lastPage(), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileItemIndexController.php b/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileItemIndexController.php new file mode 100644 index 0000000..ca7a507 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileItemIndexController.php @@ -0,0 +1,44 @@ +integer('per_page', 50), 1), 200); + $page = max((int) $request->integer('page', 1), 1); + + $paginator = $reconcile_job->items() + ->orderBy('id') + ->paginate($perPage, ['*'], 'page', $page); + + return ApiResponse::success([ + 'job_id' => (int) $reconcile_job->id, + 'job_no' => $reconcile_job->job_no, + 'items' => collect($paginator->items())->map(fn (ReconcileItem $r) => [ + 'id' => (int) $r->id, + 'side_a_ref' => $r->side_a_ref, + 'side_b_ref' => $r->side_b_ref, + 'difference_amount' => (int) $r->difference_amount, + 'status' => $r->status, + 'resolved_at' => $r->resolved_at?->toIso8601String(), + 'created_at' => $r->created_at?->toIso8601String(), + ])->all(), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileJobIndexController.php b/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileJobIndexController.php new file mode 100644 index 0000000..1ac6077 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileJobIndexController.php @@ -0,0 +1,54 @@ +integer('per_page', 25), 1), 100); + $page = max((int) $request->integer('page', 1), 1); + $type = trim((string) $request->query('reconcile_type', '')); + + $q = ReconcileJob::query()->orderByDesc('id'); + if ($type !== '') { + $q->where('reconcile_type', $type); + } + + $paginator = $q->paginate($perPage, ['*'], 'page', $page); + + return ApiResponse::success([ + 'items' => collect($paginator->items())->map(fn (ReconcileJob $j) => $this->row($j))->all(), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } + + /** @return array */ + private function row(ReconcileJob $j): array + { + return [ + 'id' => (int) $j->id, + 'job_no' => $j->job_no, + 'admin_user_id' => $j->admin_user_id !== null ? (int) $j->admin_user_id : null, + 'reconcile_type' => $j->reconcile_type, + 'status' => $j->status, + 'period_start' => $j->period_start?->toIso8601String(), + 'period_end' => $j->period_end?->toIso8601String(), + 'summary_json' => $j->summary_json, + 'finished_at' => $j->finished_at?->toIso8601String(), + 'created_at' => $j->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileJobShowController.php b/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileJobShowController.php new file mode 100644 index 0000000..d88e33a --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileJobShowController.php @@ -0,0 +1,28 @@ + (int) $reconcile_job->id, + 'job_no' => $reconcile_job->job_no, + 'admin_user_id' => $reconcile_job->admin_user_id !== null ? (int) $reconcile_job->admin_user_id : null, + 'reconcile_type' => $reconcile_job->reconcile_type, + 'status' => $reconcile_job->status, + 'period_start' => $reconcile_job->period_start?->toIso8601String(), + 'period_end' => $reconcile_job->period_end?->toIso8601String(), + 'summary_json' => $reconcile_job->summary_json, + 'finished_at' => $reconcile_job->finished_at?->toIso8601String(), + 'created_at' => $reconcile_job->created_at?->toIso8601String(), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileJobStoreController.php b/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileJobStoreController.php new file mode 100644 index 0000000..145d076 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileJobStoreController.php @@ -0,0 +1,49 @@ +lotteryAdmin(); + + $data = validator($request->all(), [ + 'reconcile_type' => ['required', 'string', 'max:32'], + 'period_start' => ['nullable', 'date'], + 'period_end' => ['nullable', 'date', 'after_or_equal:period_start'], + 'items' => ['nullable', 'array', 'max:5000'], + 'items.*.side_a_ref' => ['nullable', 'string', 'max:128'], + 'items.*.side_b_ref' => ['nullable', 'string', 'max:128'], + 'items.*.difference_amount' => ['nullable', 'integer'], + 'items.*.status' => ['nullable', 'string', 'max:32'], + ])->validate(); + + $job = $service->createJob( + $admin, + $request, + (string) $data['reconcile_type'], + isset($data['period_start']) ? Carbon::parse((string) $data['period_start']) : null, + isset($data['period_end']) ? Carbon::parse((string) $data['period_end']) : null, + isset($data['items']) ? (array) $data['items'] : null, + ); + + return ApiResponse::success([ + 'id' => (int) $job->id, + 'job_no' => $job->job_no, + 'status' => $job->status, + 'summary_json' => $job->summary_json, + 'item_count' => $job->items()->count(), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobIndexController.php b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobIndexController.php new file mode 100644 index 0000000..29a97f6 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobIndexController.php @@ -0,0 +1,51 @@ +integer('per_page', 25), 1), 100); + $page = max((int) $request->integer('page', 1), 1); + + $paginator = ReportJob::query() + ->orderByDesc('id') + ->paginate($perPage, ['*'], 'page', $page); + + return ApiResponse::success([ + 'items' => collect($paginator->items())->map(fn (ReportJob $j) => $this->row($j))->all(), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } + + /** @return array */ + private function row(ReportJob $j): array + { + return [ + 'id' => (int) $j->id, + 'job_no' => $j->job_no, + 'admin_user_id' => $j->admin_user_id !== null ? (int) $j->admin_user_id : null, + 'report_type' => $j->report_type, + 'export_format' => $j->export_format, + 'filter_json' => $j->filter_json, + 'status' => $j->status, + 'output_path' => $j->output_path, + 'error_message' => $j->error_message, + 'finished_at' => $j->finished_at?->toIso8601String(), + 'created_at' => $j->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobShowController.php b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobShowController.php new file mode 100644 index 0000000..370bcd8 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobShowController.php @@ -0,0 +1,29 @@ + (int) $report_job->id, + 'job_no' => $report_job->job_no, + 'admin_user_id' => $report_job->admin_user_id !== null ? (int) $report_job->admin_user_id : null, + 'report_type' => $report_job->report_type, + 'export_format' => $report_job->export_format, + 'filter_json' => $report_job->filter_json, + 'status' => $report_job->status, + 'output_path' => $report_job->output_path, + 'error_message' => $report_job->error_message, + 'finished_at' => $report_job->finished_at?->toIso8601String(), + 'created_at' => $report_job->created_at?->toIso8601String(), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobStoreController.php b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobStoreController.php new file mode 100644 index 0000000..3f0a064 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobStoreController.php @@ -0,0 +1,41 @@ +lotteryAdmin(); + + $data = validator($request->all(), [ + 'report_type' => ['required', 'string', 'max:64'], + 'export_format' => ['sometimes', 'string', 'in:csv,xlsx'], + 'filter_json' => ['nullable', 'array'], + ])->validate(); + + $job = $service->enqueue( + $admin, + $request, + (string) $data['report_type'], + (string) ($data['export_format'] ?? 'csv'), + isset($data['filter_json']) ? (array) $data['filter_json'] : null, + ); + + return ApiResponse::success([ + 'id' => (int) $job->id, + 'job_no' => $job->job_no, + 'status' => $job->status, + 'output_path' => $job->output_path, + ]); + } +} diff --git a/app/Http/Middleware/EnsureAdminPermission.php b/app/Http/Middleware/EnsureAdminPermission.php new file mode 100644 index 0000000..f08cdf4 --- /dev/null +++ b/app/Http/Middleware/EnsureAdminPermission.php @@ -0,0 +1,48 @@ +lotteryAdmin(); + if (! $admin instanceof AdminUser) { + return ApiResponse::error( + trans('admin.unauthenticated', [], $request->lotteryLocale()), + ErrorCode::AdminUnauthenticated->value, + null, + 401, + ); + } + + $slugs = array_values(array_filter(array_map('trim', explode('|', $permissionSlugs)))); + if ($slugs === []) { + return $next($request); + } + + foreach ($slugs as $slug) { + if ($admin->hasAdminPermission($slug)) { + return $next($request); + } + } + + return ApiResponse::error( + trans('admin.permission_denied', [], $request->lotteryLocale()), + ErrorCode::AdminForbidden->value, + ['required_any' => $slugs], + 403, + ); + } +} diff --git a/app/Lottery/ErrorCode.php b/app/Lottery/ErrorCode.php index f14b244..ead8468 100644 --- a/app/Lottery/ErrorCode.php +++ b/app/Lottery/ErrorCode.php @@ -120,6 +120,9 @@ enum ErrorCode: int /** 登录:账号已禁用 */ case AdminAccountDisabled = 8113; + /** 已登录但无 RBAC 权限 */ + case AdminForbidden = 8114; + /* ========== 9000–9999 系统 / 框架 ========== */ /** 表单或 Query 校验失败(ValidationException → 422) */ diff --git a/app/Models/AdminPermission.php b/app/Models/AdminPermission.php new file mode 100644 index 0000000..17b20e7 --- /dev/null +++ b/app/Models/AdminPermission.php @@ -0,0 +1,27 @@ + */ + public function roles(): BelongsToMany + { + return $this->belongsToMany( + AdminRole::class, + 'admin_role_permissions', + 'permission_id', + 'role_id', + ); + } +} diff --git a/app/Models/AdminRole.php b/app/Models/AdminRole.php new file mode 100644 index 0000000..0c5a5df --- /dev/null +++ b/app/Models/AdminRole.php @@ -0,0 +1,38 @@ + */ + public function permissions(): BelongsToMany + { + return $this->belongsToMany( + AdminPermission::class, + 'admin_role_permissions', + 'role_id', + 'permission_id', + ); + } + + /** @return BelongsToMany */ + public function users(): BelongsToMany + { + return $this->belongsToMany( + AdminUser::class, + 'admin_user_roles', + 'role_id', + 'admin_user_id', + ); + } +} diff --git a/app/Models/AdminUser.php b/app/Models/AdminUser.php index 3c20a01..17d1c4d 100644 --- a/app/Models/AdminUser.php +++ b/app/Models/AdminUser.php @@ -2,6 +2,7 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; @@ -11,6 +12,8 @@ class AdminUser extends Authenticatable use HasApiTokens; use Notifiable; + public const ROLE_SUPER_ADMIN = 'super_admin'; + protected $table = 'admin_users'; protected $fillable = [ @@ -34,4 +37,54 @@ class AdminUser extends Authenticatable 'password' => 'hashed', ]; } + + /** @return BelongsToMany */ + public function roles(): BelongsToMany + { + return $this->belongsToMany( + AdminRole::class, + 'admin_user_roles', + 'admin_user_id', + 'role_id', + ); + } + + /** 是否具备指定权限(含 `super_admin` 角色全放行)。 */ + public function hasAdminPermission(string $slug): bool + { + $this->loadMissing(['roles.permissions']); + + foreach ($this->roles as $role) { + if ($role->slug === self::ROLE_SUPER_ADMIN) { + return true; + } + foreach ($role->permissions as $permission) { + if ($permission->slug === $slug) { + return true; + } + } + } + + return false; + } + + /** + * @return list + */ + public function adminPermissionSlugs(): array + { + $this->loadMissing(['roles.permissions']); + if ($this->roles->contains('slug', self::ROLE_SUPER_ADMIN)) { + return AdminPermission::query()->orderBy('slug')->pluck('slug')->all(); + } + + $out = []; + foreach ($this->roles as $role) { + foreach ($role->permissions as $permission) { + $out[$permission->slug] = true; + } + } + + return array_keys($out); + } } diff --git a/app/Models/ReconcileItem.php b/app/Models/ReconcileItem.php new file mode 100644 index 0000000..790439d --- /dev/null +++ b/app/Models/ReconcileItem.php @@ -0,0 +1,34 @@ + 'integer', + 'resolved_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function reconcileJob(): BelongsTo + { + return $this->belongsTo(ReconcileJob::class, 'reconcile_job_id'); + } +} diff --git a/app/Models/ReconcileJob.php b/app/Models/ReconcileJob.php new file mode 100644 index 0000000..8c71ff8 --- /dev/null +++ b/app/Models/ReconcileJob.php @@ -0,0 +1,45 @@ + 'datetime', + 'period_end' => 'datetime', + 'summary_json' => 'array', + 'finished_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function adminUser(): BelongsTo + { + return $this->belongsTo(AdminUser::class, 'admin_user_id'); + } + + /** @return HasMany */ + public function items(): HasMany + { + return $this->hasMany(ReconcileItem::class, 'reconcile_job_id'); + } +} diff --git a/app/Models/ReportJob.php b/app/Models/ReportJob.php new file mode 100644 index 0000000..7840917 --- /dev/null +++ b/app/Models/ReportJob.php @@ -0,0 +1,37 @@ + 'array', + 'finished_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function adminUser(): BelongsTo + { + return $this->belongsTo(AdminUser::class, 'admin_user_id'); + } +} diff --git a/app/Services/Admin/AdminReconcileJobService.php b/app/Services/Admin/AdminReconcileJobService.php new file mode 100644 index 0000000..b983bb5 --- /dev/null +++ b/app/Services/Admin/AdminReconcileJobService.php @@ -0,0 +1,84 @@ +|null $items + */ + public function createJob( + AdminUser $admin, + Request $request, + string $reconcileType, + ?Carbon $periodStart, + ?Carbon $periodEnd, + ?array $items, + ): ReconcileJob { + return DB::transaction(function () use ($admin, $request, $reconcileType, $periodStart, $periodEnd, $items): ReconcileJob { + $jobNo = 'REC'.now()->format('YmdHis').strtoupper(Str::random(4)); + + $job = ReconcileJob::query()->create([ + 'job_no' => $jobNo, + 'admin_user_id' => (int) $admin->getKey(), + 'reconcile_type' => $reconcileType, + 'status' => 'completed', + 'period_start' => $periodStart, + 'period_end' => $periodEnd, + 'summary_json' => null, + 'finished_at' => now(), + ]); + + $mismatch = 0; + foreach ($items ?? [] as $row) { + ReconcileItem::query()->create([ + 'reconcile_job_id' => (int) $job->getKey(), + 'side_a_ref' => $row['side_a_ref'] ?? null, + 'side_b_ref' => $row['side_b_ref'] ?? null, + 'difference_amount' => (int) ($row['difference_amount'] ?? 0), + 'status' => (string) ($row['status'] ?? 'mismatch'), + 'resolved_at' => null, + ]); + if (($row['status'] ?? 'mismatch') === 'mismatch') { + $mismatch++; + } + } + + $job->forceFill([ + 'summary_json' => [ + 'item_count' => count($items ?? []), + 'mismatch_count' => $mismatch, + ], + ])->save(); + + AuditLogger::recordForAdmin( + $admin, + $request, + 'reconcile_jobs', + 'create', + 'reconcile_job', + (string) $job->getKey(), + null, + [ + 'job_no' => $jobNo, + 'reconcile_type' => $reconcileType, + 'item_count' => count($items ?? []), + ], + ); + + return $job->fresh(); + }); + } +} diff --git a/app/Services/Admin/AdminReportJobService.php b/app/Services/Admin/AdminReportJobService.php new file mode 100644 index 0000000..c5bcf05 --- /dev/null +++ b/app/Services/Admin/AdminReportJobService.php @@ -0,0 +1,55 @@ +|null $filterJson + */ + public function enqueue(AdminUser $admin, Request $request, string $reportType, string $exportFormat, ?array $filterJson): ReportJob + { + return DB::transaction(function () use ($admin, $request, $reportType, $exportFormat, $filterJson): ReportJob { + $jobNo = 'RPT'.now()->format('YmdHis').strtoupper(Str::random(4)); + + $job = ReportJob::query()->create([ + 'job_no' => $jobNo, + 'admin_user_id' => (int) $admin->getKey(), + 'report_type' => $reportType, + 'export_format' => $exportFormat, + 'filter_json' => $filterJson, + 'status' => 'completed', + 'output_path' => 'reports/'.$jobNo.'.'.$exportFormat, + 'error_message' => null, + 'finished_at' => now(), + ]); + + AuditLogger::recordForAdmin( + $admin, + $request, + 'report_jobs', + 'enqueue', + 'report_job', + (string) $job->getKey(), + null, + [ + 'job_no' => $jobNo, + 'report_type' => $reportType, + 'export_format' => $exportFormat, + ], + ); + + return $job; + }); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 852cdc7..1bf6b1c 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -10,6 +10,7 @@ */ use App\Http\Middleware\EnsureAdminApi; +use App\Http\Middleware\EnsureAdminPermission; use App\Http\Middleware\EnsurePlayerApi; use App\Http\Middleware\NegotiateLotteryLocale; use App\Lottery\ErrorCode; @@ -46,6 +47,7 @@ return Application::configure(basePath: dirname(__DIR__)) 'lottery.player' => EnsurePlayerApi::class, // 后台 API 预留:Sanctum / RBAC 'lottery.admin' => EnsureAdminApi::class, + 'admin.permission' => EnsureAdminPermission::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/database/migrations/2026_05_11_120000_add_admin_user_id_to_reconcile_jobs_table.php b/database/migrations/2026_05_11_120000_add_admin_user_id_to_reconcile_jobs_table.php new file mode 100644 index 0000000..6dd376b --- /dev/null +++ b/database/migrations/2026_05_11_120000_add_admin_user_id_to_reconcile_jobs_table.php @@ -0,0 +1,25 @@ +foreignId('admin_user_id') + ->nullable() + ->constrained('admin_users') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('reconcile_jobs', function (Blueprint $table): void { + $table->dropConstrainedForeignId('admin_user_id'); + }); + } +}; diff --git a/database/seeders/AdminRbacAndUserSeeder.php b/database/seeders/AdminRbacAndUserSeeder.php index 1f39853..d66a026 100644 --- a/database/seeders/AdminRbacAndUserSeeder.php +++ b/database/seeders/AdminRbacAndUserSeeder.php @@ -2,53 +2,141 @@ namespace Database\Seeders; +use App\Models\AdminPermission; +use App\Models\AdminRole; use App\Models\AdminUser; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\DB; /** - * 后台角色(super_admin)、若干权限占位;本地演示:账号 **admin** / **123456**(仅限非 production)。 + * 后台 RBAC:与 {@see AdminUser::ROLE_SUPER_ADMIN} 及 PRD 对齐。 + * + * - 角色 slug:`01-产品文档.md` §3 + `04-领域字典与编码规范.md` §11 + * - 权限点 slug:`01-产品文档.md` §8「功能」行 → `prd.{功能键}.{动作}`,路由中间件引用同表 + * + * 演示账号 **admin** / **123456**(仅限非 production)。 */ class AdminRbacAndUserSeeder extends Seeder { + /** @return list */ + private function permissionDefinitions(): array + { + return [ + ['slug' => 'prd.users.manage', 'name' => '§8 用户管理·可管理'], + ['slug' => 'prd.users.view_finance', 'name' => '§8 用户管理·财务查看'], + ['slug' => 'prd.users.view_cs', 'name' => '§8 用户管理·客服单用户'], + + ['slug' => 'prd.play_switch.manage', 'name' => '§8 玩法开关·可管理'], + ['slug' => 'prd.odds.manage', 'name' => '§8 赔率配置·可管理'], + ['slug' => 'prd.risk_cap.manage', 'name' => '§8 封顶配置·可管理'], + ['slug' => 'prd.risk_cap.view', 'name' => '§8 封顶配置·查看'], + ['slug' => 'prd.rebate.manage', 'name' => '§8 佣金/回水·可管理'], + ['slug' => 'prd.rebate.view', 'name' => '§8 佣金/回水·查看'], + ['slug' => 'prd.jackpot.manage', 'name' => '§8 Jackpot 配置·可管理'], + ['slug' => 'prd.jackpot.view', 'name' => '§8 Jackpot 配置·查看'], + + ['slug' => 'prd.draw_result.manage', 'name' => '§8 开奖结果录入·可管理'], + ['slug' => 'prd.draw_result.view', 'name' => '§8 开奖结果·查看'], + ['slug' => 'prd.draw_reopen.manage', 'name' => '§8 开奖结果重开·可管理'], + + ['slug' => 'prd.payout.manage', 'name' => '§8 派彩确认·可管理'], + ['slug' => 'prd.payout.review', 'name' => '§8 派彩确认·可审核'], + ['slug' => 'prd.payout.view', 'name' => '§8 派彩确认·查看'], + + ['slug' => 'prd.wallet_reconcile.manage', 'name' => '§8 钱包对账·可管理'], + ['slug' => 'prd.wallet_reconcile.view', 'name' => '§8 钱包对账·查看'], + ['slug' => 'prd.wallet_reconcile.view_cs', 'name' => '§8 钱包对账·客服单用户'], + + ['slug' => 'prd.wallet_adjust.manage', 'name' => '§8 补单/冲正·可管理'], + + ['slug' => 'prd.report.all', 'name' => '§8 报表·全部'], + ['slug' => 'prd.report.risk', 'name' => '§8 报表·风控'], + ['slug' => 'prd.report.finance', 'name' => '§8 报表·财务'], + ['slug' => 'prd.report.player', 'name' => '§8 报表·单用户'], + + ['slug' => 'prd.audit.all', 'name' => '§8 审计日志·全部'], + ['slug' => 'prd.audit.self', 'name' => '§8 审计日志·自身相关'], + ['slug' => 'prd.audit.finance', 'name' => '§8 审计日志·资金相关'], + + ['slug' => 'prd.player_freeze.manage', 'name' => '§8 冻结/解冻玩家·可管理'], + ]; + } + + /** @param list $slugs */ + private function syncRolePermissions(AdminRole $role, array $slugs): void + { + $ids = AdminPermission::query()->whereIn('slug', $slugs)->pluck('id')->all(); + $role->permissions()->sync($ids); + } + public function run(): void { - $now = now(); + foreach ($this->permissionDefinitions() as $row) { + AdminPermission::query()->updateOrCreate( + ['slug' => $row['slug']], + ['name' => $row['name']], + ); + } - DB::table('admin_roles')->updateOrInsert( - ['slug' => 'super_admin'], - [ - 'name' => 'Super Admin', - 'created_at' => $now, - 'updated_at' => $now, - ], - ); - /** @var int $rid */ - $rid = (int) DB::table('admin_roles')->where('slug', 'super_admin')->value('id'); - - $perms = [ - ['slug' => 'admin.dashboard', 'name' => 'Dashboard'], - ['slug' => 'admin.players.read', 'name' => 'View players'], - ['slug' => 'admin.wallet.read', 'name' => 'View wallets'], + $legacySlugs = [ + 'admin.dashboard', 'admin.players.read', 'admin.wallet.read', 'admin.draws.read', + 'admin.draws.publish', 'admin.settlement.run', 'admin.settlement.read', 'admin.jackpot.read', + 'admin.jackpot.write', 'admin.config.read', 'admin.config.write', 'admin.audit.read', + 'admin.reports.manage', 'admin.reconcile.manage', ]; - foreach ($perms as $p) { - DB::table('admin_permissions')->updateOrInsert( - ['slug' => $p['slug']], - [ - 'name' => $p['name'], - 'created_at' => $now, - 'updated_at' => $now, - ], - ); - } + AdminPermission::query()->whereIn('slug', $legacySlugs)->delete(); - $pidRows = DB::table('admin_permissions')->whereIn('slug', array_column($perms, 'slug'))->pluck('id'); - foreach ($pidRows as $pid) { - DB::table('admin_role_permissions')->updateOrInsert( - ['role_id' => $rid, 'permission_id' => $pid], - [], - ); - } + $super = AdminRole::query()->updateOrCreate( + ['slug' => AdminUser::ROLE_SUPER_ADMIN], + ['name' => '超级管理员'], + ); + $this->syncRolePermissions($super, array_column($this->permissionDefinitions(), 'slug')); + + $risk = AdminRole::query()->updateOrCreate( + ['slug' => 'risk_operator'], + ['name' => '风控运营员'], + ); + $this->syncRolePermissions($risk, [ + 'prd.play_switch.manage', + 'prd.odds.manage', + 'prd.risk_cap.manage', + 'prd.rebate.manage', + 'prd.jackpot.manage', + 'prd.draw_result.manage', + 'prd.payout.review', + 'prd.wallet_reconcile.view', + 'prd.report.risk', + 'prd.audit.self', + 'prd.player_freeze.manage', + ]); + + $finance = AdminRole::query()->updateOrCreate( + ['slug' => 'finance'], + ['name' => '财务/对账员'], + ); + $this->syncRolePermissions($finance, [ + 'prd.users.view_finance', + 'prd.risk_cap.view', + 'prd.rebate.view', + 'prd.jackpot.view', + 'prd.draw_result.view', + 'prd.payout.view', + 'prd.wallet_reconcile.manage', + 'prd.wallet_adjust.manage', + 'prd.report.finance', + 'prd.audit.finance', + ]); + + $cs = AdminRole::query()->updateOrCreate( + ['slug' => 'customer_service'], + ['name' => '客服人员'], + ); + $this->syncRolePermissions($cs, [ + 'prd.users.view_cs', + 'prd.draw_result.view', + 'prd.wallet_reconcile.view_cs', + 'prd.report.player', + ]); $username = 'admin'; AdminUser::query()->updateOrCreate( @@ -56,17 +144,17 @@ class AdminRbacAndUserSeeder extends Seeder [ 'name' => '超级管理员', 'email' => null, - /** 明文;模型 casts `password => hashed`,勿在生产库使用种子弱口令 */ 'password' => '123456', 'status' => 0, ], ); - /** @var int $uid */ - $uid = (int) AdminUser::query()->where('username', $username)->value('id'); - DB::table('admin_user_roles')->updateOrInsert( - ['admin_user_id' => $uid, 'role_id' => $rid], - [], - ); + /** @var AdminUser $admin */ + $admin = AdminUser::query()->where('username', $username)->firstOrFail(); + $admin->roles()->sync([(int) $super->getKey()]); + + DB::table('admin_user_roles')->where('admin_user_id', $admin->id) + ->whereNotIn('role_id', [(int) $super->getKey()]) + ->delete(); } } diff --git a/lang/en/admin.php b/lang/en/admin.php index e475da2..b79d445 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -5,4 +5,5 @@ return [ 'invalid_captcha' => 'Invalid or expired captcha.', 'invalid_credentials' => 'Invalid account or password.', 'account_disabled' => 'This account has been disabled.', + 'permission_denied' => 'You do not have permission to perform this action.', ]; diff --git a/lang/ne/admin.php b/lang/ne/admin.php index 5ad3782..2b94639 100644 --- a/lang/ne/admin.php +++ b/lang/ne/admin.php @@ -5,4 +5,5 @@ return [ 'invalid_captcha' => 'क्याप्चा गलत वा समय सकियो।', 'invalid_credentials' => 'खाता वा पासवर्ड गलत।', 'account_disabled' => 'यो खाता निष्क्रिय गरिएको छ।', + 'permission_denied' => 'यो कार्य गर्न अनुमति छैन।', ]; diff --git a/lang/zh/admin.php b/lang/zh/admin.php index 55b6584..e12cead 100644 --- a/lang/zh/admin.php +++ b/lang/zh/admin.php @@ -5,4 +5,5 @@ return [ 'invalid_captcha' => '验证码错误或已过期,请重试。', 'invalid_credentials' => '账号或密码错误。', 'account_disabled' => '该账号已被禁用。', + 'permission_denied' => '当前账号无此操作权限。', ]; diff --git a/routes/api.php b/routes/api.php index e387022..f7827b5 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,5 +1,6 @@ group(function (): void { ->name('auth.login'); Route::middleware(['auth:sanctum', 'lottery.admin'])->group(function (): void { - // 名称:后台接口连通性探测(需 Bearer Token) + // 名称:后台接口连通性探测(需 Bearer Token;不校验细粒度 RBAC) Route::get('ping', AdminPingController::class)->name('ping'); - // 资金:转账单 / 流水 / 玩家钱包 - Route::get('wallet/transfer-orders', TransferOrderListController::class) - ->name('wallet.transfer-orders'); - Route::get('wallet/transactions', WalletTransactionListController::class) - ->name('wallet.transactions'); - Route::get('players/{player}/wallets', PlayerWalletShowController::class) - ->name('players.wallets'); - // 期号:列表 / 详情 / 批次(开奖结果与审核数据) - Route::get('draws', AdminDrawIndexController::class)->name('draws.index'); - Route::get('draws/{draw}', AdminDrawShowController::class)->name('draws.show'); - Route::get('draws/{draw}/result-batches', AdminDrawResultBatchesIndexController::class) - ->name('draws.result-batches.index'); - // 阶段 5:风险池 / 占用流水 / 售罄监控(后台 §13.4) - Route::get('draws/{draw}/risk-pools/{number_4d}', AdminRiskPoolShowController::class) - ->where('number_4d', '[0-9]{4}') - ->name('draws.risk-pools.show'); - Route::get('draws/{draw}/risk-pool-lock-logs', AdminRiskPoolLockLogIndexController::class) - ->name('draws.risk-pool-lock-logs.index'); - Route::get('draws/{draw}/risk-pools', AdminRiskPoolIndexController::class) - ->name('draws.risk-pools.index'); - // 名称:发布待审核开奖批次(人工审核) - Route::post( - 'draws/{draw}/result-batches/{batch}/publish', - DrawResultBatchPublishController::class, - )->name('draws.result-batches.publish'); - Route::post('draws/{draw}/settlement/run', DrawSettlementRunController::class) - ->name('draws.settlement.run'); - Route::get('settlement-batches', AdminSettlementBatchIndexController::class) - ->name('settlement-batches.index'); - Route::get('settlement-batches/{batch}', AdminSettlementBatchShowController::class) - ->name('settlement-batches.show'); - Route::get('settlement-batches/{batch}/details', AdminSettlementBatchDetailsController::class) - ->name('settlement-batches.details'); + /** §8 钱包对账:超管可管、风控查看、财务可管、客服单用户 */ + Route::middleware('admin.permission:prd.wallet_reconcile.manage|prd.wallet_reconcile.view|prd.wallet_reconcile.view_cs')->group(function (): void { + Route::get('wallet/transfer-orders', TransferOrderListController::class) + ->name('wallet.transfer-orders'); + Route::get('wallet/transactions', WalletTransactionListController::class) + ->name('wallet.transactions'); + }); - Route::get('jackpot/pools', AdminJackpotPoolIndexController::class)->name('jackpot.pools.index'); - Route::put('jackpot/pools/{pool}', AdminJackpotPoolUpdateController::class)->name('jackpot.pools.update'); - Route::get('jackpot/payout-logs', AdminJackpotPayoutLogIndexController::class) - ->name('jackpot.payout-logs.index'); - Route::get('jackpot/contributions', AdminJackpotContributionIndexController::class) - ->name('jackpot.contributions.index'); + /** §8 用户管理:财务查看 / 客服单用户 / 超管可管 */ + Route::middleware('admin.permission:prd.users.manage|prd.users.view_finance|prd.users.view_cs')->group(function (): void { + Route::get('players/{player}/wallets', PlayerWalletShowController::class) + ->name('players.wallets'); + /** §15.4 客服/财务:按玩家查注单 */ + Route::get('players/{player}/ticket-items', AdminPlayerTicketItemsIndexController::class) + ->name('players.ticket-items.index'); + }); - // 阶段 4:玩法目录 + 赔率 + 风控封顶(版本化管理) - Route::get('play-types', PlayTypeIndexController::class)->name('play-types.index'); - Route::patch('play-types/{play_code}', PlayTypePatchController::class) - ->where('play_code', '[a-z0-9_]+') - ->name('play-types.patch'); + /** §8 开奖结果·查看 + 风控占用监控(与开奖/风险域一致) */ + Route::middleware('admin.permission:prd.draw_result.manage|prd.draw_result.view')->group(function (): void { + Route::get('draws', AdminDrawIndexController::class)->name('draws.index'); + Route::get('draws/{draw}', AdminDrawShowController::class)->name('draws.show'); + /** §15.4 单期投注/派彩汇总(与结算批次对照) */ + Route::get('draws/{draw}/finance-summary', AdminDrawFinanceSummaryController::class) + ->name('draws.finance-summary'); + Route::get('draws/{draw}/result-batches', AdminDrawResultBatchesIndexController::class) + ->name('draws.result-batches.index'); + Route::get('draws/{draw}/risk-pools/{number_4d}', AdminRiskPoolShowController::class) + ->where('number_4d', '[0-9]{4}') + ->name('draws.risk-pools.show'); + Route::get('draws/{draw}/risk-pool-lock-logs', AdminRiskPoolLockLogIndexController::class) + ->name('draws.risk-pool-lock-logs.index'); + Route::get('draws/{draw}/risk-pools', AdminRiskPoolIndexController::class) + ->name('draws.risk-pools.index'); + }); - Route::prefix('config')->name('config.')->group(function (): void { - Route::get('play-versions', PlayConfigVersionIndexController::class)->name('play-versions.index'); - Route::post('play-versions', PlayConfigVersionStoreController::class)->name('play-versions.store'); - Route::get('play-versions/{id}', PlayConfigVersionShowController::class) - ->whereNumber('id') - ->name('play-versions.show'); - Route::put('play-versions/{id}/items', PlayConfigItemsReplaceController::class) - ->whereNumber('id') - ->name('play-versions.items.replace'); - Route::post('play-versions/{id}/publish', PlayConfigVersionPublishController::class) - ->whereNumber('id') - ->name('play-versions.publish'); + /** §8 开奖结果录入(发布批次) */ + Route::middleware('admin.permission:prd.draw_result.manage')->group(function (): void { + Route::post( + 'draws/{draw}/result-batches/{batch}/publish', + DrawResultBatchPublishController::class, + )->name('draws.result-batches.publish'); + }); - Route::get('odds-versions', OddsVersionIndexController::class)->name('odds-versions.index'); - Route::post('odds-versions', OddsVersionStoreController::class)->name('odds-versions.store'); - Route::get('odds-versions/{id}', OddsVersionShowController::class) - ->whereNumber('id') - ->name('odds-versions.show'); - Route::put('odds-versions/{id}/items', OddsItemsReplaceController::class) - ->whereNumber('id') - ->name('odds-versions.items.replace'); - Route::post('odds-versions/{id}/publish', OddsVersionPublishController::class) - ->whereNumber('id') - ->name('odds-versions.publish'); + /** §8 派彩确认:超管执行 + 风控审核 */ + Route::middleware('admin.permission:prd.payout.manage|prd.payout.review')->group(function (): void { + Route::post('draws/{draw}/settlement/run', DrawSettlementRunController::class) + ->name('draws.settlement.run'); + }); - Route::get('risk-cap-versions', RiskCapVersionIndexController::class)->name('risk-cap-versions.index'); - Route::post('risk-cap-versions', RiskCapVersionStoreController::class)->name('risk-cap-versions.store'); - Route::get('risk-cap-versions/{id}', RiskCapVersionShowController::class) - ->whereNumber('id') - ->name('risk-cap-versions.show'); - Route::put('risk-cap-versions/{id}/items', RiskCapItemsReplaceController::class) - ->whereNumber('id') - ->name('risk-cap-versions.items.replace'); - Route::post('risk-cap-versions/{id}/publish', RiskCapVersionPublishController::class) - ->whereNumber('id') - ->name('risk-cap-versions.publish'); + Route::middleware('admin.permission:prd.payout.manage|prd.payout.review|prd.payout.view')->group(function (): void { + Route::get('settlement-batches', AdminSettlementBatchIndexController::class) + ->name('settlement-batches.index'); + Route::get('settlement-batches/{batch}', AdminSettlementBatchShowController::class) + ->name('settlement-batches.show'); + Route::get('settlement-batches/{batch}/details', AdminSettlementBatchDetailsController::class) + ->name('settlement-batches.details'); + }); + + Route::middleware('admin.permission:prd.jackpot.manage|prd.jackpot.view')->group(function (): void { + Route::get('jackpot/pools', AdminJackpotPoolIndexController::class)->name('jackpot.pools.index'); + Route::get('jackpot/payout-logs', AdminJackpotPayoutLogIndexController::class) + ->name('jackpot.payout-logs.index'); + Route::get('jackpot/contributions', AdminJackpotContributionIndexController::class) + ->name('jackpot.contributions.index'); + }); + + Route::middleware('admin.permission:prd.jackpot.manage')->group(function (): void { + Route::put('jackpot/pools/{pool}', AdminJackpotPoolUpdateController::class)->name('jackpot.pools.update'); + }); + + /** §8 玩法/玩法版本只读:财务不可(不含 rebate.view) */ + Route::middleware('admin.permission:prd.play_switch.manage|prd.odds.manage')->group(function (): void { + Route::get('play-types', PlayTypeIndexController::class)->name('play-types.index'); + Route::prefix('config')->name('config.')->group(function (): void { + Route::get('play-versions', PlayConfigVersionIndexController::class)->name('play-versions.index'); + Route::get('play-versions/{id}', PlayConfigVersionShowController::class) + ->whereNumber('id') + ->name('play-versions.show'); + }); + }); + + /** §8 赔率/回水只读:财务仅 rebate.view,不可单独看玩法版本 */ + Route::middleware('admin.permission:prd.odds.manage|prd.rebate.manage|prd.rebate.view')->group(function (): void { + Route::prefix('config')->name('config.')->group(function (): void { + Route::get('odds-versions', OddsVersionIndexController::class)->name('odds-versions.index'); + Route::get('odds-versions/{id}', OddsVersionShowController::class) + ->whereNumber('id') + ->name('odds-versions.show'); + }); + }); + + /** §8 封顶只读 */ + Route::middleware('admin.permission:prd.risk_cap.manage|prd.risk_cap.view')->group(function (): void { + Route::prefix('config')->name('config.')->group(function (): void { + Route::get('risk-cap-versions', RiskCapVersionIndexController::class)->name('risk-cap-versions.index'); + Route::get('risk-cap-versions/{id}', RiskCapVersionShowController::class) + ->whereNumber('id') + ->name('risk-cap-versions.show'); + }); + }); + + /** §8 玩法/赔率/封顶/回水/Jackpot 配置写 */ + Route::middleware('admin.permission:prd.play_switch.manage|prd.odds.manage|prd.risk_cap.manage|prd.rebate.manage|prd.jackpot.manage')->group(function (): void { + Route::patch('play-types/{play_code}', PlayTypePatchController::class) + ->where('play_code', '[a-z0-9_]+') + ->name('play-types.patch'); + Route::prefix('config')->name('config.')->group(function (): void { + Route::post('play-versions', PlayConfigVersionStoreController::class)->name('play-versions.store'); + Route::put('play-versions/{id}/items', PlayConfigItemsReplaceController::class) + ->whereNumber('id') + ->name('play-versions.items.replace'); + Route::post('play-versions/{id}/publish', PlayConfigVersionPublishController::class) + ->whereNumber('id') + ->name('play-versions.publish'); + + Route::post('odds-versions', OddsVersionStoreController::class)->name('odds-versions.store'); + Route::put('odds-versions/{id}/items', OddsItemsReplaceController::class) + ->whereNumber('id') + ->name('odds-versions.items.replace'); + Route::post('odds-versions/{id}/publish', OddsVersionPublishController::class) + ->whereNumber('id') + ->name('odds-versions.publish'); + + Route::post('risk-cap-versions', RiskCapVersionStoreController::class)->name('risk-cap-versions.store'); + Route::put('risk-cap-versions/{id}/items', RiskCapItemsReplaceController::class) + ->whereNumber('id') + ->name('risk-cap-versions.items.replace'); + Route::post('risk-cap-versions/{id}/publish', RiskCapVersionPublishController::class) + ->whereNumber('id') + ->name('risk-cap-versions.publish'); + }); + }); + + /** §8 审计日志:超管全部 / 风控自身 / 财务资金;客服无 */ + Route::middleware('admin.permission:prd.audit.all|prd.audit.self|prd.audit.finance')->group(function (): void { + Route::get('audit-logs', AuditLogIndexController::class)->name('audit-logs.index'); + }); + + /** §8 报表 */ + Route::middleware('admin.permission:prd.report.all|prd.report.risk|prd.report.finance|prd.report.player')->group(function (): void { + Route::get('report-jobs', ReportJobIndexController::class)->name('report-jobs.index'); + Route::post('report-jobs', ReportJobStoreController::class)->name('report-jobs.store'); + Route::get('report-jobs/{report_job}', ReportJobShowController::class) + ->name('report-jobs.show'); + }); + + /** §8 钱包对账任务:查看含客服单用户;创建任务仅可管理(超管/财务) */ + Route::middleware('admin.permission:prd.wallet_reconcile.manage|prd.wallet_reconcile.view|prd.wallet_reconcile.view_cs')->group(function (): void { + Route::get('reconcile-jobs', ReconcileJobIndexController::class)->name('reconcile-jobs.index'); + Route::get('reconcile-jobs/{reconcile_job}', ReconcileJobShowController::class) + ->name('reconcile-jobs.show'); + Route::get('reconcile-jobs/{reconcile_job}/items', ReconcileItemIndexController::class) + ->name('reconcile-jobs.items.index'); + }); + Route::middleware('admin.permission:prd.wallet_reconcile.manage')->group(function (): void { + Route::post('reconcile-jobs', ReconcileJobStoreController::class)->name('reconcile-jobs.store'); }); }); }); diff --git a/tests/Feature/AdminAuthLoginTest.php b/tests/Feature/AdminAuthLoginTest.php index 0572238..ba3cd8c 100644 --- a/tests/Feature/AdminAuthLoginTest.php +++ b/tests/Feature/AdminAuthLoginTest.php @@ -37,7 +37,7 @@ test('admin login returns bearer token when captcha passes validation', function ->assertJsonPath('code', ErrorCode::Success->value) ->assertJsonPath('data.admin.username', 'tester') ->assertJsonPath('data.admin.nickname', '测试昵称') - ->assertJsonStructure(['data' => ['token', 'token_type', 'admin' => ['id', 'username', 'nickname', 'email']]]); + ->assertJsonStructure(['data' => ['token', 'token_type', 'admin' => ['id', 'username', 'nickname', 'email', 'permissions']]]); $token = $resp->json('data.token'); expect($token)->not->toBeNull(); diff --git a/tests/Feature/AdminCsFinanceApisTest.php b/tests/Feature/AdminCsFinanceApisTest.php new file mode 100644 index 0000000..072dbc9 --- /dev/null +++ b/tests/Feature/AdminCsFinanceApisTest.php @@ -0,0 +1,195 @@ +create([ + 'username' => 'cs_finance_admin', + 'name' => 'CS Finance QA', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + + return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; +} + +test('admin lists ticket items for a player', function (): void { + $token = mintCsFinanceAdminToken(); + + $player = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'csf-p1', + 'username' => 'csf_u1', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $draw = Draw::query()->create([ + 'draw_no' => '20260520-001', + 'business_date' => '2026-05-20', + 'sequence_no' => 1, + 'status' => 'settled', + 'start_time' => now()->subDay(), + 'close_time' => now()->subDay(), + 'draw_time' => now()->subDay(), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 1, + 'settle_version' => 1, + 'is_reopened' => false, + ]); + + $order = TicketOrder::query()->create([ + 'order_no' => 'ORD-CSF-1', + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'currency_code' => 'NPR', + 'total_bet_amount' => 1000, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 1000, + 'total_estimated_payout' => 0, + 'status' => 'settled', + 'submit_source' => 'h5', + 'client_trace_id' => null, + ]); + + TicketItem::query()->create([ + 'ticket_no' => 'TKCSF0001', + 'order_id' => $order->id, + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'original_number' => '1234', + 'normalized_number' => '1234', + 'play_code' => 'big', + 'dimension' => 4, + 'digit_slot' => null, + 'bet_mode' => null, + 'unit_bet_amount' => 1000, + 'total_bet_amount' => 1000, + 'rebate_rate_snapshot' => 0, + 'commission_rate_snapshot' => 0, + 'actual_deduct_amount' => 1000, + 'odds_snapshot_json' => null, + 'rule_snapshot_json' => null, + 'combination_count' => 1, + 'estimated_max_payout' => 0, + 'risk_locked_amount' => 0, + 'status' => 'settled', + 'fail_reason_code' => null, + 'fail_reason_text' => null, + 'win_amount' => 0, + 'jackpot_win_amount' => 0, + 'settled_at' => now(), + ]); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/players/'.$player->id.'/ticket-items?per_page=10') + ->assertOk() + ->assertJsonPath('data.player_id', $player->id) + ->assertJsonPath('data.total', 1) + ->assertJsonPath('data.items.0.ticket_no', 'TKCSF0001') + ->assertJsonPath('data.items.0.draw_no', '20260520-001'); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/players/'.$player->id.'/ticket-items?draw_no=20260520-001') + ->assertOk() + ->assertJsonPath('data.total', 1); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/players/'.$player->id.'/ticket-items?draw_no=20991231-999') + ->assertOk() + ->assertJsonPath('data.total', 0); +}); + +test('admin draw finance summary aggregates bet and payout', function (): void { + $token = mintCsFinanceAdminToken(); + + $player = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'csf-p2', + 'username' => 'csf_u2', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $draw = Draw::query()->create([ + 'draw_no' => '20260520-002', + 'business_date' => '2026-05-20', + 'sequence_no' => 2, + 'status' => 'settled', + 'start_time' => now()->subDay(), + 'close_time' => now()->subDay(), + 'draw_time' => now()->subDay(), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 1, + 'settle_version' => 1, + 'is_reopened' => false, + ]); + + $order = TicketOrder::query()->create([ + 'order_no' => 'ORD-CSF-2', + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'currency_code' => 'NPR', + 'total_bet_amount' => 5000, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 5000, + 'total_estimated_payout' => 0, + 'status' => 'settled', + 'submit_source' => 'h5', + 'client_trace_id' => null, + ]); + + TicketItem::query()->create([ + 'ticket_no' => 'TKCSF0002', + 'order_id' => $order->id, + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'original_number' => '5678', + 'normalized_number' => '5678', + 'play_code' => 'big', + 'dimension' => 4, + 'digit_slot' => null, + 'bet_mode' => null, + 'unit_bet_amount' => 5000, + 'total_bet_amount' => 5000, + 'rebate_rate_snapshot' => 0, + 'commission_rate_snapshot' => 0, + 'actual_deduct_amount' => 5000, + 'odds_snapshot_json' => null, + 'rule_snapshot_json' => null, + 'combination_count' => 1, + 'estimated_max_payout' => 0, + 'risk_locked_amount' => 0, + 'status' => 'settled', + 'fail_reason_code' => null, + 'fail_reason_text' => null, + 'win_amount' => 2000, + 'jackpot_win_amount' => 500, + 'settled_at' => now(), + ]); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/draws/'.$draw->id.'/finance-summary') + ->assertOk() + ->assertJsonPath('data.draw_no', '20260520-002') + ->assertJsonPath('data.total_bet_minor', 5000) + ->assertJsonPath('data.total_win_payout_minor', 2000) + ->assertJsonPath('data.total_jackpot_win_minor', 500) + ->assertJsonPath('data.total_payout_minor', 2500) + ->assertJsonPath('data.approx_house_gross_minor', 2500); +}); diff --git a/tests/Feature/AdminDrawApiTest.php b/tests/Feature/AdminDrawApiTest.php index 32df22a..a91e745 100644 --- a/tests/Feature/AdminDrawApiTest.php +++ b/tests/Feature/AdminDrawApiTest.php @@ -20,6 +20,7 @@ function mintAdminBearer(): string 'password' => Hash::make('secret-strong'), 'status' => 0, ]); + grantSuperAdminRole($admin); return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; } diff --git a/tests/Feature/AdminPhase15OperationsTest.php b/tests/Feature/AdminPhase15OperationsTest.php new file mode 100644 index 0000000..d2c0e36 --- /dev/null +++ b/tests/Feature/AdminPhase15OperationsTest.php @@ -0,0 +1,115 @@ +create([ + 'username' => 'phase15_super', + 'name' => 'Phase15', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + + return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; +} + +test('report job create list show and audit log index work for super admin', function (): void { + AuditLogger::record('system', 0, 'bootstrap', 'test', null, null, null, null); + + $token = phase15SuperToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/audit-logs?per_page=5') + ->assertOk() + ->assertJsonPath('code', ErrorCode::Success->value); + + $create = $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/report-jobs', [ + 'report_type' => 'wallet_txns_daily', + 'export_format' => 'csv', + 'filter_json' => ['currency_code' => 'NPR'], + ]); + $create->assertOk()->assertJsonPath('code', ErrorCode::Success->value); + $id = (int) $create->json('data.id'); + expect($id)->toBeGreaterThan(0); + expect(ReportJob::query()->whereKey($id)->exists())->toBeTrue(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/report-jobs/'.$id) + ->assertOk() + ->assertJsonPath('data.report_type', 'wallet_txns_daily'); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/report-jobs?per_page=10') + ->assertOk() + ->assertJsonPath('code', ErrorCode::Success->value); + + expect(AuditLog::query()->where('module_code', 'report_jobs')->exists())->toBeTrue(); +}); + +test('reconcile job create with items and nested items index', function (): void { + $token = phase15SuperToken(); + + $resp = $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/reconcile-jobs', [ + 'reconcile_type' => 'wallet_transfer', + 'period_start' => '2026-05-01T00:00:00Z', + 'period_end' => '2026-05-02T00:00:00Z', + 'items' => [ + ['side_a_ref' => 'TO-1', 'side_b_ref' => 'MAIN-1', 'difference_amount' => 100, 'status' => 'mismatch'], + ['side_a_ref' => 'TO-2', 'side_b_ref' => 'MAIN-2', 'difference_amount' => 0, 'status' => 'matched'], + ], + ]); + $resp->assertOk(); + $id = (int) $resp->json('data.id'); + expect($id)->toBeGreaterThan(0); + + $job = ReconcileJob::query()->whereKey($id)->firstOrFail(); + expect((int) $job->admin_user_id)->toBeGreaterThan(0); + expect($job->items()->count())->toBe(2); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/reconcile-jobs/'.$id.'/items') + ->assertOk() + ->assertJsonPath('data.meta.total', 2); +}); + +test('admin without report permission receives 403 on report-jobs', function (): void { + $role = AdminRole::query()->create(['slug' => 'auditor_test', 'name' => 'Auditor Test']); + $perm = AdminPermission::query()->create(['slug' => 'prd.audit.finance', 'name' => '§8 审计日志·资金相关']); + $role->permissions()->sync([(int) $perm->getKey()]); + + $user = AdminUser::query()->create([ + 'username' => 'auditor_only', + 'name' => 'Auditor', + 'email' => null, + 'password' => Hash::make('pw-audit'), + 'status' => 0, + ]); + $user->roles()->sync([(int) $role->getKey()]); + + $token = $user->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/audit-logs') + ->assertOk(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/report-jobs', ['report_type' => 'x']) + ->assertStatus(403) + ->assertJsonPath('code', ErrorCode::AdminForbidden->value); +}); diff --git a/tests/Feature/AdminRiskPoolApiTest.php b/tests/Feature/AdminRiskPoolApiTest.php index a52bf6c..c669879 100644 --- a/tests/Feature/AdminRiskPoolApiTest.php +++ b/tests/Feature/AdminRiskPoolApiTest.php @@ -18,6 +18,7 @@ function mintRiskAdminToken(): string 'password' => Hash::make('secret-strong'), 'status' => 0, ]); + grantSuperAdminRole($admin); return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; } diff --git a/tests/Feature/AdminSettlementJackpotApiTest.php b/tests/Feature/AdminSettlementJackpotApiTest.php index 939e870..b8ce2c8 100644 --- a/tests/Feature/AdminSettlementJackpotApiTest.php +++ b/tests/Feature/AdminSettlementJackpotApiTest.php @@ -16,6 +16,7 @@ function mintSettlementAdminToken(): string 'password' => Hash::make('secret-strong'), 'status' => 0, ]); + grantSuperAdminRole($admin); return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; } diff --git a/tests/Feature/AdminWalletApiTest.php b/tests/Feature/AdminWalletApiTest.php index feacfa5..cb06369 100644 --- a/tests/Feature/AdminWalletApiTest.php +++ b/tests/Feature/AdminWalletApiTest.php @@ -20,6 +20,7 @@ function makeAdminToken(): string 'password' => Hash::make('secret-strong'), 'status' => 0, ]); + grantSuperAdminRole($admin); return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; } diff --git a/tests/Feature/DrawPipelineTest.php b/tests/Feature/DrawPipelineTest.php index cd622ba..e46dcc3 100644 --- a/tests/Feature/DrawPipelineTest.php +++ b/tests/Feature/DrawPipelineTest.php @@ -150,6 +150,7 @@ test('draw tick rng awaits manual publish when review enabled', function (): voi 'password' => Hash::make('secret-strong'), 'status' => 0, ]); + grantSuperAdminRole($admin); $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; $this->withHeader('Authorization', 'Bearer '.$token) diff --git a/tests/Feature/OperationalConfigAcceptanceTest.php b/tests/Feature/OperationalConfigAcceptanceTest.php index 48c16f4..0438c07 100644 --- a/tests/Feature/OperationalConfigAcceptanceTest.php +++ b/tests/Feature/OperationalConfigAcceptanceTest.php @@ -43,6 +43,7 @@ function acceptanceMintAdminToken(): string 'password' => Hash::make('secret-strong'), 'status' => 0, ]); + grantSuperAdminRole($admin); return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; } diff --git a/tests/Feature/OperationalConfigApiTest.php b/tests/Feature/OperationalConfigApiTest.php index 87eafb3..6ca8bd3 100644 --- a/tests/Feature/OperationalConfigApiTest.php +++ b/tests/Feature/OperationalConfigApiTest.php @@ -27,6 +27,7 @@ function mintConfigAdminToken(): string 'password' => Hash::make('secret-strong'), 'status' => 0, ]); + grantSuperAdminRole($admin); return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; } diff --git a/tests/Pest.php b/tests/Pest.php index 2c5012c..2afd163 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,6 +1,8 @@ extend('toBeOne', function () { | */ -function something() +/** 为后台测试账号挂上 `super_admin` 角色(细粒度权限校验全放行)。 */ +function grantSuperAdminRole(AdminUser $admin): void { - // .. + $now = now(); + DB::table('admin_roles')->updateOrInsert( + ['slug' => AdminUser::ROLE_SUPER_ADMIN], + ['name' => 'Super Admin', 'created_at' => $now, 'updated_at' => $now], + ); + $rid = (int) DB::table('admin_roles')->where('slug', AdminUser::ROLE_SUPER_ADMIN)->value('id'); + if (! DB::table('admin_user_roles')->where('admin_user_id', $admin->id)->where('role_id', $rid)->exists()) { + DB::table('admin_user_roles')->insert([ + 'admin_user_id' => $admin->id, + 'role_id' => $rid, + ]); + } }