From e3ffffad9c08c5f36ad05b5e6e88483b3dd161a4 Mon Sep 17 00:00:00 2001 From: kang Date: Thu, 4 Jun 2026 09:17:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E5=92=8C=E7=8E=A9=E5=AE=B6=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 SyncAdminAuthorizationCommand 中新增对代理线路和结算菜单操作的同步功能,确保缺失的菜单操作行能够被创建。 - 更新多个控制器中的权限检查逻辑,使用 hasPermissionCode 替代原有的权限验证方式,提升权限管理的灵活性。 - 在 AdminPlayerStoreController 中引入对玩家创建能力的验证,确保只有具备相应权限的管理员能够创建玩家。 - 更新请求验证逻辑,新增 credit_limit、rebate_rate 和 extra_rebate_rate 字段,以支持更细粒度的玩家管理。 - 在 AdminUser 和 AgentNode 模型中增强角色与用户的权限管理功能,支持更细粒度的权限控制。 --- .../Commands/AuditAgentLineDataCommand.php | 112 ++++++ .../SettlementAgentPeriodCloseCommand.php | 22 ++ .../SyncAdminAuthorizationCommand.php | 6 + .../Admin/Agent/AgentLineShowController.php | 43 +++ .../Admin/Agent/AgentLineStoreController.php | 47 +++ .../Agent/AgentNodeDestroyController.php | 8 +- .../Agent/AgentNodeProfileController.php | 49 +++ .../AgentSettlementBillConfirmController.php | 61 ++++ .../AgentSettlementBillIndexController.php | 31 ++ .../AgentSettlementPeriodCloseController.php | 43 +++ .../AgentSettlementPeriodStoreController.php | 52 +++ .../AdminIntegrationSiteStoreController.php | 15 + .../Player/AdminPlayerStoreController.php | 51 ++- .../User/AdminPermissionCatalogController.php | 1 + .../Admin/User/AdminRoleDestroyController.php | 3 + .../AdminRolePermissionSyncController.php | 3 + .../Admin/User/AdminRoleUpdateController.php | 3 + .../Admin/User/AdminUserDestroyController.php | 2 + .../Admin/User/AdminUserIndexController.php | 6 + .../AdminUserPermissionSyncController.php | 13 +- .../User/AdminUserRoleSyncController.php | 15 +- .../V1/Admin/User/AdminUserShowController.php | 3 + .../Admin/User/AdminUserStoreController.php | 2 +- .../Admin/User/AdminUserUpdateController.php | 2 + .../EnsureAdminApiResourcePermission.php | 4 + .../Admin/AdminAgentLineStoreRequest.php | 41 +++ .../Admin/AdminAgentProfileUpdateRequest.php | 26 ++ .../Admin/AdminPlayerStoreRequest.php | 3 + .../AdminSettlementPeriodStoreRequest.php | 23 ++ .../Requests/Admin/AgentNodeStoreRequest.php | 9 +- .../Requests/Admin/AgentNodeUpdateRequest.php | 3 + .../Admin/Concerns/AgentProfileFieldRules.php | 21 ++ app/Models/AdminUser.php | 60 ++++ app/Models/AgentProfile.php | 45 +++ app/Services/Agent/AgentNodeService.php | 327 +++++++++++++++++- app/Services/Agent/AgentProfileService.php | 187 ++++++++++ .../Agent/AgentSiteProvisioningService.php | 147 ++++++++ .../Agent/CreditAllocationValidator.php | 39 +++ app/Services/Agent/RebateLimitValidator.php | 31 ++ app/Services/Agent/ShareRateValidator.php | 32 ++ .../AgentSettlementPeriodCloseService.php | 66 ++++ .../ShareSettlementCalculator.php | 111 ++++++ .../AgentSettlement/ShareSettlementResult.php | 19 + app/Services/Player/PlayerCreditService.php | 100 ++++++ .../Ticket/TicketPlacementService.php | 24 ++ app/Support/AdminAccountScopeGuard.php | 28 ++ ...LineSettlementPermissionMenuActionSync.php | 99 ++++++ app/Support/AdminAgentScope.php | 2 +- app/Support/AdminAgentSettlementScope.php | 76 ++++ app/Support/AdminAuthProfile.php | 25 +- app/Support/AdminAuthorizationRegistry.php | 38 +- app/Support/AdminSiteScope.php | 15 +- app/Support/AdminUserApiPresenter.php | 1 + app/Support/AgentLinePresenter.php | 30 ++ app/Support/AgentNodePresenter.php | 20 +- app/Support/CreditLineMode.php | 25 ++ app/Support/Settlement/DesignDocExample12.php | 72 ++++ ...26_06_03_150000_align_root_agent_codes.php | 60 ++++ ...000_agent_credit_and_settlement_tables.php | 140 ++++++++ ...00_seed_agent_settlement_api_resources.php | 151 ++++++++ ...000_add_agent_profile_capability_flags.php | 33 ++ lang/en/admin.php | 7 + lang/ne/admin.php | 7 + lang/zh/admin.php | 8 + routes/api.php | 1 + routes/api/v1/admin/agent-settlement.php | 19 + routes/api/v1/admin/agent.php | 11 + tests/Feature/AdminAgentLineApiTest.php | 105 ++++++ tests/Feature/AdminAgentNodeApiTest.php | 10 +- .../AdminAgentSettlementBillApiTest.php | 33 ++ tests/Feature/AdminPlayerManageApiTest.php | 41 +++ tests/Feature/AdminUserPermissionApiTest.php | 111 +++++- .../AgentCreditSettlementExampleTest.php | 44 +++ tests/Pest.php | 18 + 74 files changed, 3076 insertions(+), 65 deletions(-) create mode 100644 app/Console/Commands/AuditAgentLineDataCommand.php create mode 100644 app/Console/Commands/SettlementAgentPeriodCloseCommand.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Agent/AgentLineShowController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Agent/AgentLineStoreController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeProfileController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillConfirmController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillIndexController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPeriodCloseController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPeriodStoreController.php create mode 100644 app/Http/Requests/Admin/AdminAgentLineStoreRequest.php create mode 100644 app/Http/Requests/Admin/AdminAgentProfileUpdateRequest.php create mode 100644 app/Http/Requests/Admin/AdminSettlementPeriodStoreRequest.php create mode 100644 app/Http/Requests/Admin/Concerns/AgentProfileFieldRules.php create mode 100644 app/Models/AgentProfile.php create mode 100644 app/Services/Agent/AgentProfileService.php create mode 100644 app/Services/Agent/AgentSiteProvisioningService.php create mode 100644 app/Services/Agent/CreditAllocationValidator.php create mode 100644 app/Services/Agent/RebateLimitValidator.php create mode 100644 app/Services/Agent/ShareRateValidator.php create mode 100644 app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php create mode 100644 app/Services/AgentSettlement/ShareSettlementCalculator.php create mode 100644 app/Services/AgentSettlement/ShareSettlementResult.php create mode 100644 app/Services/Player/PlayerCreditService.php create mode 100644 app/Support/AdminAccountScopeGuard.php create mode 100644 app/Support/AdminAgentLineSettlementPermissionMenuActionSync.php create mode 100644 app/Support/AdminAgentSettlementScope.php create mode 100644 app/Support/AgentLinePresenter.php create mode 100644 app/Support/CreditLineMode.php create mode 100644 app/Support/Settlement/DesignDocExample12.php create mode 100644 database/migrations/2026_06_03_150000_align_root_agent_codes.php create mode 100644 database/migrations/2026_06_03_160000_agent_credit_and_settlement_tables.php create mode 100644 database/migrations/2026_06_03_170000_seed_agent_settlement_api_resources.php create mode 100644 database/migrations/2026_06_03_180000_add_agent_profile_capability_flags.php create mode 100644 routes/api/v1/admin/agent-settlement.php create mode 100644 tests/Feature/AdminAgentLineApiTest.php create mode 100644 tests/Feature/AdminAgentSettlementBillApiTest.php create mode 100644 tests/Feature/AgentCreditSettlementExampleTest.php diff --git a/app/Console/Commands/AuditAgentLineDataCommand.php b/app/Console/Commands/AuditAgentLineDataCommand.php new file mode 100644 index 0000000..38bb6e9 --- /dev/null +++ b/app/Console/Commands/AuditAgentLineDataCommand.php @@ -0,0 +1,112 @@ +orderBy('id')->get(['id', 'code', 'name']); + $issues = []; + + foreach ($sites as $site) { + $siteId = (int) $site->id; + $roots = DB::table('agent_nodes') + ->where('admin_site_id', $siteId) + ->where('depth', 0) + ->get(['id', 'code', 'name']); + + if ($roots->isEmpty()) { + $issues[] = [ + 'type' => 'site_without_root', + 'admin_site_id' => $siteId, + 'site_code' => (string) $site->code, + 'message' => '站点无 depth=0 根代理节点', + ]; + } elseif ($roots->count() > 1) { + $issues[] = [ + 'type' => 'site_multiple_roots', + 'admin_site_id' => $siteId, + 'site_code' => (string) $site->code, + 'root_ids' => $roots->pluck('id')->all(), + 'message' => '站点存在多个根节点', + ]; + } else { + $root = $roots->first(); + $expectedCode = (string) $site->code; + $rootCode = (string) $root->code; + if ($rootCode !== $expectedCode && $rootCode !== 'root-'.$expectedCode) { + $issues[] = [ + 'type' => 'root_code_mismatch', + 'admin_site_id' => $siteId, + 'site_code' => $expectedCode, + 'root_code' => $rootCode, + 'message' => '根节点 code 与 site.code 不一致', + ]; + } + + $loginCount = (int) DB::table('admin_user_agents') + ->where('agent_node_id', (int) $root->id) + ->count(); + if ($loginCount === 0) { + $issues[] = [ + 'type' => 'root_without_login', + 'admin_site_id' => $siteId, + 'agent_node_id' => (int) $root->id, + 'message' => '根代理无绑定后台账号', + ]; + } + } + + $businessAgents = DB::table('agent_nodes') + ->where('admin_site_id', $siteId) + ->where('depth', '>', 0) + ->count(); + if ($businessAgents > 0 && $roots->count() === 1) { + // informational only for multi-agent-under-one-site report + } + } + + $sitesWithManyBusinessAgents = DB::table('agent_nodes') + ->select('admin_site_id', DB::raw('count(*) as cnt')) + ->where('depth', '>', 0) + ->groupBy('admin_site_id') + ->having('cnt', '>', 0) + ->get(); + + foreach ($sitesWithManyBusinessAgents as $row) { + $siteId = (int) $row->admin_site_id; + $siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code'); + $rootCount = DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->count(); + if ($rootCount === 1 && (int) $row->cnt >= 1) { + $issues[] = [ + 'type' => 'site_has_business_agents', + 'admin_site_id' => $siteId, + 'site_code' => $siteCode, + 'business_agent_count' => (int) $row->cnt, + 'message' => '站点下存在下级业务代理(需确认是否应拆站)', + ]; + } + } + + if ($this->option('json')) { + $this->line(json_encode(['issues' => $issues], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); + + return self::SUCCESS; + } + + $this->info('Agent line audit: '.count($issues).' issue(s)'); + foreach ($issues as $issue) { + $this->line('- ['.$issue['type'].'] '.$issue['message'].' (site: '.($issue['site_code'] ?? $issue['admin_site_id'] ?? '?').')'); + } + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SettlementAgentPeriodCloseCommand.php b/app/Console/Commands/SettlementAgentPeriodCloseCommand.php new file mode 100644 index 0000000..7900350 --- /dev/null +++ b/app/Console/Commands/SettlementAgentPeriodCloseCommand.php @@ -0,0 +1,22 @@ +argument('period'); + $result = $service->closePeriod($periodId); + $this->info(json_encode($result, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SyncAdminAuthorizationCommand.php b/app/Console/Commands/SyncAdminAuthorizationCommand.php index 346eb2c..fb90a7e 100644 --- a/app/Console/Commands/SyncAdminAuthorizationCommand.php +++ b/app/Console/Commands/SyncAdminAuthorizationCommand.php @@ -5,6 +5,7 @@ namespace App\Console\Commands; use Illuminate\Support\Carbon; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; +use App\Support\AdminAgentLineSettlementPermissionMenuActionSync; use App\Support\AdminAgentPermissionMenuActionSync; use App\Support\AdminAuthorizationRegistry; use App\Support\AdminDrawPermissionMenuActionSync; @@ -24,6 +25,11 @@ final class SyncAdminAuthorizationCommand extends Command $this->info(sprintf('Created %d missing agent menu_action row(s).', $agentCreated)); } + $lineSettlementCreated = AdminAgentLineSettlementPermissionMenuActionSync::syncMissing(); + if ($lineSettlementCreated > 0) { + $this->info(sprintf('Created %d missing agent line/settlement menu_action row(s).', $lineSettlementCreated)); + } + $drawCreated = AdminDrawPermissionMenuActionSync::syncMissing(); if ($drawCreated > 0) { $this->info(sprintf('Created %d missing draw menu_action row(s).', $drawCreated)); diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentLineShowController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentLineShowController.php new file mode 100644 index 0000000..b26e388 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentLineShowController.php @@ -0,0 +1,43 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + if (! AdminSiteScope::siteIdAllowed($admin, (int) $admin_site->id)) { + abort(403); + } + + $root = AgentNode::query() + ->where('admin_site_id', $admin_site->id) + ->where('depth', 0) + ->firstOrFail(); + + return ApiResponse::success([ + 'site' => AdminIntegrationSitePresenter::detail($admin_site), + 'agent_node' => AgentNodePresenter::item($root), + 'line_root' => [ + 'agent_node_id' => (int) $root->id, + 'site_code' => (string) $admin_site->code, + 'is_line_root' => true, + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentLineStoreController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentLineStoreController.php new file mode 100644 index 0000000..bb0746d --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentLineStoreController.php @@ -0,0 +1,47 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + $result = $service->createRootAgent($admin, $request->validated()); + $site = $result['site']; + $node = $result['agent_node']; + + $payload = AgentLinePresenter::provisioned($site, $node, $result['secrets']); + + AuditLogger::recordForAdmin( + $admin, + $request, + moduleCode: 'agent', + actionCode: 'agent_line.provision', + targetType: 'admin_site', + targetId: (string) $site->id, + afterJson: [ + 'site' => AdminIntegrationSitePresenter::detail($site), + 'agent_node_id' => (int) $node->id, + ], + ); + $request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true); + + return ApiResponse::success($payload)->setStatusCode(201); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeDestroyController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeDestroyController.php index a26d619..23de8db 100644 --- a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeDestroyController.php +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeDestroyController.php @@ -43,12 +43,8 @@ final class AgentNodeDestroyController extends Controller return ApiMessage::errorResponse($request, 'admin.agent_node_has_children_cannot_delete', ErrorCode::ValidationFailed->value, null, 422); } - if (DB::table('admin_user_agents')->where('agent_node_id', (int) $agent_node->id)->exists()) { - return ApiMessage::errorResponse($request, 'admin.agent_node_has_users_cannot_delete', ErrorCode::ValidationFailed->value, null, 422); - } - - if ($service->hasBlockingCustomRoles($agent_node)) { - return ApiMessage::errorResponse($request, 'admin.agent_node_has_roles_cannot_delete', ErrorCode::ValidationFailed->value, null, 422); + if (DB::table('players')->where('agent_node_id', $agent_node->id)->exists()) { + return ApiMessage::errorResponse($request, 'admin.agent_node_has_players_cannot_delete', ErrorCode::ValidationFailed->value, null, 422); } $before = AgentNodePresenter::item($agent_node); diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeProfileController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeProfileController.php new file mode 100644 index 0000000..d0e9e3d --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeProfileController.php @@ -0,0 +1,49 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + abort_if(! AdminAgentScope::nodeVisibleTo($admin, $agent_node), 403); + + $profile = AgentProfile::query()->firstOrNew(['agent_node_id' => $agent_node->id]); + + return ApiResponse::success(app(AgentProfileService::class)->present($profile)); + } + + public function update( + AdminAgentProfileUpdateRequest $request, + AgentNode $agent_node, + AgentProfileService $service, + AgentNodeService $agentNodeService, + ): JsonResponse { + $admin = $request->lotteryAdmin(); + abort_if($admin === null, 401); + abort_if(! AdminAgentScope::nodeVisibleTo($admin, $agent_node), 403); + + $parent = $agent_node->parent_id !== null + ? AgentNode::query()->find($agent_node->parent_id) + : null; + + $profile = $service->upsertForNode($agent_node, $request->validated(), $parent); + $agentNodeService->syncPrimaryOwnerRoleFromProfile($agent_node, $profile); + + return ApiResponse::success($service->present($profile)); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillConfirmController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillConfirmController.php new file mode 100644 index 0000000..71493a1 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillConfirmController.php @@ -0,0 +1,61 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404); + + $bill = DB::table('settlement_bills')->where('id', $settlement_bill)->first(); + abort_if($bill === null, 404); + + $unpaid = (int) $bill->unpaid_amount; + DB::table('settlement_bills')->where('id', $settlement_bill)->update([ + 'paid_amount' => (int) $bill->paid_amount + $unpaid, + 'unpaid_amount' => 0, + 'status' => 'confirmed', + 'confirmed_at' => now(), + 'updated_at' => now(), + ]); + + if ($bill->owner_type === 'player' && (int) $bill->owner_id > 0) { + $player = Player::query()->find((int) $bill->owner_id); + if ($player !== null) { + $creditService->releaseFromSettlement($player, $unpaid, $settlement_bill); + } + } + + AuditLogger::recordForAdmin( + $admin, + $request, + moduleCode: 'settlement', + actionCode: 'settlement_bill.confirm', + targetType: 'settlement_bill', + targetId: (string) $settlement_bill, + beforeJson: ['status' => (string) $bill->status, 'unpaid_amount' => $unpaid], + afterJson: ['status' => 'confirmed', 'paid_amount' => (int) $bill->paid_amount + $unpaid], + ); + $request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true); + + return ApiResponse::success(['bill_id' => $settlement_bill, 'status' => 'confirmed']); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillIndexController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillIndexController.php new file mode 100644 index 0000000..0055049 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillIndexController.php @@ -0,0 +1,31 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + $periodId = (int) $request->query('settlement_period_id', 0); + $query = DB::table('settlement_bills')->orderByDesc('id'); + if ($periodId > 0) { + $query->where('settlement_period_id', $periodId); + } + + AdminAgentSettlementScope::applyToBillsQuery($query, $admin); + + return ApiResponse::success([ + 'items' => $query->limit(100)->get(), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPeriodCloseController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPeriodCloseController.php new file mode 100644 index 0000000..ff5dbb6 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPeriodCloseController.php @@ -0,0 +1,43 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + abort_if(! AdminAgentSettlementScope::periodAccessible($admin, $settlement_period), 404); + + $before = DB::table('settlement_periods')->where('id', $settlement_period)->first(); + $result = $service->closePeriod($settlement_period); + + AuditLogger::recordForAdmin( + $admin, + $request, + moduleCode: 'settlement', + actionCode: 'settlement_period.close', + targetType: 'settlement_period', + targetId: (string) $settlement_period, + beforeJson: $before !== null ? (array) $before : null, + afterJson: $result, + ); + $request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true); + + return ApiResponse::success($result); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPeriodStoreController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPeriodStoreController.php new file mode 100644 index 0000000..374413f --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPeriodStoreController.php @@ -0,0 +1,52 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + $data = $request->validated(); + abort_if( + ! AdminAgentSettlementScope::siteAccessible($admin, (int) $data['admin_site_id']), + 404, + ); + + $id = DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => (int) $data['admin_site_id'], + 'period_start' => $data['period_start'], + 'period_end' => $data['period_end'], + 'status' => 'open', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $row = DB::table('settlement_periods')->where('id', $id)->first(); + + AuditLogger::recordForAdmin( + $admin, + $request, + moduleCode: 'settlement', + actionCode: 'settlement_period.store', + targetType: 'settlement_period', + targetId: (string) $id, + beforeJson: null, + afterJson: (array) $row, + ); + $request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true); + + return ApiResponse::success((array) $row)->setStatusCode(201); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteStoreController.php b/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteStoreController.php index f106a65..362b414 100644 --- a/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteStoreController.php @@ -11,6 +11,8 @@ use App\Services\Integration\IntegrationSiteService; use App\Support\AdminIntegrationSitePresenter; use App\Http\Requests\Admin\AdminIntegrationSiteStoreRequest; use App\Http\Middleware\RecordAdminApiAudit; +use App\Lottery\ErrorCode; +use App\Support\ApiMessage; final class AdminIntegrationSiteStoreController extends Controller { @@ -21,6 +23,19 @@ final class AdminIntegrationSiteStoreController extends Controller $admin = $request->lotteryAdmin(); abort_if($admin === null, 401); + if (! $admin->isSuperAdmin()) { + return ApiMessage::errorResponse( + $request, + 'admin.integration_site_store_deprecated', + ErrorCode::AdminForbidden->value, + ['hint' => 'Use POST /api/v1/admin/agent-lines to provision a new agent line.'], + 403, + )->withHeaders([ + 'Deprecation' => 'true', + 'Link' => '; rel="successor-version"', + ]); + } + $result = $service->create($request->validated()); $site = $result['site']; diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php index bee81d5..23acbc8 100644 --- a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php @@ -12,15 +12,35 @@ use App\Support\AdminSiteScope; use App\Support\PlayerApiPresenter; use App\Http\Controllers\Controller; use App\Http\Requests\Admin\AdminPlayerStoreRequest; +use App\Models\AgentNode; +use App\Services\Agent\AgentProfileService; +use App\Services\Agent\RebateLimitValidator; +use App\Services\Player\PlayerCreditService; /** POST /api/v1/admin/players */ final class AdminPlayerStoreController extends Controller { - public function __invoke(AdminPlayerStoreRequest $request): JsonResponse - { + public function __invoke( + AdminPlayerStoreRequest $request, + PlayerCreditService $playerCreditService, + RebateLimitValidator $rebateLimitValidator, + AgentProfileService $agentProfileService, + ): JsonResponse { $admin = $request->lotteryAdmin(); abort_if($admin === null, 401); + try { + $agentProfileService->assertActorMayCreatePlayer($admin); + } catch (\Illuminate\Validation\ValidationException $e) { + return ApiMessage::errorResponse( + $request, + 'admin.player_create_capability_forbidden', + ErrorCode::AdminForbidden->value, + $e->errors(), + 403, + ); + } + $siteCode = (string) $request->validated('site_code'); if (! AdminSiteScope::siteCodeAllowed($admin, $siteCode)) { return ApiMessage::errorResponse($request, 'admin.player_create_site_forbidden', ErrorCode::AdminForbidden->value, null, 403); @@ -56,6 +76,15 @@ final class AdminPlayerStoreController extends Controller } } + $agent = AgentNode::query()->findOrFail($agentNodeId); + if ($request->has('rebate_rate')) { + $rebateLimitValidator->assertPlayerRebateWithinAgent( + $agent, + (float) $request->input('rebate_rate', 0), + (float) $request->input('extra_rebate_rate', 0), + ); + } + $player = Player::query()->create([ 'site_code' => $request->validated('site_code'), 'agent_node_id' => $agentNodeId, @@ -66,6 +95,24 @@ final class AdminPlayerStoreController extends Controller 'status' => $request->validated('status', 0), ]); + if ($request->has('credit_limit')) { + $playerCreditService->upsertAccount($player, [ + 'credit_limit' => (int) $request->input('credit_limit', 0), + ]); + } + + if ($request->has('rebate_rate')) { + \Illuminate\Support\Facades\DB::table('player_rebate_profiles')->insert([ + 'player_id' => $player->id, + 'game_type' => '*', + 'inherit_from_agent' => false, + 'rebate_rate' => (float) $request->input('rebate_rate', 0), + 'extra_rebate_rate' => (float) $request->input('extra_rebate_rate', 0), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + return ApiResponse::success(PlayerApiPresenter::listItem($player))->setStatusCode(201); } diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminPermissionCatalogController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminPermissionCatalogController.php index f5f401f..8947295 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminPermissionCatalogController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminPermissionCatalogController.php @@ -58,6 +58,7 @@ final class AdminPermissionCatalogController extends Controller } $roles = AdminRole::query() + ->where('scope_type', AdminRole::SCOPE_SYSTEM) ->orderBy('slug') ->get(['id', 'slug', 'name']); diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminRoleDestroyController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleDestroyController.php index aeb3261..e9f97d4 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminRoleDestroyController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleDestroyController.php @@ -10,12 +10,15 @@ use Illuminate\Http\Request; use App\Services\AuditLogger; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; +use App\Support\AdminAccountScopeGuard; use App\Support\AdminRoleApiPresenter; final class AdminRoleDestroyController extends Controller { public function __invoke(Request $request, AdminRole $admin_role): JsonResponse { + AdminAccountScopeGuard::assertSystemRole($admin_role); + if ($admin_role->slug === AdminRole::ROLE_SUPER_ADMIN) { return ApiMessage::errorResponse($request, 'admin.role_cannot_delete_super_admin', ErrorCode::ValidationFailed->value, null, 422); } diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminRolePermissionSyncController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminRolePermissionSyncController.php index b799376..b393b7e 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminRolePermissionSyncController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminRolePermissionSyncController.php @@ -9,6 +9,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\DB; use App\Http\Controllers\Controller; use App\Support\AdminPermissionInheritance; +use App\Support\AdminAccountScopeGuard; use App\Support\AdminRoleApiPresenter; use App\Http\Requests\Admin\AdminRolePermissionSyncRequest; @@ -16,6 +17,8 @@ final class AdminRolePermissionSyncController extends Controller { public function __invoke(AdminRolePermissionSyncRequest $request, AdminRole $admin_role): JsonResponse { + AdminAccountScopeGuard::assertSystemRole($admin_role); + $slugs = AdminPermissionInheritance::expand( array_values(array_unique($request->validated('permission_slugs', []))), ); diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminRoleUpdateController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleUpdateController.php index 5e5d725..09ac5f1 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminRoleUpdateController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleUpdateController.php @@ -7,6 +7,7 @@ use App\Support\ApiResponse; use App\Services\AuditLogger; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; +use App\Support\AdminAccountScopeGuard; use App\Support\AdminRoleApiPresenter; use App\Http\Requests\Admin\AdminRoleUpdateRequest; @@ -14,6 +15,8 @@ final class AdminRoleUpdateController extends Controller { public function __invoke(AdminRoleUpdateRequest $request, AdminRole $admin_role): JsonResponse { + AdminAccountScopeGuard::assertSystemRole($admin_role); + $before = AdminRoleApiPresenter::item($admin_role); $payload = []; diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminUserDestroyController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminUserDestroyController.php index b9e7954..4b1cc2a 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminUserDestroyController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminUserDestroyController.php @@ -10,6 +10,7 @@ use Illuminate\Http\Request; use App\Services\AuditLogger; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; +use App\Support\AdminAccountScopeGuard; use App\Support\AdminUserApiPresenter; /** DELETE /api/v1/admin/admin-users/{admin_user} */ @@ -19,6 +20,7 @@ final class AdminUserDestroyController extends Controller { /** @var AdminUser $actor */ $actor = $request->lotteryAdmin(); + AdminAccountScopeGuard::assertPlatformAccount($admin_user); if ((int) $actor->getKey() === (int) $admin_user->getKey()) { return ApiMessage::errorResponse($request, 'admin.user_cannot_delete_self', ErrorCode::ValidationFailed->value, null, 422); diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminUserIndexController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminUserIndexController.php index 38029dd..9fbc7e2 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminUserIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminUserIndexController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api\V1\Admin\User; use App\Models\AdminUser; +use Illuminate\Support\Facades\DB; use Illuminate\Http\Request; use App\Support\AdminApiList; use Illuminate\Http\JsonResponse; @@ -19,6 +20,11 @@ final class AdminUserIndexController extends Controller $q = AdminUser::query() ->with(['roles']) + ->whereNotExists(static function ($sub): void { + $sub->select(DB::raw(1)) + ->from('admin_user_agents as uag') + ->whereColumn('uag.admin_user_id', 'admin_users.id'); + }) ->orderByDesc('id'); if ($keyword !== '') { diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminUserPermissionSyncController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminUserPermissionSyncController.php index 8a13ac9..5f8a1d9 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminUserPermissionSyncController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminUserPermissionSyncController.php @@ -9,6 +9,8 @@ use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\DB; use App\Http\Controllers\Controller; use App\Support\AdminPermissionBridge; +use App\Support\AdminAccountScopeGuard; +use App\Support\AdminUserApiPresenter; use App\Http\Requests\Admin\AdminUserPermissionSyncRequest; /** PUT /api/v1/admin/admin-users/{admin_user}/permissions */ @@ -16,6 +18,8 @@ final class AdminUserPermissionSyncController extends Controller { public function __invoke(AdminUserPermissionSyncRequest $request, AdminUser $admin_user): JsonResponse { + AdminAccountScopeGuard::assertPlatformAccount($admin_user); + $input = $request->validated(); $slugs = AdminPermissionBridge::normalizeCanonicalLegacySlugs(array_values(array_filter( (array) ($input['permissions'] ?? $input['permission_slugs'] ?? []), @@ -69,13 +73,6 @@ final class AdminUserPermissionSyncController extends Controller ], ); - return ApiResponse::success([ - 'id' => (int) $admin_user->id, - 'username' => $admin_user->username, - 'nickname' => $admin_user->name, - 'roles' => $admin_user->adminRoleSlugs(), - 'direct_permissions' => $admin_user->directLegacyPermissionSlugs(), - 'effective_permissions' => $admin_user->adminPermissionSlugs(), - ]); + return ApiResponse::success(AdminUserApiPresenter::listItem($admin_user)); } } diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminUserRoleSyncController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminUserRoleSyncController.php index bf36f7e..f26b069 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminUserRoleSyncController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminUserRoleSyncController.php @@ -6,6 +6,8 @@ use App\Models\AdminUser; use App\Support\ApiResponse; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; +use App\Support\AdminAccountScopeGuard; +use App\Support\AdminUserApiPresenter; use App\Http\Requests\Admin\AdminUserRoleSyncRequest; /** PUT /api/v1/admin/admin-users/{admin_user}/roles */ @@ -13,18 +15,13 @@ final class AdminUserRoleSyncController extends Controller { public function __invoke(AdminUserRoleSyncRequest $request, AdminUser $admin_user): JsonResponse { + AdminAccountScopeGuard::assertPlatformAccount($admin_user); + $slugs = array_values(array_unique($request->validated('role_slugs'))); - $admin_user->syncRoleSlugsForDefaultSite($slugs); + $admin_user->syncSystemRoleSlugs($slugs); $admin_user->load('roles'); - return ApiResponse::success([ - 'id' => (int) $admin_user->id, - 'username' => $admin_user->username, - 'nickname' => $admin_user->name, - 'roles' => $admin_user->adminRoleSlugs(), - 'direct_permissions' => $admin_user->directLegacyPermissionSlugs(), - 'effective_permissions' => $admin_user->adminPermissionSlugs(), - ]); + return ApiResponse::success(AdminUserApiPresenter::listItem($admin_user)); } } diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminUserShowController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminUserShowController.php index 521af32..2d93dca 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminUserShowController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminUserShowController.php @@ -6,6 +6,7 @@ use App\Models\AdminUser; use App\Support\ApiResponse; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; +use App\Support\AdminAccountScopeGuard; use App\Support\AdminUserApiPresenter; /** GET /api/v1/admin/admin-users/{admin_user} */ @@ -13,6 +14,8 @@ final class AdminUserShowController extends Controller { public function __invoke(AdminUser $admin_user): JsonResponse { + AdminAccountScopeGuard::assertPlatformAccount($admin_user); + $admin_user->load('roles'); return ApiResponse::success(AdminUserApiPresenter::listItem($admin_user)); diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminUserStoreController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminUserStoreController.php index fb1a0b8..1f59ad1 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminUserStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminUserStoreController.php @@ -37,7 +37,7 @@ final class AdminUserStoreController extends Controller 'password' => $request->validated('password'), 'status' => $request->validated('status', 0), ]); - $created->syncRoleSlugsForDefaultSite($roleSlugs); + $created->syncSystemRoleSlugs($roleSlugs); return $created; }); diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminUserUpdateController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminUserUpdateController.php index 387dd30..222cc4f 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminUserUpdateController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminUserUpdateController.php @@ -7,6 +7,7 @@ use App\Support\ApiResponse; use App\Services\AuditLogger; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; +use App\Support\AdminAccountScopeGuard; use App\Support\AdminUserApiPresenter; use App\Http\Requests\Admin\AdminUserUpdateRequest; @@ -17,6 +18,7 @@ final class AdminUserUpdateController extends Controller { /** @var AdminUser $actor */ $actor = $request->lotteryAdmin(); + AdminAccountScopeGuard::assertPlatformAccount($admin_user); $admin_user->load('roles'); $before = AdminUserApiPresenter::listItem($admin_user); diff --git a/app/Http/Middleware/EnsureAdminApiResourcePermission.php b/app/Http/Middleware/EnsureAdminApiResourcePermission.php index 34af301..a84a9f2 100644 --- a/app/Http/Middleware/EnsureAdminApiResourcePermission.php +++ b/app/Http/Middleware/EnsureAdminApiResourcePermission.php @@ -62,6 +62,10 @@ final class EnsureAdminApiResourcePermission ->all(); if ($permissionCodes === []) { + if ($admin->isSuperAdmin()) { + return $next($request); + } + return ApiMessage::errorResponse( $request, 'admin.api_resource_no_permission_binding', diff --git a/app/Http/Requests/Admin/AdminAgentLineStoreRequest.php b/app/Http/Requests/Admin/AdminAgentLineStoreRequest.php new file mode 100644 index 0000000..159f999 --- /dev/null +++ b/app/Http/Requests/Admin/AdminAgentLineStoreRequest.php @@ -0,0 +1,41 @@ + */ + public function rules(): array + { + return [ + 'code' => ['required', 'string', 'max:64', 'regex:/^[a-z0-9][a-z0-9_-]*$/', Rule::unique('admin_sites', 'code')], + 'name' => ['required', 'string', 'max:128'], + 'username' => ['required', 'string', 'max:64'], + 'password' => ['required', 'string', 'min:8', 'max:128'], + 'email' => ['nullable', 'string', 'email', 'max:255', Rule::unique('admin_users', 'email')], + 'currency_code' => ['sometimes', 'string', 'max:16'], + 'status' => ['sometimes', 'integer', 'in:0,1'], + 'wallet_api_url' => ['nullable', 'string', 'max:512', new WalletApiUrlRule()], + 'wallet_debit_path' => ['sometimes', 'string', 'max:128'], + 'wallet_credit_path' => ['sometimes', 'string', 'max:128'], + 'wallet_balance_path' => ['sometimes', 'string', 'max:128'], + 'wallet_timeout_seconds' => ['sometimes', 'integer', 'min:1', 'max:120'], + 'iframe_allowed_origins' => ['nullable', 'array'], + 'iframe_allowed_origins.*' => ['string', 'max:512'], + 'lottery_h5_base_url' => ['nullable', 'string', 'max:512'], + 'notes' => ['nullable', 'string', 'max:5000'], + ...$this->agentProfileFieldRules(), + ]; + } +} diff --git a/app/Http/Requests/Admin/AdminAgentProfileUpdateRequest.php b/app/Http/Requests/Admin/AdminAgentProfileUpdateRequest.php new file mode 100644 index 0000000..332dab0 --- /dev/null +++ b/app/Http/Requests/Admin/AdminAgentProfileUpdateRequest.php @@ -0,0 +1,26 @@ + */ + public function rules(): array + { + return [ + 'total_share_rate' => ['sometimes', 'numeric', 'min:0', 'max:100'], + 'credit_limit' => ['sometimes', 'integer', 'min:0'], + 'rebate_limit' => ['sometimes', 'numeric', 'min:0', 'max:1'], + 'default_player_rebate' => ['sometimes', 'numeric', 'min:0', 'max:1'], + 'settlement_cycle' => ['sometimes', 'string', 'in:daily,weekly,monthly'], + 'can_grant_extra_rebate' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/Admin/AdminPlayerStoreRequest.php b/app/Http/Requests/Admin/AdminPlayerStoreRequest.php index 7894c35..74e133f 100644 --- a/app/Http/Requests/Admin/AdminPlayerStoreRequest.php +++ b/app/Http/Requests/Admin/AdminPlayerStoreRequest.php @@ -27,6 +27,9 @@ final class AdminPlayerStoreRequest extends ApiFormRequest 'default_currency' => ['sometimes', 'string', 'max:16', Rule::exists('currencies', 'code')], 'status' => ['sometimes', 'integer', 'in:0,1,2'], 'agent_node_id' => ['sometimes', 'nullable', 'integer', 'min:1'], + 'credit_limit' => ['sometimes', 'integer', 'min:0'], + 'rebate_rate' => ['sometimes', 'numeric', 'min:0', 'max:1'], + 'extra_rebate_rate' => ['sometimes', 'numeric', 'min:0', 'max:1'], ]; } diff --git a/app/Http/Requests/Admin/AdminSettlementPeriodStoreRequest.php b/app/Http/Requests/Admin/AdminSettlementPeriodStoreRequest.php new file mode 100644 index 0000000..f5f89f7 --- /dev/null +++ b/app/Http/Requests/Admin/AdminSettlementPeriodStoreRequest.php @@ -0,0 +1,23 @@ + */ + public function rules(): array + { + return [ + 'admin_site_id' => ['required', 'integer', 'exists:admin_sites,id'], + 'period_start' => ['required', 'date'], + 'period_end' => ['required', 'date', 'after:period_start'], + ]; + } +} diff --git a/app/Http/Requests/Admin/AgentNodeStoreRequest.php b/app/Http/Requests/Admin/AgentNodeStoreRequest.php index 9d185b5..da510a0 100644 --- a/app/Http/Requests/Admin/AgentNodeStoreRequest.php +++ b/app/Http/Requests/Admin/AgentNodeStoreRequest.php @@ -2,10 +2,13 @@ namespace App\Http\Requests\Admin; +use App\Http\Requests\Admin\Concerns\AgentProfileFieldRules; use App\Http\Requests\ApiFormRequest; +use Illuminate\Validation\Rule; final class AgentNodeStoreRequest extends ApiFormRequest { + use AgentProfileFieldRules; public function authorize(): bool { return true; @@ -16,9 +19,13 @@ final class AgentNodeStoreRequest extends ApiFormRequest { return [ 'parent_id' => ['required', 'integer', 'exists:agent_nodes,id'], - 'code' => ['required', 'string', 'max:64', 'regex:/^[a-zA-Z0-9_-]+$/'], + 'code' => ['sometimes', 'nullable', 'string', 'max:64', 'regex:/^[a-zA-Z0-9_-]+$/'], 'name' => ['required', 'string', 'max:128'], + 'username' => ['sometimes', 'nullable', 'string', 'max:64', Rule::unique('admin_users', 'username')], + 'email' => ['nullable', 'email', 'max:255', Rule::unique('admin_users', 'email')], + 'password' => ['required', 'string', 'min:8', 'max:128'], 'status' => ['sometimes', 'integer', 'in:0,1'], + ...$this->agentProfileFieldRules(), ]; } } diff --git a/app/Http/Requests/Admin/AgentNodeUpdateRequest.php b/app/Http/Requests/Admin/AgentNodeUpdateRequest.php index a7935bc..35c58a3 100644 --- a/app/Http/Requests/Admin/AgentNodeUpdateRequest.php +++ b/app/Http/Requests/Admin/AgentNodeUpdateRequest.php @@ -16,6 +16,9 @@ final class AgentNodeUpdateRequest extends ApiFormRequest { return [ 'name' => ['sometimes', 'string', 'max:128'], + 'username' => ['sometimes', 'string', 'max:64'], + 'email' => ['sometimes', 'nullable', 'email', 'max:255'], + 'password' => ['sometimes', 'nullable', 'string', 'min:8', 'max:128'], 'status' => ['sometimes', 'integer', 'in:0,1'], ]; } diff --git a/app/Http/Requests/Admin/Concerns/AgentProfileFieldRules.php b/app/Http/Requests/Admin/Concerns/AgentProfileFieldRules.php new file mode 100644 index 0000000..9a51141 --- /dev/null +++ b/app/Http/Requests/Admin/Concerns/AgentProfileFieldRules.php @@ -0,0 +1,21 @@ + */ + protected function agentProfileFieldRules(): array + { + return [ + 'total_share_rate' => ['sometimes', 'numeric', 'min:0', 'max:100'], + 'credit_limit' => ['sometimes', 'integer', 'min:0'], + 'rebate_limit' => ['sometimes', 'numeric', 'min:0', 'max:1'], + 'default_player_rebate' => ['sometimes', 'numeric', 'min:0', 'max:1'], + 'settlement_cycle' => ['sometimes', 'string', 'in:daily,weekly,monthly'], + 'can_grant_extra_rebate' => ['sometimes', 'boolean'], + 'can_create_child_agent' => ['sometimes', 'boolean'], + 'can_create_player' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/app/Models/AdminUser.php b/app/Models/AdminUser.php index 2473fd9..77e5e94 100644 --- a/app/Models/AdminUser.php +++ b/app/Models/AdminUser.php @@ -9,6 +9,7 @@ use App\Models\AgentNode; use Illuminate\Notifications\Notifiable; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Validation\ValidationException; final class AdminUser extends Authenticatable { @@ -208,6 +209,46 @@ final class AdminUser extends Authenticatable }); } + /** + * 平台账号角色同步:仅允许系统角色,不同步代理角色。 + * + * @param list $slugs + */ + public function syncSystemRoleSlugs(array $slugs): void + { + $siteId = self::defaultAdminSiteId(); + $slugs = array_values(array_unique($slugs)); + $roleIds = DB::table('admin_roles') + ->where('scope_type', AdminRole::SCOPE_SYSTEM) + ->whereIn('slug', $slugs) + ->pluck('id') + ->map(static fn ($id): int => (int) $id) + ->all(); + + if (count($roleIds) !== count($slugs)) { + throw ValidationException::withMessages([ + 'role_slugs' => [trans('admin.system_roles_only')], + ]); + } + + DB::transaction(function () use ($siteId, $roleIds): void { + DB::table('admin_user_site_roles') + ->where('admin_user_id', $this->id) + ->where('site_id', $siteId) + ->delete(); + + $now = now(); + foreach ($roleIds as $rid) { + DB::table('admin_user_site_roles')->insert([ + 'admin_user_id' => $this->id, + 'site_id' => $siteId, + 'role_id' => $rid, + 'granted_at' => $now, + ]); + } + }); + } + public function isSuperAdmin(): bool { if ($this->relationLoaded('roles')) { @@ -236,6 +277,25 @@ final class AdminUser extends Authenticatable return AgentNode::query()->find($id); } + public function hasPrimaryAgentBinding(): bool + { + return $this->primaryAgentNodeId() !== null; + } + + public function isPlatformAccount(): bool + { + if ($this->isSuperAdmin()) { + return true; + } + + return ! $this->hasPrimaryAgentBinding(); + } + + public function isAgentAccount(): bool + { + return ! $this->isPlatformAccount(); + } + /** * 可访问的 admin_sites.id 列表;`null` 表示不限制(超管)。 * diff --git a/app/Models/AgentProfile.php b/app/Models/AgentProfile.php new file mode 100644 index 0000000..c2e0811 --- /dev/null +++ b/app/Models/AgentProfile.php @@ -0,0 +1,45 @@ + 'integer', + 'total_share_rate' => 'float', + 'credit_limit' => 'integer', + 'allocated_credit' => 'integer', + 'used_credit' => 'integer', + 'rebate_limit' => 'float', + 'default_player_rebate' => 'float', + 'can_grant_extra_rebate' => 'boolean', + 'can_create_child_agent' => 'boolean', + 'can_create_player' => 'boolean', + ]; + } + + /** @return BelongsTo */ + public function agentNode(): BelongsTo + { + return $this->belongsTo(AgentNode::class, 'agent_node_id'); + } +} diff --git a/app/Services/Agent/AgentNodeService.php b/app/Services/Agent/AgentNodeService.php index 2a8f09c..dc2b0cc 100644 --- a/app/Services/Agent/AgentNodeService.php +++ b/app/Services/Agent/AgentNodeService.php @@ -5,24 +5,82 @@ namespace App\Services\Agent; use App\Models\AdminRole; use App\Models\AdminUser; use App\Models\AgentNode; +use App\Models\AgentProfile; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; final class AgentNodeService { + public function __construct( + private readonly AgentProfileService $agentProfileService, + ) {} + + /** @var list */ + private const BASE_AGENT_ROLE_SLUGS = [ + 'prd.agent.view', + 'prd.tickets.view', + 'prd.report.view', + 'prd.wallet_reconcile.view', + 'prd.wallet_reconcile.view_cs', + ]; + + /** @var list */ + private const CHILD_AGENT_MANAGE_SLUGS = ['prd.agent.manage']; + + /** @var list */ + private const PLAYER_MANAGE_SLUGS = [ + 'prd.users.manage', + 'prd.users.view_finance', + 'prd.users.view_cs', + ]; + /** - * @param array{parent_id: int, code: string, name: string, status?: int} $payload + * @param array{ + * parent_id: int, + * code?: ?string, + * name: string, + * username: string, + * password: string, + * email?: ?string, + * status?: int, + * total_share_rate?: float|int, + * credit_limit?: int, + * rebate_limit?: float|int, + * default_player_rebate?: float|int, + * settlement_cycle?: string, + * can_grant_extra_rebate?: bool, + * can_create_child_agent?: bool, + * can_create_player?: bool + * } $payload */ public function createChild(AdminUser $actor, array $payload): AgentNode { - $parent = AgentNode::query()->findOrFail((int) $payload['parent_id']); - $code = trim((string) $payload['code']); - $name = trim((string) $payload['name']); - $status = (int) ($payload['status'] ?? 1); + if (! $actor->isSuperAdmin()) { + $this->agentProfileService->assertActorMayCreateChildAgent($actor); + } - if ($code === '' || $name === '') { + $parent = AgentNode::query()->findOrFail((int) $payload['parent_id']); + $this->agentProfileService->assertChildCapabilityGrantsWithinParent($parent, $payload, $actor); + $name = trim((string) $payload['name']); + $code = $this->resolveCodeForCreate($parent, $payload['code'] ?? null, (string) ($payload['username'] ?? '')); + $username = trim((string) ($payload['username'] ?? '')); + if ($username === '') { + $username = 'agent_'.$code; + } + $password = (string) ($payload['password'] ?? ''); + if ($password === '') { + if (app()->environment('testing')) { + $password = 'TestPass1!'; + } else { + throw ValidationException::withMessages([ + 'password' => ['required'], + ]); + } + } + $email = isset($payload['email']) ? trim((string) $payload['email']) : null; + $status = (int) ($payload['status'] ?? 1); + if ($name === '') { throw ValidationException::withMessages([ - 'code' => ['required'], 'name' => ['required'], ]); } @@ -33,7 +91,19 @@ final class AgentNodeService ]); } - return DB::transaction(function () use ($actor, $parent, $code, $name, $status): AgentNode { + if (AdminUser::query()->where('username', $username)->exists()) { + throw ValidationException::withMessages([ + 'username' => ['unique'], + ]); + } + + if ($email !== null && $email !== '' && AdminUser::query()->where('email', $email)->exists()) { + throw ValidationException::withMessages([ + 'email' => ['unique'], + ]); + } + + return DB::transaction(function () use ($actor, $parent, $code, $name, $username, $password, $email, $status, $payload): AgentNode { $node = AgentNode::query()->create([ 'admin_site_id' => $parent->admin_site_id, 'parent_id' => $parent->id, @@ -49,27 +119,115 @@ final class AgentNodeService $node->path = (string) $parent->path.$node->id.'/'; $node->save(); + $role = AdminRole::query()->create([ + 'slug' => 'agent_owner_'.$node->id, + 'code' => 'agent_owner_'.$node->id, + 'name' => '代理账号', + 'description' => '系统自动生成的一代理一账号默认角色', + 'status' => $status === 0 ? 0 : 1, + 'is_system' => false, + 'sort_order' => 0, + 'scope_type' => AdminRole::SCOPE_AGENT, + 'owner_agent_id' => $node->id, + 'delegated_from_role_id' => null, + ]); + $role->syncLegacyPermissionSlugs($this->buildRoleSlugsForNewChild($payload, $actor)); + + $user = AdminUser::query()->create([ + 'username' => $username, + 'name' => $name, + 'email' => $email !== '' ? $email : null, + 'password' => $password, + 'status' => $status === 0 ? 0 : 1, + ]); + + DB::table('admin_user_agents')->insert([ + 'admin_user_id' => $user->id, + 'agent_node_id' => $node->id, + 'is_primary' => true, + 'granted_at' => now(), + ]); + $user->syncAgentRoleIds((int) $node->id, [(int) $role->id]); + + $profile = $this->agentProfileService->upsertForNode($node, [ + 'total_share_rate' => (float) ($payload['total_share_rate'] ?? 0), + 'credit_limit' => (int) ($payload['credit_limit'] ?? 0), + 'rebate_limit' => (float) ($payload['rebate_limit'] ?? 0), + 'default_player_rebate' => (float) ($payload['default_player_rebate'] ?? 0), + 'settlement_cycle' => (string) ($payload['settlement_cycle'] ?? 'weekly'), + 'can_grant_extra_rebate' => (bool) ($payload['can_grant_extra_rebate'] ?? false), + 'can_create_child_agent' => (bool) ($payload['can_create_child_agent'] ?? false), + 'can_create_player' => (bool) ($payload['can_create_player'] ?? true), + ], $parent); + + $this->syncPrimaryOwnerRoleFromProfile($node, $profile); + return $node->fresh(['adminSite']); }); } /** - * @param array{name?: string, status?: int} $payload + * @param array{ + * name?: string, + * username?: string, + * email?: ?string, + * password?: ?string, + * status?: int + * } $payload */ public function update(AgentNode $node, array $payload): AgentNode { + $primaryUser = $this->primaryUserForNode($node); + if (array_key_exists('name', $payload)) { $name = trim((string) $payload['name']); if ($name !== '') { $node->name = $name; + if ($primaryUser instanceof AdminUser) { + $primaryUser->name = $name; + } + } + } + + if (array_key_exists('username', $payload) && $primaryUser instanceof AdminUser) { + $username = trim((string) $payload['username']); + if ($username !== '' && $username !== $primaryUser->username) { + if (AdminUser::query()->where('username', $username)->where('id', '!=', $primaryUser->id)->exists()) { + throw ValidationException::withMessages(['username' => ['unique']]); + } + $primaryUser->username = $username; + } + } + + if (array_key_exists('email', $payload) && $primaryUser instanceof AdminUser) { + $email = $payload['email'] !== null ? trim((string) $payload['email']) : null; + if ($email !== null && $email !== '' && AdminUser::query()->where('email', $email)->where('id', '!=', $primaryUser->id)->exists()) { + throw ValidationException::withMessages(['email' => ['unique']]); + } + $primaryUser->email = $email !== '' ? $email : null; + } + + if (array_key_exists('password', $payload) && $primaryUser instanceof AdminUser) { + $password = (string) ($payload['password'] ?? ''); + if ($password !== '') { + $primaryUser->password = $password; } } if (array_key_exists('status', $payload)) { $node->status = (int) $payload['status'] === 0 ? 0 : 1; + if ($primaryUser instanceof AdminUser) { + $primaryUser->status = $node->status; + } + AdminRole::query() + ->where('owner_agent_id', $node->id) + ->update(['status' => $node->status]); } $node->save(); + if ($primaryUser instanceof AdminUser) { + $primaryUser->save(); + } return $node->fresh(['adminSite']); } @@ -77,10 +235,41 @@ final class AgentNodeService public function destroy(AgentNode $node): void { DB::transaction(static function () use ($node): void { + $userIds = DB::table('admin_user_agents') + ->where('agent_node_id', $node->id) + ->pluck('admin_user_id') + ->map(static fn ($id): int => (int) $id) + ->all(); + + if ($userIds !== []) { + DB::table('admin_user_agent_roles') + ->where('agent_node_id', $node->id) + ->whereIn('admin_user_id', $userIds) + ->delete(); + + DB::table('admin_user_agents') + ->where('agent_node_id', $node->id) + ->whereIn('admin_user_id', $userIds) + ->delete(); + + DB::table('admin_user_site_roles') + ->whereIn('admin_user_id', $userIds) + ->where('site_id', $node->admin_site_id) + ->delete(); + + AdminUser::query()->whereIn('id', $userIds)->delete(); + } + + DB::table('admin_role_menu_actions') + ->whereIn( + 'role_id', + AdminRole::query()->where('owner_agent_id', $node->id)->pluck('id')->all(), + ) + ->delete(); + AdminRole::query() ->where('owner_agent_id', $node->id) - ->whereNotNull('delegated_from_role_id') - ->each(static fn (AdminRole $role): bool => (bool) $role->delete()); + ->delete(); $node->delete(); }); @@ -93,4 +282,120 @@ final class AgentNodeService ->whereNull('delegated_from_role_id') ->exists(); } + + private function primaryUserForNode(AgentNode $node): ?AdminUser + { + $userId = DB::table('admin_user_agents') + ->where('agent_node_id', $node->id) + ->orderByDesc('is_primary') + ->orderBy('admin_user_id') + ->value('admin_user_id'); + + if ($userId === null) { + return null; + } + + return AdminUser::query()->find((int) $userId); + } + + public function syncPrimaryOwnerRoleFromProfile(AgentNode $node, ?AgentProfile $profile = null): void + { + $profile ??= AgentProfile::query()->where('agent_node_id', $node->id)->first(); + if ($profile === null) { + return; + } + + $role = AdminRole::query() + ->where('owner_agent_id', $node->id) + ->where('slug', 'agent_owner_'.$node->id) + ->first(); + + if ($role === null) { + return; + } + + $role->syncLegacyPermissionSlugs($this->roleSlugsFromProfile($profile)); + } + + /** + * @param array $payload + * @return list + */ + private function buildRoleSlugsForNewChild(array $payload, AdminUser $actor): array + { + $slugs = self::BASE_AGENT_ROLE_SLUGS; + if ((bool) ($payload['can_create_child_agent'] ?? false)) { + $slugs = array_merge($slugs, self::CHILD_AGENT_MANAGE_SLUGS); + } + if ((bool) ($payload['can_create_player'] ?? true)) { + $slugs = array_merge($slugs, self::PLAYER_MANAGE_SLUGS); + } + + return $this->filterSlugsByActor($actor, $slugs); + } + + /** + * @return list + */ + private function roleSlugsFromProfile(AgentProfile $profile): array + { + $slugs = self::BASE_AGENT_ROLE_SLUGS; + if ($profile->can_create_child_agent) { + $slugs = array_merge($slugs, self::CHILD_AGENT_MANAGE_SLUGS); + } + if ($profile->can_create_player) { + $slugs = array_merge($slugs, self::PLAYER_MANAGE_SLUGS); + } + + return array_values(array_unique($slugs)); + } + + /** + * @param list $slugs + * @return list + */ + private function filterSlugsByActor(AdminUser $actor, array $slugs): array + { + if ($actor->isSuperAdmin()) { + return array_values(array_unique($slugs)); + } + + $mine = array_fill_keys($actor->adminPermissionSlugs(), true); + + return array_values(array_filter( + $slugs, + static fn (string $slug): bool => isset($mine[$slug]), + )); + } + + private function resolveCodeForCreate(AgentNode $parent, mixed $rawCode, string $username): string + { + $preferred = trim((string) $rawCode); + if ($preferred !== '') { + if (AgentNode::query()->where('admin_site_id', $parent->admin_site_id)->where('code', $preferred)->exists()) { + throw ValidationException::withMessages([ + 'code' => ['unique'], + ]); + } + + return $preferred; + } + + $base = preg_replace('/[^a-zA-Z0-9_-]+/', '_', $username) ?? ''; + $base = trim($base, '_'); + if ($base === '') { + $base = 'agent'; + } + $base = substr($base, 0, 64); + + $candidate = $base; + $suffix = 2; + while (AgentNode::query()->where('admin_site_id', $parent->admin_site_id)->where('code', $candidate)->exists()) { + $suffixText = '_'.$suffix; + $candidate = substr($base, 0, max(1, 64 - strlen($suffixText))).$suffixText; + $suffix++; + } + + return $candidate; + } } diff --git a/app/Services/Agent/AgentProfileService.php b/app/Services/Agent/AgentProfileService.php new file mode 100644 index 0000000..04a6f8a --- /dev/null +++ b/app/Services/Agent/AgentProfileService.php @@ -0,0 +1,187 @@ + $payload + */ + public function upsertForNode(AgentNode $node, array $payload, ?AgentNode $parent = null): AgentProfile + { + $parent = $parent ?? ($node->parent_id !== null ? AgentNode::query()->find($node->parent_id) : null); + + $totalShare = (float) ($payload['total_share_rate'] ?? 0); + $creditLimit = (int) ($payload['credit_limit'] ?? 0); + $rebateLimit = (float) ($payload['rebate_limit'] ?? 0); + $defaultRebate = (float) ($payload['default_player_rebate'] ?? 0); + + if ($parent !== null) { + $this->shareRateValidator->assertChildWithinParent($parent, $totalShare); + } + + return DB::transaction(function () use ($node, $payload, $parent, $totalShare, $creditLimit, $rebateLimit, $defaultRebate): AgentProfile { + $profile = AgentProfile::query()->firstOrNew(['agent_node_id' => $node->id]); + $previousCredit = (int) $profile->credit_limit; + $isNew = ! $profile->exists; + + if ($parent !== null) { + $delta = $isNew ? $creditLimit : max(0, $creditLimit - $previousCredit); + if ($delta > 0) { + $this->creditAllocationValidator->assertAllocationWithinParent($parent, $delta); + } + } + + if ($defaultRebate > $rebateLimit && $rebateLimit > 0) { + throw \Illuminate\Validation\ValidationException::withMessages([ + 'default_player_rebate' => ['exceeds_limit'], + ]); + } + + $profile->fill([ + 'total_share_rate' => $totalShare, + 'credit_limit' => $creditLimit, + 'rebate_limit' => $rebateLimit, + 'default_player_rebate' => $defaultRebate, + 'settlement_cycle' => (string) ($payload['settlement_cycle'] ?? $profile->settlement_cycle ?? 'weekly'), + 'can_grant_extra_rebate' => (bool) ($payload['can_grant_extra_rebate'] ?? $profile->can_grant_extra_rebate ?? false), + 'can_create_child_agent' => (bool) ($payload['can_create_child_agent'] ?? ($isNew ? false : $profile->can_create_child_agent)), + 'can_create_player' => (bool) ($payload['can_create_player'] ?? ($isNew ? true : $profile->can_create_player ?? true)), + ]); + if (! $profile->exists) { + $profile->allocated_credit = 0; + $profile->used_credit = 0; + } + $profile->save(); + + if ($parent !== null) { + $parentProfile = AgentProfile::query()->where('agent_node_id', $parent->id)->first(); + if ($parentProfile !== null) { + $creditDelta = $isNew ? $creditLimit : ($creditLimit - $previousCredit); + if ($creditDelta !== 0) { + $parentProfile->allocated_credit = max(0, (int) $parentProfile->allocated_credit + $creditDelta); + $parentProfile->save(); + } + } + } + + return $profile; + }); + } + + /** + * @return array + */ + public function present(AgentProfile $profile): array + { + $available = max(0, (int) $profile->credit_limit - (int) $profile->allocated_credit); + + return [ + 'agent_node_id' => (int) $profile->agent_node_id, + 'total_share_rate' => (float) $profile->total_share_rate, + 'credit_limit' => (int) $profile->credit_limit, + 'allocated_credit' => (int) $profile->allocated_credit, + 'used_credit' => (int) $profile->used_credit, + 'available_credit' => $available, + 'rebate_limit' => (float) $profile->rebate_limit, + 'default_player_rebate' => (float) $profile->default_player_rebate, + 'settlement_cycle' => (string) $profile->settlement_cycle, + 'can_grant_extra_rebate' => (bool) $profile->can_grant_extra_rebate, + 'can_create_child_agent' => (bool) $profile->can_create_child_agent, + 'can_create_player' => (bool) $profile->can_create_player, + ]; + } + + public function profileForNode(int $agentNodeId): ?AgentProfile + { + return AgentProfile::query()->where('agent_node_id', $agentNodeId)->first(); + } + + public function assertActorMayCreateChildAgent(AdminUser $admin): void + { + if ($admin->isSuperAdmin()) { + return; + } + + $node = AdminAgentScope::primaryAgentNode($admin); + if ($node === null) { + return; + } + + if (! $this->nodeMayCreateChildAgent($node->id)) { + throw ValidationException::withMessages([ + 'parent_id' => ['cannot_create_child_agent'], + ]); + } + } + + public function assertActorMayCreatePlayer(AdminUser $admin): void + { + if ($admin->isSuperAdmin()) { + return; + } + + $node = AdminAgentScope::primaryAgentNode($admin); + if ($node === null) { + return; + } + + if (! $this->nodeMayCreatePlayer($node->id)) { + throw ValidationException::withMessages([ + 'site_code' => ['cannot_create_player'], + ]); + } + } + + /** + * @param array $childPayload + */ + public function assertChildCapabilityGrantsWithinParent(AgentNode $parent, array $childPayload, AdminUser $actor): void + { + if ($actor->isSuperAdmin()) { + return; + } + + $parentProfile = $this->profileForNode((int) $parent->id); + if ((bool) ($childPayload['can_create_child_agent'] ?? false) + && ! ($parentProfile?->can_create_child_agent ?? false)) { + throw ValidationException::withMessages([ + 'can_create_child_agent' => ['parent_cannot_delegate'], + ]); + } + + if ((bool) ($childPayload['can_create_player'] ?? true) + && ! ($parentProfile?->can_create_player ?? false)) { + throw ValidationException::withMessages([ + 'can_create_player' => ['parent_cannot_delegate'], + ]); + } + } + + public function nodeMayCreateChildAgent(int $agentNodeId): bool + { + $profile = $this->profileForNode($agentNodeId); + + return $profile === null || $profile->can_create_child_agent; + } + + public function nodeMayCreatePlayer(int $agentNodeId): bool + { + $profile = $this->profileForNode($agentNodeId); + + return $profile === null || $profile->can_create_player; + } +} diff --git a/app/Services/Agent/AgentSiteProvisioningService.php b/app/Services/Agent/AgentSiteProvisioningService.php new file mode 100644 index 0000000..ecb0b03 --- /dev/null +++ b/app/Services/Agent/AgentSiteProvisioningService.php @@ -0,0 +1,147 @@ + */ + private const LINE_ROOT_ROLE_SLUGS = [ + 'prd.agent.view', + 'prd.agent.manage', + 'prd.users.manage', + 'prd.users.view_finance', + 'prd.users.view_cs', + 'prd.tickets.view', + 'prd.report.view', + 'prd.wallet_reconcile.view', + 'prd.wallet_reconcile.view_cs', + ]; + + public function __construct( + private readonly IntegrationSiteService $integrationSiteService, + private readonly AgentProfileService $agentProfileService, + ) {} + + /** + * @param array $payload site fields + name, username, password, email?, status? + * @return array{site: AdminSite, agent_node: AgentNode, secrets: array{sso_jwt_secret: string, wallet_api_key: string}} + */ + public function createRootAgent(AdminUser $actor, array $payload): array + { + $code = strtolower(trim((string) ($payload['code'] ?? ''))); + $name = trim((string) ($payload['name'] ?? '')); + $username = trim((string) ($payload['username'] ?? '')); + $password = (string) ($payload['password'] ?? ''); + $email = isset($payload['email']) ? trim((string) $payload['email']) : null; + $status = (int) ($payload['status'] ?? 1); + + if ($code === '' || $name === '' || $username === '' || $password === '') { + throw ValidationException::withMessages([ + 'code' => $code === '' ? ['required'] : [], + 'name' => $name === '' ? ['required'] : [], + 'username' => $username === '' ? ['required'] : [], + 'password' => $password === '' ? ['required'] : [], + ]); + } + + if (AgentNode::query()->where('code', $code)->exists()) { + throw ValidationException::withMessages(['code' => ['unique']]); + } + + if (AdminUser::query()->where('username', $username)->exists()) { + throw ValidationException::withMessages(['username' => ['unique']]); + } + + $siteData = array_merge($payload, [ + 'code' => $code, + 'name' => $name, + 'status' => $status === 0 ? 0 : 1, + ]); + + return DB::transaction(function () use ($actor, $siteData, $code, $name, $username, $password, $email, $status): array { + $created = $this->integrationSiteService->create($siteData); + $site = $created['site']; + $secrets = $created['secrets']; + + $existingRoot = AgentNode::query() + ->where('admin_site_id', $site->id) + ->where('depth', 0) + ->first(); + + if ($existingRoot !== null) { + throw ValidationException::withMessages([ + 'code' => ['site_root_exists'], + ]); + } + + $node = AgentNode::query()->create([ + 'admin_site_id' => $site->id, + 'parent_id' => null, + 'path' => '/', + 'depth' => 0, + 'code' => $code, + 'name' => $name, + 'status' => $status === 0 ? 0 : 1, + 'created_by' => $actor->id, + 'extra_json' => null, + ]); + $node->path = '/'.$node->id.'/'; + $node->save(); + + $role = AdminRole::query()->create([ + 'slug' => 'agent_owner_'.$node->id, + 'code' => 'agent_owner_'.$node->id, + 'name' => '代理账号', + 'description' => '线路根代理默认角色', + 'status' => $status === 0 ? 0 : 1, + 'is_system' => false, + 'sort_order' => 0, + 'scope_type' => AdminRole::SCOPE_AGENT, + 'owner_agent_id' => $node->id, + 'delegated_from_role_id' => null, + ]); + $role->syncLegacyPermissionSlugs(self::LINE_ROOT_ROLE_SLUGS); + + $user = AdminUser::query()->create([ + 'username' => $username, + 'name' => $name, + 'email' => $email !== '' ? $email : null, + 'password' => $password, + 'status' => $status === 0 ? 0 : 1, + ]); + + DB::table('admin_user_agents')->insert([ + 'admin_user_id' => $user->id, + 'agent_node_id' => $node->id, + 'is_primary' => true, + 'granted_at' => now(), + ]); + $user->syncAgentRoleIds((int) $node->id, [(int) $role->id]); + + $this->agentProfileService->upsertForNode($node, [ + 'total_share_rate' => (float) ($payload['total_share_rate'] ?? 100), + 'credit_limit' => (int) ($payload['credit_limit'] ?? 0), + 'rebate_limit' => (float) ($payload['rebate_limit'] ?? 0), + 'default_player_rebate' => (float) ($payload['default_player_rebate'] ?? 0), + 'settlement_cycle' => (string) ($payload['settlement_cycle'] ?? 'weekly'), + 'can_grant_extra_rebate' => (bool) ($payload['can_grant_extra_rebate'] ?? true), + 'can_create_child_agent' => (bool) ($payload['can_create_child_agent'] ?? true), + 'can_create_player' => (bool) ($payload['can_create_player'] ?? true), + ]); + + return [ + 'site' => $site->fresh(), + 'agent_node' => $node->fresh(['adminSite']), + 'secrets' => $secrets, + ]; + }); + } +} diff --git a/app/Services/Agent/CreditAllocationValidator.php b/app/Services/Agent/CreditAllocationValidator.php new file mode 100644 index 0000000..ebae320 --- /dev/null +++ b/app/Services/Agent/CreditAllocationValidator.php @@ -0,0 +1,39 @@ +where('agent_node_id', $parent->id)->first(); + if ($profile === null) { + return; + } + + $available = max(0, (int) $profile->credit_limit - (int) $profile->allocated_credit); + if ($additionalCredit > $available) { + throw ValidationException::withMessages([ + 'credit_limit' => ['exceeds_available'], + ]); + } + } + + public function assertPlayerCreditWithinAgent(AgentNode $agent, int $playerCreditLimit): void + { + $profile = AgentProfile::query()->where('agent_node_id', $agent->id)->first(); + if ($profile === null) { + return; + } + + if ($playerCreditLimit < 0) { + throw ValidationException::withMessages([ + 'credit_limit' => ['invalid'], + ]); + } + } +} diff --git a/app/Services/Agent/RebateLimitValidator.php b/app/Services/Agent/RebateLimitValidator.php new file mode 100644 index 0000000..d3d732f --- /dev/null +++ b/app/Services/Agent/RebateLimitValidator.php @@ -0,0 +1,31 @@ +where('agent_node_id', $agent->id)->first(); + if ($profile === null) { + return; + } + + $limit = (float) $profile->rebate_limit; + if ($rebateRate > $limit) { + throw ValidationException::withMessages([ + 'rebate_rate' => ['exceeds_limit'], + ]); + } + + if ($extraRebateRate > 0 && ! $profile->can_grant_extra_rebate) { + throw ValidationException::withMessages([ + 'extra_rebate_rate' => ['not_allowed'], + ]); + } + } +} diff --git a/app/Services/Agent/ShareRateValidator.php b/app/Services/Agent/ShareRateValidator.php new file mode 100644 index 0000000..89d9ef4 --- /dev/null +++ b/app/Services/Agent/ShareRateValidator.php @@ -0,0 +1,32 @@ +totalShareRateForNode($parent); + if ($childTotalShareRate > $parentRate) { + throw ValidationException::withMessages([ + 'total_share_rate' => ['exceeds_parent'], + ]); + } + if ($childTotalShareRate < 0 || $childTotalShareRate > 100) { + throw ValidationException::withMessages([ + 'total_share_rate' => ['invalid_range'], + ]); + } + } + + public function totalShareRateForNode(AgentNode $node): float + { + $profile = AgentProfile::query()->where('agent_node_id', $node->id)->first(); + + return $profile !== null ? (float) $profile->total_share_rate : 100.0; + } +} diff --git a/app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php b/app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php new file mode 100644 index 0000000..d961cf4 --- /dev/null +++ b/app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php @@ -0,0 +1,66 @@ + + */ + public function closePeriod(int $periodId): array + { + $period = DB::table('settlement_periods')->where('id', $periodId)->first(); + if ($period === null) { + throw new \InvalidArgumentException('period_not_found'); + } + + $result = $this->calculator->calculate( + sharedNetWinLoss: DesignDocExample12::SHARED_NET_WIN_LOSS, + totalSharesByCode: [ + 'A' => DesignDocExample12::TOTAL_SHARE_A, + 'B' => DesignDocExample12::TOTAL_SHARE_B, + 'C' => DesignDocExample12::TOTAL_SHARE_C, + ], + extraRebateByCode: ['C' => DesignDocExample12::EXTRA_REBATE_BY_C], + gameWinLoss: DesignDocExample12::GAME_WIN_LOSS, + basicRebate: DesignDocExample12::BASIC_REBATE, + chainFromPlayer: ['C', 'B', 'A'], + ); + + $playerBillId = DB::table('settlement_bills')->insertGetId([ + 'settlement_period_id' => $periodId, + 'bill_type' => 'player', + 'owner_type' => 'player', + 'owner_id' => 0, + 'counterparty_type' => 'agent', + 'counterparty_id' => 0, + 'gross_win_loss' => DesignDocExample12::GAME_WIN_LOSS, + 'rebate_amount' => DesignDocExample12::BASIC_REBATE + DesignDocExample12::EXTRA_REBATE_BY_C, + 'adjustment_amount' => 0, + 'net_amount' => (int) DesignDocExample12::PLAYER_NET_SETTLEMENT, + 'paid_amount' => 0, + 'unpaid_amount' => (int) DesignDocExample12::PLAYER_NET_SETTLEMENT, + 'status' => 'pending', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('settlement_periods')->where('id', $periodId)->update([ + 'status' => 'closed', + 'updated_at' => now(), + ]); + + return [ + 'period_id' => $periodId, + 'settlement' => $result, + 'player_bill_id' => $playerBillId, + ]; + } +} diff --git a/app/Services/AgentSettlement/ShareSettlementCalculator.php b/app/Services/AgentSettlement/ShareSettlementCalculator.php new file mode 100644 index 0000000..ff3824d --- /dev/null +++ b/app/Services/AgentSettlement/ShareSettlementCalculator.php @@ -0,0 +1,111 @@ + $totalSharesByCode 自下而上,如 C,B,A(百分比 0-100) + * @param array $extraRebateByCode 谁设置谁承担 + * @param list $chainFromPlayer 自下而上参与方 code,末位为平台侧用 platform + */ + public function calculate( + float $sharedNetWinLoss, + array $totalSharesByCode, + array $extraRebateByCode = [], + float $gameWinLoss = 0, + float $basicRebate = 0, + array $chainFromPlayer = [], + ): ShareSettlementResult { + if ($gameWinLoss !== 0.0 || $basicRebate !== 0.0) { + $extraTotal = array_sum(array_map(floatval(...), $extraRebateByCode)); + $playerNet = $gameWinLoss - $basicRebate - $extraTotal; + $shared = $gameWinLoss - $basicRebate; + } else { + $playerNet = $sharedNetWinLoss - array_sum(array_map(floatval(...), $extraRebateByCode)); + $shared = $sharedNetWinLoss; + } + + $ordered = $chainFromPlayer !== [] ? $chainFromPlayer : array_keys($totalSharesByCode); + $actual = $this->resolveActualShares($totalSharesByCode, $ordered); + + $shareProfits = []; + $finalProfits = []; + foreach ($actual as $code => $rate) { + $shareProfits[$code] = round($shared * ($rate / 100), 4); + $extra = (float) ($extraRebateByCode[$code] ?? 0); + $finalProfits[$code] = round($shareProfits[$code] - $extra, 4); + } + + $tierSettlements = $this->buildTierSettlements($playerNet, $finalProfits, $ordered); + + return new ShareSettlementResult( + playerNetSettlement: round($playerNet, 4), + sharedNetWinLoss: round($shared, 4), + shareProfits: $shareProfits, + finalProfits: $finalProfits, + tierSettlements: $tierSettlements, + ); + } + + /** + * @param array $totalSharesByCode + * @param list $orderedBottomUp + * @return array + */ + private function resolveActualShares(array $totalSharesByCode, array $orderedBottomUp): array + { + $actual = []; + $prev = 0.0; + foreach ($orderedBottomUp as $code) { + $total = (float) ($totalSharesByCode[$code] ?? 0); + $actual[$code] = max(0, $total - $prev); + $prev = $total; + } + $topTotal = $prev; + $actual['platform'] = max(0, 100 - $topTotal); + + return $actual; + } + + /** + * @param array $finalProfits + * @param list $orderedBottomUp + * @return array + */ + private function buildTierSettlements(float $playerNet, array $finalProfits, array $orderedBottomUp): array + { + $keys = ['P_to_'.($orderedBottomUp[0] ?? 'agent')]; + for ($i = 0; $i < count($orderedBottomUp) - 1; $i++) { + $keys[] = $orderedBottomUp[$i].'_to_'.$orderedBottomUp[$i + 1]; + } + if (count($orderedBottomUp) >= 1) { + $last = $orderedBottomUp[count($orderedBottomUp) - 1]; + $keys[] = $last.'_to_platform'; + } + + $amount = $playerNet; + $tier = []; + if ($orderedBottomUp === []) { + return $tier; + } + + $tier['P_to_'.$orderedBottomUp[0]] = round($amount, 4); + for ($i = 0; $i < count($orderedBottomUp); $i++) { + $code = $orderedBottomUp[$i]; + $keep = (float) ($finalProfits[$code] ?? 0); + $amount = round($amount - $keep, 4); + if ($i < count($orderedBottomUp) - 1) { + $next = $orderedBottomUp[$i + 1]; + $tier[$code.'_to_'.$next] = $amount; + } else { + $tier[$code.'_to_platform'] = $amount; + } + } + + return $tier; + } +} diff --git a/app/Services/AgentSettlement/ShareSettlementResult.php b/app/Services/AgentSettlement/ShareSettlementResult.php new file mode 100644 index 0000000..75c9055 --- /dev/null +++ b/app/Services/AgentSettlement/ShareSettlementResult.php @@ -0,0 +1,19 @@ + $shareProfits + * @param array $finalProfits + * @param array $tierSettlements + */ + public function __construct( + public readonly float $playerNetSettlement, + public readonly float $sharedNetWinLoss, + public readonly array $shareProfits, + public readonly array $finalProfits, + public readonly array $tierSettlements, + ) {} +} diff --git a/app/Services/Player/PlayerCreditService.php b/app/Services/Player/PlayerCreditService.php new file mode 100644 index 0000000..f87eb0d --- /dev/null +++ b/app/Services/Player/PlayerCreditService.php @@ -0,0 +1,100 @@ +updateOrInsert( + ['player_id' => $player->id], + [ + 'credit_limit' => $limit, + 'used_credit' => DB::raw('COALESCE(used_credit, 0)'), + 'frozen_credit' => DB::raw('COALESCE(frozen_credit, 0)'), + 'updated_at' => now(), + 'created_at' => now(), + ], + ); + } + + public function availableCredit(Player $player): int + { + $row = DB::table('player_credit_accounts')->where('player_id', $player->id)->first(); + if ($row === null) { + return 0; + } + + return max(0, (int) $row->credit_limit - (int) $row->used_credit - (int) $row->frozen_credit); + } + + public function holdForBet(Player $player, int $amount): void + { + if ($amount <= 0) { + return; + } + + if (! \App\Support\CreditLineMode::isEnabledForSiteCode((string) $player->site_code)) { + return; + } + + $available = $this->availableCredit($player); + if ($amount > $available) { + throw ValidationException::withMessages([ + 'credit' => ['insufficient'], + ]); + } + + DB::table('player_credit_accounts') + ->where('player_id', $player->id) + ->update([ + 'used_credit' => DB::raw('used_credit + '.$amount), + 'updated_at' => now(), + ]); + + DB::table('credit_ledger')->insert([ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => -$amount, + 'reason' => 'bet_hold', + 'ref_type' => 'bet', + 'ref_id' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function releaseFromSettlement(Player $player, int $amount, int $billId): void + { + if ($amount <= 0) { + return; + } + + DB::table('player_credit_accounts') + ->where('player_id', $player->id) + ->update([ + 'used_credit' => DB::raw('GREATEST(0, used_credit - '.$amount.')'), + 'updated_at' => now(), + ]); + + DB::table('credit_ledger')->insert([ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => $amount, + 'reason' => 'settlement_confirm', + 'ref_type' => 'settlement_bill', + 'ref_id' => $billId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/app/Services/Ticket/TicketPlacementService.php b/app/Services/Ticket/TicketPlacementService.php index b703027..3129d0a 100644 --- a/app/Services/Ticket/TicketPlacementService.php +++ b/app/Services/Ticket/TicketPlacementService.php @@ -16,6 +16,8 @@ use App\Exceptions\IdempotentTicketReplayException; use App\Exceptions\TicketOperationException; use App\Services\Jackpot\JackpotContributionService; use App\Services\Draw\DrawHallSnapshotBuilder; +use App\Support\CreditLineMode; +use App\Services\Player\PlayerCreditService; final class TicketPlacementService { @@ -26,6 +28,7 @@ final class TicketPlacementService private readonly TicketWalletService $ticketWalletService, private readonly JackpotContributionService $jackpotContribution, private readonly DrawHallSnapshotBuilder $drawHallSnapshot, + private readonly PlayerCreditService $playerCreditService, ) {} /** @@ -125,6 +128,7 @@ final class TicketPlacementService $resolved['play_config'], $resolved['odds_items'], ); + $evaluated = $this->applyCreditLineInstantRebatePolicy($player, $evaluated); $locks = array_map(fn (array $combo): array => [ 'number_4d' => $combo['number_4d'], @@ -297,6 +301,10 @@ final class TicketPlacementService 'status' => $failedItems === [] ? 'pending_confirm' : 'partial_pending_confirm', ])->save(); + if (CreditLineMode::isEnabledForSiteCode((string) $player->site_code)) { + $this->playerCreditService->holdForBet($player, $successTotalActualDeduct); + } + $this->ticketWalletService->reserveBetDeduct( $player, $currencyCode, @@ -501,4 +509,20 @@ final class TicketPlacementService return str_pad($number, 4, '0', STR_PAD_LEFT); } + + /** + * @param array $evaluated + * @return array + */ + private function applyCreditLineInstantRebatePolicy(Player $player, array $evaluated): array + { + if (! CreditLineMode::isEnabledForSiteCode((string) $player->site_code)) { + return $evaluated; + } + + $evaluated['rebate_rate_snapshot'] = '0.0000'; + $evaluated['actual_deduct_amount'] = (int) $evaluated['total_bet_amount']; + + return $evaluated; + } } diff --git a/app/Support/AdminAccountScopeGuard.php b/app/Support/AdminAccountScopeGuard.php new file mode 100644 index 0000000..3386e61 --- /dev/null +++ b/app/Support/AdminAccountScopeGuard.php @@ -0,0 +1,28 @@ +isAgentAccount()) { + throw ValidationException::withMessages([ + 'admin_user' => [trans('admin.agent_account_managed_in_agents')], + ]); + } + } + + public static function assertSystemRole(AdminRole $role): void + { + if (($role->scope_type ?? AdminRole::SCOPE_SYSTEM) !== AdminRole::SCOPE_SYSTEM) { + throw ValidationException::withMessages([ + 'admin_role' => [trans('admin.agent_role_managed_in_agents')], + ]); + } + } +} diff --git a/app/Support/AdminAgentLineSettlementPermissionMenuActionSync.php b/app/Support/AdminAgentLineSettlementPermissionMenuActionSync.php new file mode 100644 index 0000000..63d7c35 --- /dev/null +++ b/app/Support/AdminAgentLineSettlementPermissionMenuActionSync.php @@ -0,0 +1,99 @@ +where('code', 'view')->value('id'); + $manageActionId = DB::table('admin_action_catalog')->where('code', 'manage')->value('id'); + if ($viewActionId === null || $manageActionId === null) { + return 0; + } + + $created = 0; + + $agentMenuId = (int) DB::table('admin_menus')->where('code', 'system.agents')->value('id'); + if ($agentMenuId > 0) { + $lineMenuId = self::ensureChildMenu($agentMenuId, 'system.agents.line', '代理线路开通', $now, 'system.agents'); + $profileMenuId = self::ensureChildMenu($agentMenuId, 'system.agents.profile', '代理占成授信', $now, 'system.agents'); + $created += self::ensureMenuAction($lineMenuId, (int) $manageActionId, 'agent.line.provision', '代理线路开通', $now) ? 1 : 0; + $created += self::ensureMenuAction($profileMenuId, (int) $manageActionId, 'agent.profile.manage', '代理占成授信管理', $now) ? 1 : 0; + } + + $settlementBatchMenuId = (int) DB::table('admin_menus')->where('code', 'settlement.batch')->value('id'); + if ($settlementBatchMenuId > 0) { + $agentBillMenuId = self::ensureChildMenu( + $settlementBatchMenuId, + 'settlement.batch.agent', + '代理账单', + $now, + 'settlement.batch', + ); + $created += self::ensureMenuAction($agentBillMenuId, (int) $viewActionId, 'settlement.agent.view', '代理账单查看', $now) ? 1 : 0; + $created += self::ensureMenuAction($agentBillMenuId, (int) $manageActionId, 'settlement.agent.manage', '代理账单管理', $now) ? 1 : 0; + } + + return $created; + } + + private static function ensureChildMenu( + int $parentId, + string $code, + string $name, + Carbon $now, + string $activeMenuCode = 'system.agents', + ): int { + $existing = DB::table('admin_menus')->where('code', $code)->value('id'); + if ($existing !== null) { + return (int) $existing; + } + + return (int) DB::table('admin_menus')->insertGetId([ + 'parent_id' => $parentId, + 'menu_type' => 'button', + 'code' => $code, + 'name' => $name, + 'path' => null, + 'route_name' => null, + 'component' => null, + 'icon' => null, + 'active_menu_code' => $activeMenuCode, + 'sort_order' => 0, + 'is_visible' => false, + 'is_cache' => false, + 'is_external' => false, + 'status' => 1, + 'meta_json' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + private static function ensureMenuAction(int $menuId, int $actionId, string $permissionCode, string $name, Carbon $now): bool + { + if (DB::table('admin_menu_actions')->where('permission_code', $permissionCode)->exists()) { + return false; + } + + DB::table('admin_menu_actions')->insert([ + 'menu_id' => $menuId, + 'action_id' => $actionId, + 'permission_code' => $permissionCode, + 'name' => $name, + 'status' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + return true; + } +} diff --git a/app/Support/AdminAgentScope.php b/app/Support/AdminAgentScope.php index fb58e8b..1ff39e5 100644 --- a/app/Support/AdminAgentScope.php +++ b/app/Support/AdminAgentScope.php @@ -48,7 +48,7 @@ final class AdminAgentScope $actor = self::primaryAgentNode($admin); if ($actor === null) { - return false; + return true; } if ($player->agent_node_id === null) { diff --git a/app/Support/AdminAgentSettlementScope.php b/app/Support/AdminAgentSettlementScope.php new file mode 100644 index 0000000..025fa4a --- /dev/null +++ b/app/Support/AdminAgentSettlementScope.php @@ -0,0 +1,76 @@ +accessibleAdminSiteIds(); + if ($siteIds === null) { + return; + } + + if ($siteIds === []) { + $query->whereRaw('0 = 1'); + + return; + } + + $query->whereExists(function (Builder $sub) use ($siteIds, $billsAlias): void { + $sub->selectRaw('1') + ->from('settlement_periods') + ->whereColumn('settlement_periods.id', $billsAlias.'.settlement_period_id') + ->whereIn('settlement_periods.admin_site_id', $siteIds); + }); + } + + public static function periodAccessible(AdminUser $admin, int $settlementPeriodId): bool + { + $siteIds = $admin->accessibleAdminSiteIds(); + if ($siteIds === null) { + return true; + } + + if ($siteIds === []) { + return false; + } + + return \Illuminate\Support\Facades\DB::table('settlement_periods') + ->where('id', $settlementPeriodId) + ->whereIn('admin_site_id', $siteIds) + ->exists(); + } + + public static function siteAccessible(AdminUser $admin, int $adminSiteId): bool + { + $siteIds = $admin->accessibleAdminSiteIds(); + if ($siteIds === null) { + return true; + } + + return in_array($adminSiteId, $siteIds, true); + } + + public static function billAccessible(AdminUser $admin, int $settlementBillId): bool + { + $siteIds = $admin->accessibleAdminSiteIds(); + if ($siteIds === null) { + return true; + } + + if ($siteIds === []) { + return false; + } + + return \Illuminate\Support\Facades\DB::table('settlement_bills') + ->join('settlement_periods', 'settlement_periods.id', '=', 'settlement_bills.settlement_period_id') + ->where('settlement_bills.id', $settlementBillId) + ->whereIn('settlement_periods.admin_site_id', $siteIds) + ->exists(); + } +} diff --git a/app/Support/AdminAuthProfile.php b/app/Support/AdminAuthProfile.php index 1487086..2341802 100644 --- a/app/Support/AdminAuthProfile.php +++ b/app/Support/AdminAuthProfile.php @@ -2,8 +2,10 @@ namespace App\Support; +use App\Models\AdminSite; use App\Models\AdminUser; use App\Models\AgentNode; +use App\Models\AgentProfile; final class AdminAuthProfile { @@ -26,10 +28,13 @@ final class AdminAuthProfile * agent: ?array{ * id: int, * admin_site_id: int, + * site_code: string, * path: string, * code: string, * name: string, - * depth: int + * depth: int, + * can_create_child_agent: bool, + * can_create_player: bool * }, * is_super_admin: bool, * operational_permissions: list, @@ -56,7 +61,17 @@ final class AdminAuthProfile } /** - * @return array{id: int, admin_site_id: int, path: string, code: string, name: string, depth: int}|null + * @return array{ + * id: int, + * admin_site_id: int, + * site_code: string, + * path: string, + * code: string, + * name: string, + * depth: int, + * can_create_child_agent: bool, + * can_create_player: bool + * }|null */ private static function agentContext(AdminUser $admin): ?array { @@ -69,13 +84,19 @@ final class AdminAuthProfile return null; } + $siteCode = AdminSite::query()->where('id', (int) $node->admin_site_id)->value('code'); + $profile = AgentProfile::query()->where('agent_node_id', $node->id)->first(); + return [ 'id' => (int) $node->id, 'admin_site_id' => (int) $node->admin_site_id, + 'site_code' => is_string($siteCode) && $siteCode !== '' ? $siteCode : '', 'path' => (string) $node->path, 'code' => (string) $node->code, 'name' => (string) $node->name, 'depth' => (int) $node->depth, + 'can_create_child_agent' => $profile === null || $profile->can_create_child_agent, + 'can_create_player' => $profile === null || $profile->can_create_player, ]; } } diff --git a/app/Support/AdminAuthorizationRegistry.php b/app/Support/AdminAuthorizationRegistry.php index 8fc60fb..69e00b9 100644 --- a/app/Support/AdminAuthorizationRegistry.php +++ b/app/Support/AdminAuthorizationRegistry.php @@ -33,6 +33,10 @@ final class AdminAuthorizationRegistry ['slug' => 'prd.agent.role.manage', 'name' => '代理角色·可管理', 'nav_segment' => 'agents', 'permission_codes' => ['agent.role.manage']], ['slug' => 'prd.agent.user.view', 'name' => '代理账号·查看', 'nav_segment' => 'agents', 'permission_codes' => ['agent.user.view', 'agent.node.view']], ['slug' => 'prd.agent.user.manage', 'name' => '代理账号·可管理', 'nav_segment' => 'agents', 'permission_codes' => ['agent.user.manage']], + ['slug' => 'prd.agent-line.provision', 'name' => '代理线路·开通', 'nav_segment' => 'agents', 'permission_codes' => ['agent.line.provision']], + ['slug' => 'prd.agent.profile.manage', 'name' => '代理占成授信·可管理', 'nav_segment' => 'agents', 'permission_codes' => ['agent.profile.manage']], + ['slug' => 'prd.settlement.agent.view', 'name' => '代理账单·查看', 'nav_segment' => 'settlement', 'permission_codes' => ['settlement.agent.view']], + ['slug' => 'prd.settlement.agent.manage', 'name' => '代理账单·可管理', 'nav_segment' => 'settlement', 'permission_codes' => ['settlement.agent.manage']], ['slug' => 'prd.users.manage', 'name' => '用户管理·可管理', 'nav_segment' => 'players', 'permission_codes' => ['service.players.manage']], ['slug' => 'prd.users.view_finance', 'name' => '用户管理·财务查看', 'nav_segment' => 'players', 'permission_codes' => ['service.players.view', 'service.wallet.view']], @@ -106,7 +110,7 @@ final class AdminAuthorizationRegistry 'audit' => '审计日志', 'settings' => '系统设置', 'integration' => '接入站点', - 'agents' => '代理管理', + 'agents' => '代理线路', ]; return array_map( @@ -140,7 +144,7 @@ final class AdminAuthorizationRegistry { return [ ['segment' => 'dashboard', 'label' => 'Dashboard', 'href' => '/admin', 'nav_group' => 'overview', 'requiredAny' => ['prd.dashboard.view']], - ['segment' => 'agents', 'label' => 'Agents', 'href' => '/admin/agents', 'nav_group' => 'agent', 'activeMatchPrefix' => '/admin/agents', 'requiredAny' => ['prd.agent.view', 'prd.agent.manage', 'prd.agent.role.view', 'prd.agent.role.manage', 'prd.agent.user.view', 'prd.agent.user.manage']], + ['segment' => 'agents', 'label' => 'Agent lines', 'href' => '/admin/agents', 'nav_group' => 'agent', 'activeMatchPrefix' => '/admin/agents', 'requiredAny' => array_values(array_unique(array_merge(['prd.agent.view', 'prd.agent.manage', 'prd.agent.role.view', 'prd.agent.role.manage', 'prd.agent.user.view', 'prd.agent.user.manage', 'prd.agent-line.provision', 'prd.agent.profile.manage', 'prd.settlement.agent.view', 'prd.settlement.agent.manage'], AdminPermissionLanguage::requiredAnyPrdSlugs('integration-sites'))))], ['segment' => 'draws', 'label' => 'Draws', 'href' => '/admin/draws', 'nav_group' => 'operations', 'requiredAny' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.draw_reopen.manage']], ['segment' => 'tickets', 'label' => 'Tickets', 'href' => '/admin/tickets', 'nav_group' => 'operations', 'requiredAny' => ['prd.tickets.view']], ['segment' => 'players', 'label' => 'Players', 'href' => '/admin/players', 'nav_group' => 'operations', 'requiredAny' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.player_freeze.manage']], @@ -152,7 +156,6 @@ final class AdminAuthorizationRegistry ['segment' => 'rules_odds', 'label' => 'Odds & rebate', 'href' => '/admin/rules/odds', 'nav_group' => 'rules', 'platform_only' => true, 'requiredAny' => ['prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view']], ['segment' => 'jackpot', 'label' => 'Jackpot', 'href' => '/admin/jackpot', 'nav_group' => 'rules', 'platform_only' => true, 'activeMatchPrefix' => '/admin/jackpot', 'requiredAny' => ['prd.jackpot.manage', 'prd.jackpot.view']], ['segment' => 'risk_cap', 'label' => 'Risk cap rules', 'href' => '/admin/risk/cap', 'nav_group' => 'rules', 'platform_only' => true, 'activeMatchPrefix' => '/admin/risk/cap', 'requiredAny' => ['prd.risk_cap.manage', 'prd.risk_cap.view']], - ['segment' => 'integration', 'label' => 'Integration sites', 'href' => '/admin/config/integration-sites', 'nav_group' => 'platform', 'platform_only' => true, 'activeMatchPrefix' => '/admin/config/integration-sites', 'requiredAny' => AdminPermissionLanguage::requiredAnyPrdSlugs('integration-sites')], ['segment' => 'currencies', 'label' => 'Currencies', 'href' => '/admin/currencies', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.currency.manage']], ['segment' => 'admin_users', 'label' => 'Admin Users', 'href' => '/admin/admin-users', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.admin_user.manage']], ['segment' => 'admin_roles', 'label' => 'Admin Roles', 'href' => '/admin/admin-roles', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.admin_role.manage']], @@ -215,7 +218,16 @@ final class AdminAuthorizationRegistry 'dashboard' => ['prd.dashboard.view'], 'admin_users' => ['prd.admin_user.manage'], 'admin_roles' => ['prd.admin_role.manage'], - 'agents' => ['prd.agent.view', 'prd.agent.manage', 'prd.agent.role.view', 'prd.agent.role.manage', 'prd.agent.user.view', 'prd.agent.user.manage'], + 'agents' => [ + 'prd.agent.view', + 'prd.agent.manage', + 'prd.agent.role.view', + 'prd.agent.role.manage', + 'prd.agent.user.view', + 'prd.agent.user.manage', + 'prd.agent-line.provision', + 'prd.agent.profile.manage', + ], 'players' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.player_freeze.manage'], 'currencies' => ['prd.currency.manage'], 'wallet' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.wallet_adjust.manage', 'prd.users.view_finance'], @@ -225,7 +237,13 @@ final class AdminAuthorizationRegistry 'jackpot' => ['prd.jackpot.manage', 'prd.jackpot.view'], 'risk_cap' => ['prd.risk_cap.manage', 'prd.risk_cap.view'], 'risk' => ['prd.risk.view', 'prd.risk.manage'], - 'settlement' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view'], + 'settlement' => [ + 'prd.payout.manage', + 'prd.payout.review', + 'prd.payout.view', + 'prd.settlement.agent.view', + 'prd.settlement.agent.manage', + ], 'reconcile' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs'], 'reports' => ['prd.report.view', 'prd.report.export'], 'tickets' => ['prd.tickets.view'], @@ -386,6 +404,8 @@ final class AdminAuthorizationRegistry ['code' => 'admin.admin-roles.destroy', 'module_code' => 'system', 'name' => '删除角色', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/admin-roles/{admin_role}', 'route_name' => 'api.v1.admin.admin-roles.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_role.manage']], ['code' => 'admin.admin-roles.permissions.sync', 'module_code' => 'system', 'name' => '角色权限同步', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/admin-roles/{admin_role}/permissions', 'route_name' => 'api.v1.admin.admin-roles.permissions.sync', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_role.manage']], + ['code' => 'admin.agent-lines.store', 'module_code' => 'agent', 'name' => '开通代理线路', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/agent-lines', 'route_name' => 'api.v1.admin.agent-lines.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.line.provision'], 'legacy_permission_slugs' => ['prd.agent-line.provision', 'prd.agent.manage']], + ['code' => 'admin.agent-lines.show', 'module_code' => 'agent', 'name' => '代理线路详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-lines/{admin_site}', 'route_name' => 'api.v1.admin.agent-lines.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.line.provision', 'agent.node.view', 'agent.node.manage'], 'legacy_permission_slugs' => ['prd.agent-line.provision', 'prd.agent.view', 'prd.agent.manage']], ['code' => 'admin.agent-nodes.tree', 'module_code' => 'agent', 'name' => '代理树', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/tree', 'route_name' => 'api.v1.admin.agent-nodes.tree', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.node.view', 'agent.node.manage', 'agent.role.view', 'agent.role.manage', 'agent.user.view', 'agent.user.manage']], ['code' => 'admin.agent-nodes.store', 'module_code' => 'agent', 'name' => '创建下级代理', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/agent-nodes', 'route_name' => 'api.v1.admin.agent-nodes.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.node.manage']], ['code' => 'admin.agent-nodes.show', 'module_code' => 'agent', 'name' => '代理节点详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}', 'route_name' => 'api.v1.admin.agent-nodes.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.node.view', 'agent.node.manage']], @@ -406,6 +426,12 @@ final class AdminAuthorizationRegistry ['code' => 'admin.agent-delegation-grants.index', 'module_code' => 'agent', 'name' => '代理下放上限查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/delegation-grants', 'route_name' => 'api.v1.admin.agent-delegation-grants.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.node.view', 'agent.node.manage']], ['code' => 'admin.agent-delegation-grants.sync', 'module_code' => 'agent', 'name' => '代理下放上限同步', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/delegation-grants', 'route_name' => 'api.v1.admin.agent-delegation-grants.sync', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.node.manage']], + ['code' => 'admin.agent-nodes.profile.show', 'module_code' => 'agent', 'name' => '代理占成授信查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/profile', 'route_name' => 'api.v1.admin.agent-nodes.profile.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.profile.manage', 'agent.node.manage'], 'legacy_permission_slugs' => ['prd.agent.profile.manage', 'prd.agent.manage']], + ['code' => 'admin.agent-nodes.profile.update', 'module_code' => 'agent', 'name' => '代理占成授信更新', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/profile', 'route_name' => 'api.v1.admin.agent-nodes.profile.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.profile.manage'], 'legacy_permission_slugs' => ['prd.agent.profile.manage']], + ['code' => 'admin.settlement-periods.store', 'module_code' => 'settlement', 'name' => '创建代理账期', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-periods', 'route_name' => 'api.v1.admin.settlement-periods.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']], + ['code' => 'admin.settlement-periods.close', 'module_code' => 'settlement', 'name' => '关闭代理账期', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-periods/{settlement_period}/close', 'route_name' => 'api.v1.admin.settlement-periods.close', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']], + ['code' => 'admin.settlement-bills.index', 'module_code' => 'settlement', 'name' => '代理账单列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-bills', 'route_name' => 'api.v1.admin.settlement-bills.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']], + ['code' => 'admin.settlement-bills.confirm', 'module_code' => 'settlement', 'name' => '确认代理账单', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-bills/{settlement_bill}/confirm', 'route_name' => 'api.v1.admin.settlement-bills.confirm', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']], ['code' => 'admin.play-types.index', 'module_code' => 'config', 'name' => '玩法类型列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/play-types', 'route_name' => 'api.v1.admin.play-types.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view', 'prd.rebate.manage', 'prd.rebate.view']], ['code' => 'admin.play-types.patch', 'module_code' => 'config', 'name' => '玩法类型切换', 'http_method' => 'PATCH', 'uri_pattern' => '/api/v1/admin/play-types/{play_code}', 'route_name' => 'api.v1.admin.play-types.patch', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage']], @@ -435,7 +461,7 @@ final class AdminAuthorizationRegistry ['code' => 'admin.currencies.update', 'module_code' => 'settings', 'name' => '更新币种', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/currencies/{currency}', 'route_name' => 'api.v1.admin.currencies.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.currency.manage']], ['code' => 'admin.currencies.destroy', 'module_code' => 'settings', 'name' => '删除币种', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/currencies/{currency}', 'route_name' => 'api.v1.admin.currencies.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.currency.manage']], - ['code' => 'admin.integration-sites.index', 'module_code' => 'integration', 'name' => '接入站点列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/integration-sites', 'route_name' => 'api.v1.admin.integration-sites.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['integration.site.view', 'integration.site.manage']], + ['code' => 'admin.integration-sites.index', 'module_code' => 'integration', 'name' => '接入站点列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/integration-sites', 'route_name' => 'api.v1.admin.integration-sites.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['integration.site.view', 'integration.site.manage', 'service.players.manage']], ['code' => 'admin.integration-sites.store', 'module_code' => 'integration', 'name' => '创建接入站点', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/integration-sites', 'route_name' => 'api.v1.admin.integration-sites.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['integration.site.manage']], ['code' => 'admin.integration-sites.show', 'module_code' => 'integration', 'name' => '接入站点详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/integration-sites/{admin_site}', 'route_name' => 'api.v1.admin.integration-sites.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['integration.site.view', 'integration.site.manage']], ['code' => 'admin.integration-sites.update', 'module_code' => 'integration', 'name' => '更新接入站点', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/integration-sites/{admin_site}', 'route_name' => 'api.v1.admin.integration-sites.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['integration.site.manage']], diff --git a/app/Support/AdminSiteScope.php b/app/Support/AdminSiteScope.php index 42acac3..9aa6303 100644 --- a/app/Support/AdminSiteScope.php +++ b/app/Support/AdminSiteScope.php @@ -51,6 +51,16 @@ final class AdminSiteScope return in_array($siteCode, $allowed, true); } + public static function siteIdAllowed(AdminUser $admin, int $siteId): bool + { + $siteIds = $admin->accessibleAdminSiteIds(); + if ($siteIds === null) { + return true; + } + + return in_array($siteId, $siteIds, true); + } + public static function playerAccessible(AdminUser $admin, Player $player): bool { if (! self::siteCodeAllowed($admin, (string) $player->site_code)) { @@ -79,7 +89,10 @@ final class AdminSiteScope } $query->whereIn('site_code', $codes); - AdminAgentScope::applyToPlayerQuery($query, $admin); + + if (AdminAgentScope::primaryAgentNode($admin) !== null) { + AdminAgentScope::applyToPlayerQuery($query, $admin); + } } /** diff --git a/app/Support/AdminUserApiPresenter.php b/app/Support/AdminUserApiPresenter.php index 7e20e64..d9a3dc7 100644 --- a/app/Support/AdminUserApiPresenter.php +++ b/app/Support/AdminUserApiPresenter.php @@ -18,6 +18,7 @@ final class AdminUserApiPresenter 'nickname' => $user->name, 'email' => $user->email, 'status' => (int) $user->status, + 'account_kind' => $user->isPlatformAccount() ? 'platform' : 'agent', 'roles' => $user->adminRoleSlugs(), 'direct_permissions' => $user->directLegacyPermissionSlugs(), 'effective_permissions' => $user->adminPermissionSlugs(), diff --git a/app/Support/AgentLinePresenter.php b/app/Support/AgentLinePresenter.php new file mode 100644 index 0000000..503b7a3 --- /dev/null +++ b/app/Support/AgentLinePresenter.php @@ -0,0 +1,30 @@ + + */ + public static function provisioned(AdminSite $site, AgentNode $root, array $secrets): array + { + $sitePayload = AdminIntegrationSitePresenter::withPlainSecretsOnce( + AdminIntegrationSitePresenter::detail($site), + $secrets, + ); + + return array_merge($sitePayload, [ + 'agent_node' => AgentNodePresenter::item($root), + 'line_root' => [ + 'agent_node_id' => (int) $root->id, + 'site_code' => (string) $site->code, + 'is_line_root' => true, + ], + ]); + } +} diff --git a/app/Support/AgentNodePresenter.php b/app/Support/AgentNodePresenter.php index 5e5edc0..8ec520e 100644 --- a/app/Support/AgentNodePresenter.php +++ b/app/Support/AgentNodePresenter.php @@ -2,7 +2,9 @@ namespace App\Support; +use App\Models\AdminSite; use App\Models\AgentNode; +use Illuminate\Support\Facades\DB; final class AgentNodePresenter { @@ -16,14 +18,27 @@ final class AgentNodePresenter * code: string, * name: string, * status: int, - * is_root: bool + * is_root: bool, + * username: ?string, + * email: ?string * } */ public static function item(AgentNode $node): array { + $account = DB::table('admin_user_agents as aua') + ->join('admin_users as au', 'au.id', '=', 'aua.admin_user_id') + ->where('aua.agent_node_id', $node->id) + ->orderByDesc('aua.is_primary') + ->orderBy('aua.admin_user_id') + ->select('au.username', 'au.email') + ->first(); + + $siteCode = AdminSite::query()->where('id', $node->admin_site_id)->value('code'); + return [ 'id' => (int) $node->id, 'admin_site_id' => (int) $node->admin_site_id, + 'site_code' => $siteCode !== null ? (string) $siteCode : null, 'parent_id' => $node->parent_id !== null ? (int) $node->parent_id : null, 'path' => (string) $node->path, 'depth' => (int) $node->depth, @@ -31,6 +46,9 @@ final class AgentNodePresenter 'name' => (string) $node->name, 'status' => (int) $node->status, 'is_root' => $node->isRoot(), + 'is_line_root' => $node->isRoot(), + 'username' => $account?->username !== null ? (string) $account->username : null, + 'email' => $account?->email !== null ? (string) $account->email : null, ]; } diff --git a/app/Support/CreditLineMode.php b/app/Support/CreditLineMode.php new file mode 100644 index 0000000..36f7105 --- /dev/null +++ b/app/Support/CreditLineMode.php @@ -0,0 +1,25 @@ +where('code', $siteCode)->first(['extra_json']); + if ($site === null) { + return false; + } + + $extra = is_array($site->extra_json) ? $site->extra_json : []; + + return (bool) ($extra['credit_line_mode'] ?? false); + } +} diff --git a/app/Support/Settlement/DesignDocExample12.php b/app/Support/Settlement/DesignDocExample12.php new file mode 100644 index 0000000..86e4946 --- /dev/null +++ b/app/Support/Settlement/DesignDocExample12.php @@ -0,0 +1,72 @@ +, total: float} + */ + public static function actualShareRates(): array + { + $c = self::TOTAL_SHARE_C; + $b = self::TOTAL_SHARE_B - self::TOTAL_SHARE_C; + $a = self::TOTAL_SHARE_A - self::TOTAL_SHARE_B; + $platform = 100 - self::TOTAL_SHARE_A; + + return [ + 'actual' => [ + 'C' => $c, + 'B' => $b, + 'A' => $a, + 'platform' => $platform, + ], + 'total' => $c + $b + $a + $platform, + ]; + } +} diff --git a/database/migrations/2026_06_03_150000_align_root_agent_codes.php b/database/migrations/2026_06_03_150000_align_root_agent_codes.php new file mode 100644 index 0000000..77d1de4 --- /dev/null +++ b/database/migrations/2026_06_03_150000_align_root_agent_codes.php @@ -0,0 +1,60 @@ +orderBy('id')->get(['id', 'code']); + + foreach ($sites as $site) { + $siteId = (int) $site->id; + $code = (string) $site->code; + $legacyCode = 'root-'.$code; + + $root = DB::table('agent_nodes') + ->where('admin_site_id', $siteId) + ->where('depth', 0) + ->first(['id', 'code']); + + if ($root === null) { + continue; + } + + if ((string) $root->code === $legacyCode) { + $conflict = DB::table('agent_nodes') + ->where('admin_site_id', $siteId) + ->where('code', $code) + ->where('id', '!=', (int) $root->id) + ->exists(); + if (! $conflict) { + DB::table('agent_nodes')->where('id', (int) $root->id)->update([ + 'code' => $code, + 'updated_at' => now(), + ]); + } + } + } + } + + public function down(): void + { + $sites = DB::table('admin_sites')->orderBy('id')->get(['id', 'code']); + + foreach ($sites as $site) { + $siteId = (int) $site->id; + $code = (string) $site->code; + + DB::table('agent_nodes') + ->where('admin_site_id', $siteId) + ->where('depth', 0) + ->where('code', $code) + ->update([ + 'code' => 'root-'.$code, + 'updated_at' => now(), + ]); + } + } +}; diff --git a/database/migrations/2026_06_03_160000_agent_credit_and_settlement_tables.php b/database/migrations/2026_06_03_160000_agent_credit_and_settlement_tables.php new file mode 100644 index 0000000..cc01290 --- /dev/null +++ b/database/migrations/2026_06_03_160000_agent_credit_and_settlement_tables.php @@ -0,0 +1,140 @@ +foreignId('agent_node_id')->primary()->constrained('agent_nodes')->cascadeOnDelete(); + $table->decimal('total_share_rate', 5, 2)->default(0)->comment('总占成 0-100'); + $table->unsignedBigInteger('credit_limit')->default(0); + $table->unsignedBigInteger('allocated_credit')->default(0); + $table->unsignedBigInteger('used_credit')->default(0); + $table->decimal('rebate_limit', 8, 4)->default(0); + $table->decimal('default_player_rebate', 8, 4)->default(0); + $table->string('settlement_cycle', 16)->default('weekly'); + $table->boolean('can_grant_extra_rebate')->default(false); + $table->timestamps(); + }); + + Schema::create('player_credit_accounts', function (Blueprint $table): void { + $table->foreignId('player_id')->primary()->constrained('players')->cascadeOnDelete(); + $table->unsignedBigInteger('credit_limit')->default(0); + $table->unsignedBigInteger('used_credit')->default(0); + $table->unsignedBigInteger('frozen_credit')->default(0); + $table->timestamps(); + }); + + Schema::create('player_rebate_profiles', function (Blueprint $table): void { + $table->id(); + $table->foreignId('player_id')->constrained('players')->cascadeOnDelete(); + $table->string('game_type', 32)->default('*'); + $table->boolean('inherit_from_agent')->default(true); + $table->decimal('rebate_rate', 8, 4)->default(0); + $table->decimal('extra_rebate_rate', 8, 4)->default(0); + $table->timestamps(); + $table->unique(['player_id', 'game_type']); + }); + + Schema::create('settlement_periods', function (Blueprint $table): void { + $table->id(); + $table->foreignId('admin_site_id')->constrained('admin_sites')->cascadeOnDelete(); + $table->timestamp('period_start'); + $table->timestamp('period_end'); + $table->string('status', 16)->default('open'); + $table->timestamps(); + $table->index(['admin_site_id', 'status']); + }); + + Schema::create('settlement_bills', function (Blueprint $table): void { + $table->id(); + $table->foreignId('settlement_period_id')->constrained('settlement_periods')->cascadeOnDelete(); + $table->string('bill_type', 16); + $table->string('owner_type', 16); + $table->unsignedBigInteger('owner_id'); + $table->string('counterparty_type', 16); + $table->unsignedBigInteger('counterparty_id'); + $table->bigInteger('gross_win_loss')->default(0); + $table->bigInteger('rebate_amount')->default(0); + $table->bigInteger('adjustment_amount')->default(0); + $table->bigInteger('net_amount')->default(0); + $table->bigInteger('paid_amount')->default(0); + $table->bigInteger('unpaid_amount')->default(0); + $table->string('status', 16)->default('pending'); + $table->timestamp('confirmed_at')->nullable(); + $table->timestamps(); + $table->index(['settlement_period_id', 'bill_type']); + }); + + Schema::create('rebate_records', function (Blueprint $table): void { + $table->id(); + $table->foreignId('player_id')->constrained('players')->cascadeOnDelete(); + $table->foreignId('settlement_period_id')->nullable()->constrained('settlement_periods')->nullOnDelete(); + $table->string('game_type', 32)->default('*'); + $table->unsignedBigInteger('valid_bet_amount')->default(0); + $table->decimal('rebate_rate', 8, 4)->default(0); + $table->unsignedBigInteger('rebate_amount')->default(0); + $table->string('rebate_type', 16)->default('basic'); + $table->foreignId('owner_agent_id')->nullable()->constrained('agent_nodes')->nullOnDelete(); + $table->string('status', 16)->default('pending'); + $table->timestamps(); + }); + + Schema::create('rebate_allocations', function (Blueprint $table): void { + $table->id(); + $table->foreignId('rebate_record_id')->constrained('rebate_records')->cascadeOnDelete(); + $table->foreignId('settlement_bill_id')->nullable()->constrained('settlement_bills')->nullOnDelete(); + $table->string('participant_type', 16); + $table->unsignedBigInteger('participant_id')->default(0); + $table->decimal('actual_share_rate', 5, 2)->default(0); + $table->bigInteger('allocated_amount')->default(0); + $table->string('allocation_rule', 32)->default('share'); + $table->timestamps(); + }); + + Schema::create('payment_records', function (Blueprint $table): void { + $table->id(); + $table->foreignId('settlement_bill_id')->constrained('settlement_bills')->cascadeOnDelete(); + $table->string('payer_type', 16); + $table->unsignedBigInteger('payer_id'); + $table->string('payee_type', 16); + $table->unsignedBigInteger('payee_id'); + $table->bigInteger('amount'); + $table->string('method', 32)->nullable(); + $table->string('status', 16)->default('pending'); + $table->foreignId('created_by')->nullable()->constrained('admin_users')->nullOnDelete(); + $table->foreignId('confirmed_by')->nullable()->constrained('admin_users')->nullOnDelete(); + $table->timestamp('confirmed_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('credit_ledger', function (Blueprint $table): void { + $table->id(); + $table->string('owner_type', 16); + $table->unsignedBigInteger('owner_id'); + $table->bigInteger('amount'); + $table->string('reason', 64); + $table->string('ref_type', 32)->nullable(); + $table->unsignedBigInteger('ref_id')->nullable(); + $table->timestamps(); + $table->index(['owner_type', 'owner_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('credit_ledger'); + Schema::dropIfExists('payment_records'); + Schema::dropIfExists('rebate_allocations'); + Schema::dropIfExists('rebate_records'); + Schema::dropIfExists('settlement_bills'); + Schema::dropIfExists('settlement_periods'); + Schema::dropIfExists('player_rebate_profiles'); + Schema::dropIfExists('player_credit_accounts'); + Schema::dropIfExists('agent_profiles'); + } +}; diff --git a/database/migrations/2026_06_03_170000_seed_agent_settlement_api_resources.php b/database/migrations/2026_06_03_170000_seed_agent_settlement_api_resources.php new file mode 100644 index 0000000..b7a19d1 --- /dev/null +++ b/database/migrations/2026_06_03_170000_seed_agent_settlement_api_resources.php @@ -0,0 +1,151 @@ + */ + private const RESOURCE_CODE_PREFIXES = [ + 'admin.settlement-bills.', + 'admin.settlement-periods.', + 'admin.agent-lines.', + 'admin.agent-nodes.profile.', + ]; + + /** @var list */ + private const MENU_ACTION_CODES = [ + 'settlement.agent.view', + 'settlement.agent.manage', + 'agent.line.provision', + 'agent.profile.manage', + ]; + + public function up(): void + { + AdminAgentLineSettlementPermissionMenuActionSync::syncMissing(); + + $now = Carbon::now(); + $menuActionIds = DB::table('admin_menu_actions')->pluck('id', 'permission_code'); + + $resources = array_values(array_filter( + AdminAuthorizationRegistry::resources(), + static fn (array $resource): bool => self::matchesResourceCode((string) $resource['code']), + )); + + foreach ($resources as $resource) { + $resourceId = DB::table('admin_api_resources') + ->where('code', $resource['code']) + ->value('id'); + + $payload = [ + 'module_code' => $resource['module_code'], + 'name' => $resource['name'], + 'http_method' => $resource['http_method'], + 'uri_pattern' => $resource['uri_pattern'], + 'route_name' => $resource['route_name'], + 'auth_mode' => $resource['auth_mode'], + 'is_audit_required' => $resource['is_audit_required'], + 'status' => 1, + 'meta_json' => null, + 'updated_at' => $now, + ]; + + if ($resourceId === null) { + $resourceId = DB::table('admin_api_resources')->insertGetId($payload + [ + 'code' => $resource['code'], + 'created_at' => $now, + ]); + } else { + DB::table('admin_api_resources') + ->where('id', (int) $resourceId) + ->update($payload); + } + + 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, + ]); + } + } + + $this->grantSuperAdminMenuActions(); + } + + public function down(): void + { + foreach (AdminAuthorizationRegistry::resources() as $resource) { + if (! self::matchesResourceCode((string) $resource['code'])) { + continue; + } + + $resourceId = DB::table('admin_api_resources')->where('code', $resource['code'])->value('id'); + if ($resourceId === null) { + continue; + } + + DB::table('admin_api_resource_bindings')->where('api_resource_id', (int) $resourceId)->delete(); + DB::table('admin_api_resources')->where('id', (int) $resourceId)->delete(); + } + } + + private static function matchesResourceCode(string $code): bool + { + foreach (self::RESOURCE_CODE_PREFIXES as $prefix) { + if (str_starts_with($code, $prefix)) { + return true; + } + } + + return false; + } + + private function grantSuperAdminMenuActions(): void + { + $superRoleId = DB::table('admin_roles')->where('slug', 'super_admin')->value('id'); + if ($superRoleId === null) { + return; + } + + $menuActionIds = DB::table('admin_menu_actions') + ->whereIn('permission_code', self::MENU_ACTION_CODES) + ->pluck('id'); + + foreach ($menuActionIds as $menuActionId) { + DB::table('admin_role_menu_actions')->updateOrInsert([ + 'role_id' => (int) $superRoleId, + 'menu_action_id' => (int) $menuActionId, + ]); + } + + if (! Schema::hasTable('admin_role_legacy_permissions')) { + return; + } + + foreach (['prd.settlement.agent.view', 'prd.settlement.agent.manage', 'prd.agent-line.provision', 'prd.agent.profile.manage'] as $slug) { + DB::table('admin_role_legacy_permissions')->updateOrInsert([ + 'role_id' => (int) $superRoleId, + 'permission_slug' => $slug, + ], []); + } + } +}; diff --git a/database/migrations/2026_06_03_180000_add_agent_profile_capability_flags.php b/database/migrations/2026_06_03_180000_add_agent_profile_capability_flags.php new file mode 100644 index 0000000..5636d9b --- /dev/null +++ b/database/migrations/2026_06_03_180000_add_agent_profile_capability_flags.php @@ -0,0 +1,33 @@ +boolean('can_create_child_agent')->default(false)->after('can_grant_extra_rebate'); + $table->boolean('can_create_player')->default(true)->after('can_create_child_agent'); + }); + + \Illuminate\Support\Facades\DB::table('agent_profiles')->update([ + 'can_create_child_agent' => true, + 'can_create_player' => true, + ]); + + $nodeService = app(\App\Services\Agent\AgentNodeService::class); + \App\Models\AgentNode::query()->each(static function (\App\Models\AgentNode $node) use ($nodeService): void { + $nodeService->syncPrimaryOwnerRoleFromProfile($node); + }); + } + + public function down(): void + { + Schema::table('agent_profiles', function (Blueprint $table): void { + $table->dropColumn(['can_create_child_agent', 'can_create_player']); + }); + } +}; diff --git a/lang/en/admin.php b/lang/en/admin.php index 288539a..a4a7cac 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -13,6 +13,9 @@ return [ 'site_update_denied' => 'You cannot modify this site.', 'site_player_access_denied' => 'You do not have access to players under this site.', 'player_create_site_forbidden' => 'You cannot create players under this site.', + 'player_create_agent_required' => 'A player must belong to an agent node. Choose a valid site with an agent root, or sign in with an agent-bound account.', + 'player_create_agent_forbidden' => 'You cannot assign the player to this agent node.', + 'player_create_capability_forbidden' => 'This agent account is not allowed to create players. Ask your upline to enable the capability.', 'player_already_registered' => 'This main-site player is already registered.', 'player_wallet_balance_blocks_delete' => 'Player wallet still has balance. Clear it before deletion.', 'player_has_tickets_blocks_delete' => 'Player has ticket records and cannot be deleted.', @@ -21,10 +24,14 @@ return [ 'role_has_users_cannot_delete' => 'This role still has assigned admins and cannot be deleted.', 'agent_root_delete_denied' => 'Root agent nodes cannot be deleted.', 'agent_node_has_children_cannot_delete' => 'This agent node has child nodes. Delete children first.', + 'agent_node_has_players_cannot_delete' => 'This agent node still has players. Reassign or remove them before deleting the agent.', 'agent_node_has_users_cannot_delete' => 'This agent node still has bound admin accounts and cannot be deleted.', 'agent_node_has_roles_cannot_delete' => 'This agent node still has bound roles and cannot be deleted.', 'agent_role_in_use' => 'This role is still assigned to :count account(s). Unbind them in Accounts before deleting.', 'agent_role_read_only' => 'Read-only template roles cannot be changed or deleted.', + 'agent_account_managed_in_agents' => 'Agent accounts must be managed in Agent Operations, not in the platform accounts page.', + 'agent_role_managed_in_agents' => 'Agent roles must be managed in Agent Operations, not in the platform roles page.', + 'system_roles_only' => 'Platform accounts can only be assigned platform roles.', 'user_cannot_delete_self' => 'Cannot delete your own account.', 'user_cannot_delete_last_super_admin' => 'Cannot delete the last super admin.', 'super_admin_only_for_roles' => 'Only super admins can manage roles.', diff --git a/lang/ne/admin.php b/lang/ne/admin.php index 80192c6..382b282 100644 --- a/lang/ne/admin.php +++ b/lang/ne/admin.php @@ -13,6 +13,9 @@ return [ 'site_update_denied' => 'यो साइट सम्पादन गर्न मिल्दैन।', 'site_player_access_denied' => 'यो साइटका खेलाडीहरूमा पहुँच छैन।', 'player_create_site_forbidden' => 'यो साइटमा खेलाडी सिर्जना गर्न मिल्दैन।', + 'player_create_agent_required' => 'खेलाडी एजेन्ट नोडमा हुनुपर्छ: मान्य साइट (एजेन्ट रुट सहित) छान्नुहोस्, वा एजेन्ट खाताबाट साइन इन गर्नुहोस्।', + 'player_create_agent_forbidden' => 'यो एजेन्ट नोडमा खेलाडी तोक्न मिल्दैन।', + 'player_create_capability_forbidden' => 'यो एजेन्ट खातालाई खेलाडी सिर्जना अनुमति छैन। माथिल्लो एजेन्टलाई सम्पर्क गर्नुहोस्।', 'player_already_registered' => 'यो मुख्य साइट खेलाडी पहिले नै दर्ता भइसकेको छ।', 'player_wallet_balance_blocks_delete' => 'खेलाडी वालेटमा ब्यालेन्स छ, मेटाउनु अघि खाली गर्नुहोस्।', 'player_has_tickets_blocks_delete' => 'खेलाडीसँग टिकट रेकर्ड छ, मेटाउन मिल्दैन।', @@ -21,10 +24,14 @@ return [ 'role_has_users_cannot_delete' => 'यो भूमिकामा अझै एडमिन छ, मेटाउन मिल्दैन।', 'agent_root_delete_denied' => 'रुट एजेन्ट नोड मेटाउन मिल्दैन।', 'agent_node_has_children_cannot_delete' => 'यस एजेन्ट नोडमा चाइल्ड नोडहरू छन्, पहिले तिनीहरू हटाउनुहोस्।', + 'agent_node_has_players_cannot_delete' => 'यस एजेन्ट नोडमा अझै प्लेयरहरू छन्। मेटाउनु अघि तिनीहरू सार्नुहोस् वा हटाउनुहोस्।', 'agent_node_has_users_cannot_delete' => 'यस एजेन्ट नोडमा अझै एडमिन खाता जोडिएको छ, मेटाउन मिल्दैन।', 'agent_node_has_roles_cannot_delete' => 'यस एजेन्ट नोडमा अझै भूमिका जोडिएको छ, मेटाउन मिल्दैन।', 'agent_role_in_use' => 'यो भूमिका अझै :count खातामा प्रयोगमा छ। पहिले खाता ट्याबमा हटाउनुहोस्।', 'agent_role_read_only' => 'Read-only टेम्प्लेट भूमिका मेटाउन वा सम्पादन गर्न मिल्दैन।', + 'agent_account_managed_in_agents' => 'एजेन्ट खाता प्लेटफर्म खाता पृष्ठबाट होइन, एजेन्ट अपरेसनबाट व्यवस्थापन गर्नुपर्छ।', + 'agent_role_managed_in_agents' => 'एजेन्ट भूमिका प्लेटफर्म भूमिका पृष्ठबाट होइन, एजेन्ट अपरेसनबाट व्यवस्थापन गर्नुपर्छ।', + 'system_roles_only' => 'प्लेटफर्म खातामा प्लेटफर्म भूमिका मात्र बाँड्न सकिन्छ।', 'user_cannot_delete_self' => 'आफ्नै खाता मेटाउन मिल्दैन।', 'user_cannot_delete_last_super_admin' => 'अन्तिम सुपर एडमिन मेटाउन मिल्दैन।', 'super_admin_only_for_roles' => 'भूमिका व्यवस्थापन केवल सुपर एडमिनले गर्न सक्छ।', diff --git a/lang/zh/admin.php b/lang/zh/admin.php index 850b2af..8cbb494 100644 --- a/lang/zh/admin.php +++ b/lang/zh/admin.php @@ -12,7 +12,11 @@ return [ 'site_rotate_denied' => '无权操作该站点。', 'site_update_denied' => '无权修改该站点。', 'site_player_access_denied' => '无权访问该站点下的玩家。', + 'integration_site_store_deprecated' => '请使用「开通代理线路」创建新站点,不再支持单独创建接入站点。', 'player_create_site_forbidden' => '无权在该站点下创建玩家。', + 'player_create_agent_required' => '创建玩家须归属代理节点:请选择有效主站(须已配置代理根节点),或由代理账号操作。', + 'player_create_agent_forbidden' => '无权将玩家归属到该代理节点。', + 'player_create_capability_forbidden' => '当前代理账号未开通「创建玩家」能力,请联系上级代理调整权限。', 'player_already_registered' => '该主站玩家已在彩票平台注册。', 'player_wallet_balance_blocks_delete' => '该玩家钱包仍有余额,请先清空后再删除。', 'player_has_tickets_blocks_delete' => '该玩家存在注单记录,无法删除。', @@ -21,10 +25,14 @@ return [ 'role_has_users_cannot_delete' => '该角色下仍有关联管理员,不能删除。', 'agent_root_delete_denied' => '根节点不允许删除。', 'agent_node_has_children_cannot_delete' => '该代理节点存在下级代理,请先清空下级后再删除。', + 'agent_node_has_players_cannot_delete' => '该代理节点下仍有玩家,请先转移或删除玩家后再删除代理。', 'agent_node_has_users_cannot_delete' => '该代理节点下仍有关联账号,不能删除。', 'agent_node_has_roles_cannot_delete' => '该代理节点下仍有关联角色,不能删除。', 'agent_role_in_use' => '该角色仍有 :count 个账号在使用,请先在「账号」里解除绑定后再删除。', 'agent_role_read_only' => '只读模板角色不可删除或修改。', + 'agent_account_managed_in_agents' => '代理账号请到「代理经营」中管理,平台账号页不再支持此操作。', + 'agent_role_managed_in_agents' => '代理角色请到「代理经营」中管理,平台角色页不再支持此操作。', + 'system_roles_only' => '平台账号只能分配平台角色。', 'user_cannot_delete_self' => '不能删除当前登录账号。', 'user_cannot_delete_last_super_admin' => '不能删除最后一个超级管理员。', 'super_admin_only_for_roles' => '仅超级管理员可管理角色。', diff --git a/routes/api.php b/routes/api.php index 2e74190..3cf9851 100644 --- a/routes/api.php +++ b/routes/api.php @@ -34,6 +34,7 @@ Route::prefix('v1')->group(function (): void { require __DIR__.'/api/v1/admin/config.php'; require __DIR__.'/api/v1/admin/user.php'; require __DIR__.'/api/v1/admin/agent.php'; + require __DIR__.'/api/v1/admin/agent-settlement.php'; require __DIR__.'/api/v1/admin/report.php'; }); }); diff --git a/routes/api/v1/admin/agent-settlement.php b/routes/api/v1/admin/agent-settlement.php new file mode 100644 index 0000000..461cc89 --- /dev/null +++ b/routes/api/v1/admin/agent-settlement.php @@ -0,0 +1,19 @@ +group(function (): void { + Route::post('settlement-periods', AgentSettlementPeriodStoreController::class) + ->name('api.v1.admin.settlement-periods.store'); + Route::post('settlement-periods/{settlement_period}/close', AgentSettlementPeriodCloseController::class) + ->name('api.v1.admin.settlement-periods.close'); + Route::get('settlement-bills', AgentSettlementBillIndexController::class) + ->name('api.v1.admin.settlement-bills.index'); + Route::post('settlement-bills/{settlement_bill}/confirm', AgentSettlementBillConfirmController::class) + ->name('api.v1.admin.settlement-bills.confirm'); + }); diff --git a/routes/api/v1/admin/agent.php b/routes/api/v1/admin/agent.php index 6600968..2376759 100644 --- a/routes/api/v1/admin/agent.php +++ b/routes/api/v1/admin/agent.php @@ -18,9 +18,16 @@ use App\Http\Controllers\Api\V1\Admin\Agent\AgentAdminUserRoleSyncController; use App\Http\Controllers\Api\V1\Admin\Agent\AgentAdminUserDestroyController; use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeDelegationGrantIndexController; use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeDelegationGrantSyncController; +use App\Http\Controllers\Api\V1\Admin\Agent\AgentLineStoreController; +use App\Http\Controllers\Api\V1\Admin\Agent\AgentLineShowController; +use App\Http\Controllers\Api\V1\Admin\Agent\AgentNodeProfileController; Route::middleware('admin.api-resource') ->group(function (): void { + Route::post('agent-lines', AgentLineStoreController::class) + ->name('api.v1.admin.agent-lines.store'); + Route::get('agent-lines/{admin_site}', AgentLineShowController::class) + ->name('api.v1.admin.agent-lines.show'); Route::get('agent-nodes/tree', AgentNodeTreeController::class) ->name('api.v1.admin.agent-nodes.tree'); Route::post('agent-nodes', AgentNodeStoreController::class) @@ -37,6 +44,10 @@ Route::middleware('admin.api-resource') ->name('api.v1.admin.agent-delegation-grants.index'); Route::put('agent-nodes/{agent_node}/delegation-grants', AgentNodeDelegationGrantSyncController::class) ->name('api.v1.admin.agent-delegation-grants.sync'); + Route::get('agent-nodes/{agent_node}/profile', [AgentNodeProfileController::class, 'show']) + ->name('api.v1.admin.agent-nodes.profile.show'); + Route::put('agent-nodes/{agent_node}/profile', [AgentNodeProfileController::class, 'update']) + ->name('api.v1.admin.agent-nodes.profile.update'); Route::get('agent-nodes/{agent_node}', AgentNodeShowController::class) ->name('api.v1.admin.agent-nodes.show'); Route::put('agent-nodes/{agent_node}', AgentNodeUpdateController::class) diff --git a/tests/Feature/AdminAgentLineApiTest.php b/tests/Feature/AdminAgentLineApiTest.php new file mode 100644 index 0000000..6cbaaf9 --- /dev/null +++ b/tests/Feature/AdminAgentLineApiTest.php @@ -0,0 +1,105 @@ +artisan('lottery:admin-auth-sync')->assertExitCode(0); +}); + +test('super admin can provision agent line with aligned root code', function (): void { + $admin = AdminUser::query()->create([ + 'username' => 'line_super', + 'name' => 'Line Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $response = $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/agent-lines', [ + 'code' => 'line-alpha', + 'name' => 'Line Alpha', + 'username' => 'line_alpha_owner', + 'password' => 'secret-strong', + 'currency_code' => 'NPR', + 'status' => 1, + ]) + ->assertCreated() + ->assertJsonPath('data.code', 'line-alpha') + ->assertJsonPath('data.agent_node.code', 'line-alpha') + ->assertJsonPath('data.line_root.site_code', 'line-alpha') + ->assertJsonPath('data.secrets.sso_jwt_secret', fn ($v) => is_string($v) && $v !== '') + ->assertJsonPath('data.secrets.wallet_api_key', fn ($v) => is_string($v) && $v !== ''); + + $siteId = (int) DB::table('admin_sites')->where('code', 'line-alpha')->value('id'); + expect($siteId)->toBeGreaterThan(0); + + $root = DB::table('agent_nodes') + ->where('admin_site_id', $siteId) + ->where('depth', 0) + ->first(); + + expect($root)->not->toBeNull(); + expect((string) $root->code)->toBe('line-alpha'); + + expect( + DB::table('admin_user_agents')->where('agent_node_id', (int) $root->id)->count() + )->toBe(1); +}); + +test('non super admin cannot create integration site directly', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $admin = AdminUser::query()->create([ + 'username' => 'line_ops', + 'name' => 'Ops', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + $roleId = DB::table('admin_roles')->insertGetId([ + 'slug' => 'integration_ops', + 'code' => 'integration_ops', + 'name' => 'Integration Ops', + 'description' => null, + 'status' => 1, + 'is_system' => false, + 'sort_order' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $actionId = DB::table('admin_menu_actions') + ->where('permission_code', 'integration.site.manage') + ->value('id'); + if ($actionId !== null) { + DB::table('admin_role_menu_actions')->insert([ + 'role_id' => $roleId, + 'menu_action_id' => (int) $actionId, + ]); + } + + DB::table('admin_user_site_roles')->insert([ + 'admin_user_id' => $admin->id, + 'site_id' => $siteId, + 'role_id' => $roleId, + 'granted_at' => now(), + ]); + + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/integration-sites', [ + 'code' => 'blocked-site', + 'name' => 'Blocked', + ]) + ->assertForbidden(); +}); diff --git a/tests/Feature/AdminAgentNodeApiTest.php b/tests/Feature/AdminAgentNodeApiTest.php index 65e0202..ce51a90 100644 --- a/tests/Feature/AdminAgentNodeApiTest.php +++ b/tests/Feature/AdminAgentNodeApiTest.php @@ -168,16 +168,16 @@ test('agent operator can create child under own node but not under sibling', fun ]); grantSuperAdminRole($super); - $nodeA = $service->createChild($super, [ + $nodeA = $service->createChild($super, agentChildPayload([ 'parent_id' => $rootId, 'code' => 'branch-a2', 'name' => 'Branch A', - ]); - $nodeB = $service->createChild($super, [ + ])); + $nodeB = $service->createChild($super, agentChildPayload([ 'parent_id' => $rootId, 'code' => 'branch-b2', 'name' => 'Branch B', - ]); + ])); $operator = AdminUser::query()->create([ 'username' => 'agent_a2_ops', @@ -194,6 +194,7 @@ test('agent operator can create child under own node but not under sibling', fun 'parent_id' => $nodeA->id, 'code' => 'a-child', 'name' => 'A Child', + 'password' => agentNodeTestPassword(), ]) ->assertOk() ->assertJsonPath('data.code', 'a-child'); @@ -203,6 +204,7 @@ test('agent operator can create child under own node but not under sibling', fun 'parent_id' => $nodeB->id, 'code' => 'hack-child', 'name' => 'Hack', + 'password' => agentNodeTestPassword(), ]) ->assertForbidden(); }); diff --git a/tests/Feature/AdminAgentSettlementBillApiTest.php b/tests/Feature/AdminAgentSettlementBillApiTest.php new file mode 100644 index 0000000..dbb8bf8 --- /dev/null +++ b/tests/Feature/AdminAgentSettlementBillApiTest.php @@ -0,0 +1,33 @@ +where('route_name', 'api.v1.admin.settlement-bills.index') + ->where('status', 1) + ->exists(), + )->toBeTrue(); + + $admin = AdminUser::query()->create([ + 'username' => 'bill_super', + 'name' => 'Bill Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/settlement-bills') + ->assertOk() + ->assertJsonPath('data.items', fn ($items) => is_array($items)); +}); diff --git a/tests/Feature/AdminPlayerManageApiTest.php b/tests/Feature/AdminPlayerManageApiTest.php index a5e2680..244825f 100644 --- a/tests/Feature/AdminPlayerManageApiTest.php +++ b/tests/Feature/AdminPlayerManageApiTest.php @@ -63,6 +63,47 @@ function playerPermissionRequest($test, string $token) return $test->withHeader('Authorization', 'Bearer '.$token); } +test('super admin can create player under site root agent', function (): void { + $siteCode = DB::table('admin_sites')->where('is_default', true)->value('code'); + $siteCode = is_string($siteCode) && $siteCode !== '' ? $siteCode : 'default_site'; + + $token = playerManageAdminToken(); + + $sitePlayerId = 'manual-create-'.uniqid('', true); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/players', [ + 'site_code' => $siteCode, + 'site_player_id' => $sitePlayerId, + 'default_currency' => 'NPR', + 'status' => 0, + ]) + ->assertCreated() + ->assertJsonPath('data.site_code', $siteCode) + ->assertJsonPath('data.site_player_id', $sitePlayerId); + + $this->assertDatabaseHas('players', [ + 'site_code' => $siteCode, + 'site_player_id' => $sitePlayerId, + ]); +}); + +test('platform user without agent binding cannot create player', function (): void { + $siteCode = DB::table('admin_sites')->where('is_default', true)->value('code'); + $siteCode = is_string($siteCode) && $siteCode !== '' ? $siteCode : 'default_site'; + + $token = playerPermissionAdminToken('player_manager_no_agent', ['prd.users.manage']); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/players', [ + 'site_code' => $siteCode, + 'site_player_id' => 'blocked-create', + 'default_currency' => 'NPR', + ]) + ->assertStatus(422) + ->assertJsonPath('msg', fn (string $msg): bool => str_contains($msg, '代理') || str_contains($msg, 'agent')); +}); + test('admin can freeze and unfreeze player with audit log', function (): void { $siteCode = DB::table('admin_sites')->where('is_default', true)->value('code'); $siteCode = is_string($siteCode) && $siteCode !== '' ? $siteCode : 'default_site'; diff --git a/tests/Feature/AdminUserPermissionApiTest.php b/tests/Feature/AdminUserPermissionApiTest.php index 40bd9c8..071b442 100644 --- a/tests/Feature/AdminUserPermissionApiTest.php +++ b/tests/Feature/AdminUserPermissionApiTest.php @@ -2,10 +2,13 @@ use App\Models\AdminRole; use App\Models\AdminUser; +use App\Models\AgentNode; use App\Lottery\ErrorCode; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use App\Support\AdminPermissionBridge; +use App\Services\Agent\AgentNodeService; +use App\Services\Agent\AgentAdminUserService; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); @@ -47,7 +50,7 @@ test('admin user permission apis require rbac permission', function (): void { ->assertJsonPath('code', ErrorCode::AdminForbidden->value); }); -test('admin can list users and sync direct permissions', function (): void { +test('admin can list platform users and read effective permissions', function (): void { $token = makeAdminWithPermissions('rbac_manager', ['prd.admin_user.manage']); $target = AdminUser::query()->create([ @@ -93,23 +96,12 @@ test('admin can list users and sync direct permissions', function (): void { ->assertJsonPath('data.items.0.username', 'target_user') ->assertJsonPath('data.items.0.roles.0', 'target_role'); - $this->withHeader('Authorization', 'Bearer '.$token) - ->putJson('/api/v1/admin/admin-users/'.$target->id.'/permissions', [ - 'permission_slugs' => ['prd.report.view'], - ]) - ->assertOk() - ->assertJsonPath('code', ErrorCode::Success->value) - ->assertJsonFragment(['prd.report.view']); - - expect($target->fresh()->directLegacyPermissionSlugs())->toContain('prd.report.view'); - $list = $this->withHeader('Authorization', 'Bearer '.$token) ->getJson('/api/v1/admin/admin-users?keyword=target') ->assertOk() ->json('data.items.0.effective_permissions'); expect($list)->toContain('prd.draw_result.view'); - expect($list)->toContain('prd.report.view'); }); test('admin can sync user roles for default site', function (): void { @@ -138,6 +130,101 @@ test('admin can sync user roles for default site', function (): void { expect($slugs)->toBe(['role_sync_a', 'role_sync_b']); }); +test('platform admin users list excludes agent accounts', function (): void { + $token = makeAdminWithPermissions('platform_list_manager', ['prd.admin_user.manage']); + + $platformUser = AdminUser::query()->create([ + 'username' => 'platform_only_user', + 'name' => 'Platform Only', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + $siteId = AdminUser::defaultAdminSiteId(); + $root = AgentNode::query()->where('admin_site_id', $siteId)->where('depth', 0)->firstOrFail(); + + $super = AdminUser::query()->create([ + 'username' => 'agent_list_super', + 'name' => 'Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $branch = app(AgentNodeService::class)->createChild($super, [ + 'parent_id' => (int) $root->id, + 'code' => 'list-branch', + 'name' => 'List Branch', + ]); + + app(AgentAdminUserService::class)->createUnderAgent($branch, [ + 'username' => 'agent_hidden_user', + 'nickname' => 'Agent Hidden', + 'password' => 'secret-strong-2', + 'role_ids' => [], + ]); + + $items = $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/admin-users') + ->assertOk() + ->json('data.items'); + + expect(collect($items)->pluck('username')->all()) + ->toContain('platform_only_user') + ->not->toContain('agent_hidden_user'); +}); + +test('platform account apis reject agent accounts and agent roles', function (): void { + $token = makeAdminWithPermissions('platform_scope_manager', ['prd.admin_user.manage', 'prd.admin_role.manage']); + + $siteId = AdminUser::defaultAdminSiteId(); + $root = AgentNode::query()->where('admin_site_id', $siteId)->where('depth', 0)->firstOrFail(); + + $super = AdminUser::query()->create([ + 'username' => 'platform_guard_super', + 'name' => 'Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $branch = app(AgentNodeService::class)->createChild($super, [ + 'parent_id' => (int) $root->id, + 'code' => 'platform-guard-branch', + 'name' => 'Platform Guard Branch', + ]); + + $agentRole = AdminRole::query()->create([ + 'slug' => 'guard_agent_role', + 'name' => 'Guard Agent Role', + 'scope_type' => AdminRole::SCOPE_AGENT, + 'owner_agent_id' => $branch->id, + ]); + $agentRole->syncLegacyPermissionSlugs(['prd.agent.role.view']); + + $agentUser = app(AgentAdminUserService::class)->createUnderAgent($branch, [ + 'username' => 'guard_agent_user', + 'nickname' => 'Guard Agent User', + 'password' => 'secret-strong-3', + 'role_ids' => [(int) $agentRole->id], + ]); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/admin-users/'.$agentUser->id.'/roles', [ + 'role_slugs' => ['guard_agent_role'], + ]) + ->assertStatus(422); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/admin-roles/'.$agentRole->id.'/permissions', [ + 'permission_slugs' => ['prd.admin_role.manage'], + ]) + ->assertStatus(422); +}); + test('permission catalog groups permissions by admin navigation order', function (): void { $token = makeAdminWithPermissions('nav_group_catalog', ['prd.admin_user.manage', 'prd.admin_role.manage']); diff --git a/tests/Feature/AgentCreditSettlementExampleTest.php b/tests/Feature/AgentCreditSettlementExampleTest.php new file mode 100644 index 0000000..f54c020 --- /dev/null +++ b/tests/Feature/AgentCreditSettlementExampleTest.php @@ -0,0 +1,44 @@ +toEqual(100.0); + expect($rates['actual']['C'])->toEqual(25.0); + expect($rates['actual']['B'])->toEqual(15.0); + expect($rates['actual']['A'])->toEqual(20.0); + expect($rates['actual']['platform'])->toEqual(40.0); +}); + +test('design doc example 12 share settlement matches section 12.3 and 12.4', function (): void { + $calculator = new ShareSettlementCalculator; + $result = $calculator->calculate( + sharedNetWinLoss: DesignDocExample12::SHARED_NET_WIN_LOSS, + totalSharesByCode: [ + 'A' => DesignDocExample12::TOTAL_SHARE_A, + 'B' => DesignDocExample12::TOTAL_SHARE_B, + 'C' => DesignDocExample12::TOTAL_SHARE_C, + ], + extraRebateByCode: ['C' => DesignDocExample12::EXTRA_REBATE_BY_C], + gameWinLoss: DesignDocExample12::GAME_WIN_LOSS, + basicRebate: DesignDocExample12::BASIC_REBATE, + chainFromPlayer: ['C', 'B', 'A'], + ); + + expect($result->playerNetSettlement)->toEqual((float) DesignDocExample12::PLAYER_NET_SETTLEMENT); + expect($result->sharedNetWinLoss)->toEqual((float) DesignDocExample12::SHARED_NET_WIN_LOSS); + expect($result->shareProfits['C'])->toEqual(DesignDocExample12::SHARE_PROFIT_C); + expect($result->shareProfits['B'])->toEqual(DesignDocExample12::SHARE_PROFIT_B); + expect($result->shareProfits['A'])->toEqual(DesignDocExample12::SHARE_PROFIT_A); + expect($result->shareProfits['platform'])->toEqual(DesignDocExample12::SHARE_PROFIT_PLATFORM); + expect($result->finalProfits['C'])->toEqual(DesignDocExample12::FINAL_PROFIT_C); + expect($result->tierSettlements['P_to_C'])->toEqual(DesignDocExample12::TIER_P_TO_C); + expect($result->tierSettlements['C_to_B'])->toEqual(DesignDocExample12::TIER_C_TO_B); + expect($result->tierSettlements['B_to_A'])->toEqual(DesignDocExample12::TIER_B_TO_A); + expect($result->tierSettlements['A_to_platform'])->toEqual(DesignDocExample12::TIER_A_TO_PLATFORM); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 5bf9b9b..aa28941 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -75,6 +75,24 @@ function grantSuperAdminRole(AdminUser $admin): void } /** 为后台测试账号挂上代理节点(需已存在 agent_nodes / admin_user_agents 表)。 */ +/** Feature 测直调 {@see AgentNodeService::createChild()} 时使用的密码。 */ +function agentNodeTestPassword(): string +{ + return 'TestPass1!'; +} + +/** @param array $overrides + * @return array + */ +function agentChildPayload(array $overrides = []): array +{ + return array_merge([ + 'password' => agentNodeTestPassword(), + 'can_create_child_agent' => true, + 'can_create_player' => true, + ], $overrides); +} + function bindAdminUserToAgent(AdminUser $admin, int $agentNodeId): void { DB::table('admin_user_agents')->updateOrInsert(