diff --git a/AGENTS.md b/AGENTS.md index 66b1930..a0b5aef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,12 @@ - 非 `testing` 环境关账受 `AGENT_SETTLEMENT_ALLOW_PRODUCTION_CLOSE`(默认 `true`)控制;预发可设为 `false` 门禁。 - 占成账单聚合必须读注单**快照**(`share_snapshot`),禁止按当前 `agent_profiles` 重算历史。 +## 接入站点与超管 + +- 超管身份:`admin_users.is_super_admin`;**禁止**经 `admin_user_site_roles` 绑站;全库仅一名(DB partial unique index)。 +- 零站点:`admin_sites` 可为 0;`defaultAdminSiteId()` 无站返回 null;超管仍可登录;需站点的写操作用 `requireDefaultAdminSiteId()` 抛 `no_integration_site`。 +- 删接入站:DELETE API/UI;默认站亦可删(含最后一个);仅删除仅绑 `site_admin_{code}` 的自动账号,**不得**删超管。 + ## Learned Workspace Facts - 期号 `close_time` / `draw_time` 以 UTC 存储与比较;后台展示转浏览器本地时区,创建/编辑表单提交前须转回 UTC。 diff --git a/app/Console/Commands/PurgeAgentDataCommand.php b/app/Console/Commands/PurgeAgentDataCommand.php new file mode 100644 index 0000000..cb61789 --- /dev/null +++ b/app/Console/Commands/PurgeAgentDataCommand.php @@ -0,0 +1,147 @@ +option('dry-run'); + $force = (bool) $this->option('force'); + + $connection = (string) config('database.default'); + $database = (string) config('database.connections.'.$connection.'.database'); + $environment = (string) config('app.env'); + + $nonRootAgents = AgentNode::query() + ->where('depth', '>', 0) + ->orderByDesc('depth') + ->orderByDesc('id') + ->get(); + + $nonRootIds = $nonRootAgents->pluck('id')->map(static fn ($id): int => (int) $id)->all(); + + $rootBySite = DB::table('agent_nodes') + ->where('depth', 0) + ->get(['id', 'admin_site_id']) + ->groupBy('admin_site_id') + ->map(static fn ($rows) => (int) $rows->first()->id); + + $playersToReassign = $nonRootIds === [] + ? 0 + : (int) DB::table('players')->whereIn('agent_node_id', $nonRootIds)->count(); + + $metrics = [ + ['Database', $connection.' / '.$database], + ['Environment', $environment], + ['Non-root agents', (string) count($nonRootIds)], + ['Players to reassign to root', (string) $playersToReassign], + ['settlement_periods', (string) DB::table('settlement_periods')->count()], + ['settlement_bills', (string) DB::table('settlement_bills')->count()], + ['payment_records', (string) DB::table('payment_records')->count()], + ['settlement_adjustments', (string) DB::table('settlement_adjustments')->count()], + ['rebate_records', (string) DB::table('rebate_records')->count()], + ['share_ledger', (string) DB::table('share_ledger')->count()], + ['credit_ledger', (string) DB::table('credit_ledger')->count()], + ['agent_delegation_grants', (string) DB::table('agent_delegation_grants')->count()], + ]; + + $this->table(['Metric', 'Value'], $metrics); + + if ($nonRootAgents->isNotEmpty()) { + $this->line('Non-root agents to delete:'); + foreach ($nonRootAgents as $node) { + $this->line(sprintf( + ' - #%d depth=%d %s (%s)', + (int) $node->id, + (int) $node->depth, + (string) $node->code, + (string) $node->name, + )); + } + } + + if ($dryRun) { + $this->info('Dry run only — no changes written.'); + + return self::SUCCESS; + } + + if (! $force && ! $this->confirm('This permanently deletes agent settlement data and non-root agents. Continue?', false)) { + $this->warn('Aborted.'); + + return self::FAILURE; + } + + DB::transaction(function () use ($agentNodeService, $nonRootAgents, $nonRootIds, $rootBySite): void { + DB::table('settlement_adjustments')->delete(); + DB::table('payment_records')->delete(); + DB::table('rebate_allocations')->delete(); + DB::table('rebate_records')->delete(); + DB::table('share_ledger')->delete(); + DB::table('settlement_bills')->delete(); + DB::table('settlement_periods')->delete(); + DB::table('credit_ledger')->delete(); + + DB::table('player_credit_accounts')->update([ + 'used_credit' => 0, + 'frozen_credit' => 0, + 'updated_at' => now(), + ]); + + DB::table('agent_profiles')->update([ + 'allocated_credit' => 0, + 'used_credit' => 0, + 'updated_at' => now(), + ]); + + if ($nonRootIds !== []) { + DB::table('agent_delegation_grants') + ->whereIn('parent_agent_id', $nonRootIds) + ->orWhereIn('child_agent_id', $nonRootIds) + ->delete(); + + $players = DB::table('players') + ->whereIn('agent_node_id', $nonRootIds) + ->get(['id', 'agent_node_id', 'site_code']); + + foreach ($players as $player) { + $agentNodeId = (int) $player->agent_node_id; + $siteId = (int) (DB::table('agent_nodes')->where('id', $agentNodeId)->value('admin_site_id') ?? 0); + $rootId = $rootBySite->get($siteId); + if ($rootId === null) { + continue; + } + + DB::table('players') + ->where('id', (int) $player->id) + ->update(['agent_node_id' => $rootId]); + } + + foreach ($nonRootAgents as $node) { + $agentNodeService->destroy($node); + } + } + }); + + $this->info('Agent settlement data cleared; non-root agents removed. Root nodes kept.'); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteDestroyController.php b/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteDestroyController.php new file mode 100644 index 0000000..2004501 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteDestroyController.php @@ -0,0 +1,49 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + if (! AdminIntegrationSiteAccess::canAccess($admin, $admin_site)) { + return ApiMessage::errorResponse($request, 'admin.site_delete_denied', ErrorCode::AdminForbidden->value, null, 403); + } + + $before = AdminIntegrationSitePresenter::detail($admin_site); + $service->destroy($admin_site); + + AuditLogger::recordForAdmin( + $admin, + $request, + moduleCode: 'integration', + actionCode: 'destroy', + targetType: 'admin_site', + targetId: (string) $before['id'], + beforeJson: $before, + afterJson: null, + ); + $request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true); + + return ApiResponse::success(null); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminUserDestroyController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminUserDestroyController.php index 4b1cc2a..32aa838 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminUserDestroyController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminUserDestroyController.php @@ -26,11 +26,10 @@ final class AdminUserDestroyController extends Controller return ApiMessage::errorResponse($request, 'admin.user_cannot_delete_self', ErrorCode::ValidationFailed->value, null, 422); } - $admin_user->load('roles'); if ($admin_user->isSuperAdmin()) { $hasOther = AdminUser::query() ->whereKeyNot($admin_user->getKey()) - ->whereHas('roles', static fn ($q) => $q->where('admin_roles.slug', AdminUser::ROLE_SUPER_ADMIN)) + ->where('is_super_admin', true) ->exists(); if (! $hasOther) { return ApiMessage::errorResponse($request, 'admin.user_cannot_delete_last_super_admin', ErrorCode::ValidationFailed->value, null, 422); diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminUserPermissionSyncController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminUserPermissionSyncController.php index 5f8a1d9..fccc2da 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminUserPermissionSyncController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminUserPermissionSyncController.php @@ -25,7 +25,7 @@ final class AdminUserPermissionSyncController extends Controller (array) ($input['permissions'] ?? $input['permission_slugs'] ?? []), static fn ($v) => is_string($v) && $v !== '', ))); - $siteId = AdminUser::defaultAdminSiteId(); + $siteId = AdminUser::requireDefaultAdminSiteId(); $codes = []; foreach ($slugs as $slug) { diff --git a/app/Http/Requests/Admin/AdminAgentLineStoreRequest.php b/app/Http/Requests/Admin/AdminAgentLineStoreRequest.php index 7e116a8..4a5cce3 100644 --- a/app/Http/Requests/Admin/AdminAgentLineStoreRequest.php +++ b/app/Http/Requests/Admin/AdminAgentLineStoreRequest.php @@ -40,7 +40,7 @@ final class AdminAgentLineStoreRequest extends ApiFormRequest { return [ 'site_code' => ['required', 'string', 'max:64', 'regex:/^[a-z0-9][a-z0-9_-]*$/', Rule::exists('admin_sites', 'code')], - 'code' => ['required', 'string', 'max:64', 'regex:/^[a-z0-9][a-z0-9_-]*$/', Rule::unique('agent_nodes', 'code')], + 'code' => ['sometimes', 'nullable', 'string', 'max:64', 'regex:/^[a-z0-9][a-z0-9_-]*$/', Rule::unique('agent_nodes', 'code')], 'name' => ['required', 'string', 'max:128'], 'username' => ['required', 'string', 'max:64', Rule::unique('admin_users', 'username')], 'password' => ['required', 'string', 'min:8', 'max:128'], diff --git a/app/Models/AdminUser.php b/app/Models/AdminUser.php index f13dff0..183b0ba 100644 --- a/app/Models/AdminUser.php +++ b/app/Models/AdminUser.php @@ -29,6 +29,7 @@ final class AdminUser extends Authenticatable 'email', 'password', 'status', + 'is_super_admin', ]; protected $hidden = [ @@ -42,27 +43,40 @@ final class AdminUser extends Authenticatable 'email_verified_at' => 'datetime', 'last_login_at' => 'datetime', 'password' => 'hashed', + 'is_super_admin' => 'boolean', ]; } - public static function defaultAdminSiteId(): int + public static function defaultAdminSiteId(): ?int { static $cached = null; - if ($cached !== null) { + static $resolved = false; + if ($resolved) { return $cached; } + $resolved = true; + $id = DB::table('admin_sites')->where('is_default', true)->value('id'); if ($id === null) { $id = DB::table('admin_sites')->orderBy('id')->value('id'); } - if ($id === null) { - throw new \RuntimeException('No admin_sites row found.'); - } - $cached = (int) $id; + $cached = $id !== null ? (int) $id : null; return $cached; } + public static function requireDefaultAdminSiteId(): int + { + $siteId = self::requireDefaultAdminSiteId(); + if ($siteId === null) { + throw ValidationException::withMessages([ + 'admin_site_id' => [__('admin.no_integration_site')], + ]); + } + + return $siteId; + } + /** * 用户在各站点上的角色(多站点 RBAC)。 * @@ -188,7 +202,7 @@ final class AdminUser extends Authenticatable public function syncRoleSlugsForDefaultSite(array $slugs): void { - $siteId = self::defaultAdminSiteId(); + $siteId = self::requireDefaultAdminSiteId(); $slugs = array_values(array_unique($slugs)); $roleIds = DB::table('admin_roles') ->whereIn('slug', $slugs) @@ -225,7 +239,7 @@ final class AdminUser extends Authenticatable */ public function syncSystemRoleSlugs(array $slugs): void { - $this->syncSystemRoleSlugsForSite(self::defaultAdminSiteId(), $slugs); + $this->syncSystemRoleSlugsForSite(self::requireDefaultAdminSiteId(), $slugs); } /** @@ -236,6 +250,8 @@ final class AdminUser extends Authenticatable public function syncSystemRoleSlugsForSite(int $siteId, array $slugs): void { $slugs = array_values(array_unique($slugs)); + \App\Support\SuperAdminAccount::assertNotSiteRoleAssignment($slugs); + $roleIds = DB::table('admin_roles') ->where('scope_type', AdminRole::SCOPE_SYSTEM) ->whereIn('slug', $slugs) @@ -269,11 +285,7 @@ final class AdminUser extends Authenticatable public function isSuperAdmin(): bool { - if ($this->relationLoaded('roles')) { - return $this->roles->contains('slug', self::ROLE_SUPER_ADMIN); - } - - return $this->roles()->where('admin_roles.slug', self::ROLE_SUPER_ADMIN)->exists(); + return (bool) $this->is_super_admin; } public function primaryAgentNodeId(): ?int @@ -348,16 +360,25 @@ final class AdminUser extends Authenticatable */ public function directMenuActionPermissionCodes(): array { + if ($this->isSuperAdmin()) { + return []; + } + $siteId = self::defaultAdminSiteId(); - $rows = DB::table('admin_user_menu_actions as uma') + $query = DB::table('admin_user_menu_actions as uma') ->join('admin_menu_actions as ma', 'ma.id', '=', 'uma.menu_action_id') ->where('uma.admin_user_id', $this->id) - ->where(function ($q) use ($siteId): void { + ->where('ma.status', 1); + + if ($siteId !== null) { + $query->where(function ($q) use ($siteId): void { $q->where('uma.site_id', $siteId)->orWhereNull('uma.site_id'); - }) - ->where('ma.status', 1) - ->pluck('ma.permission_code') - ->all(); + }); + } else { + $query->whereNull('uma.site_id'); + } + + $rows = $query->pluck('ma.permission_code')->all(); $out = []; foreach ($rows as $code) { @@ -485,11 +506,17 @@ final class AdminUser extends Authenticatable { $this->loadMissing('roles'); - return $this->roles + $slugs = $this->roles ->pluck('slug') ->filter(static fn ($slug): bool => is_string($slug) && $slug !== '') ->unique() ->values() ->all(); + + if ($this->isSuperAdmin()) { + $slugs = array_values(array_unique(array_merge([self::ROLE_SUPER_ADMIN], $slugs))); + } + + return $slugs; } } diff --git a/app/Services/Admin/AdminDashboardSnapshotBuilder.php b/app/Services/Admin/AdminDashboardSnapshotBuilder.php index aaab989..dbf002b 100644 --- a/app/Services/Admin/AdminDashboardSnapshotBuilder.php +++ b/app/Services/Admin/AdminDashboardSnapshotBuilder.php @@ -17,6 +17,7 @@ use App\Support\AdminDataScope; use App\Support\AdminScopeContext; use App\Support\AdminAgentScope; use App\Support\AdminScopeContextResolver; +use App\Support\SitePlatformRole; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; /** @@ -30,6 +31,7 @@ final class AdminDashboardSnapshotBuilder private readonly DrawHallSnapshotBuilder $hallSnapshot, private readonly AdminReportQueryService $reportQuery, private readonly AgentDashboardOverviewBuilder $agentOverview, + private readonly SiteDashboardOverviewBuilder $siteOverview, ) {} /** @return array */ @@ -57,10 +59,13 @@ final class AdminDashboardSnapshotBuilder 'wallet_transfer_view' => $canWallet, ], 'agent_overview' => null, + 'site_overview' => null, ]; if ($admin->primaryAgentNode() !== null) { $out['agent_overview'] = $this->agentOverview->build($admin); + } elseif (SitePlatformRole::userHasSiteAdminRole($admin)) { + $out['site_overview'] = $this->siteOverview->build($admin, $scope); } if ($canDraw) { diff --git a/app/Services/Admin/SiteDashboardOverviewBuilder.php b/app/Services/Admin/SiteDashboardOverviewBuilder.php new file mode 100644 index 0000000..99d89ec --- /dev/null +++ b/app/Services/Admin/SiteDashboardOverviewBuilder.php @@ -0,0 +1,137 @@ +|null + */ + public function build(AdminUser $admin, AdminScopeContext $scope): ?array + { + if (! $admin->hasPermissionCode('dashboard.view')) { + return null; + } + + $site = $this->resolvePrimarySite($admin); + if ($site === null) { + return null; + } + + $siteId = (int) $site->id; + $siteCode = (string) $site->code; + $agentNodeIds = AgentNode::query() + ->where('admin_site_id', $siteId) + ->pluck('id') + ->map(static fn ($id): int => (int) $id) + ->all(); + + $playerCount = (int) Player::query()->where('site_code', $siteCode)->count(); + $today = now()->toDateString(); + $sevenDayFrom = now()->subDays(6)->toDateString(); + $scoped = AdminScopeContextResolver::fromValues( + $admin, + requestedSiteCode: $siteCode, + ); + $todayTotals = $this->reportQuery->periodFinanceTotals($today, $today, $scoped); + $sevenDayTotals = $this->reportQuery->periodFinanceTotals($sevenDayFrom, $today, $scoped); + $currencyCode = $this->reportQuery->resolvePeriodCurrencyCode($today, $today, $scoped) + ?? $this->reportQuery->resolvePeriodCurrencyCode($sevenDayFrom, $today, $scoped); + $todayActivity = $this->todayActivityStats($siteCode, $today); + $pendingBills = $this->pendingBillStats($siteId); + $topAgentToday = $this->topAgentToday($scoped, $today); + + return [ + 'admin_site_id' => $siteId, + 'site_code' => $siteCode, + 'site_name' => (string) $site->name, + 'agent_count' => count($agentNodeIds), + 'player_count' => $playerCount, + 'active_player_count_today' => $todayActivity['player_count'], + 'bet_order_count_today' => $todayActivity['order_count'], + 'today_bet_minor' => $todayTotals['total_bet_minor'], + 'today_payout_minor' => $todayTotals['total_payout_minor'], + 'today_profit_minor' => $todayTotals['approx_house_gross_minor'], + 'seven_day_bet_minor' => $sevenDayTotals['total_bet_minor'], + 'seven_day_payout_minor' => $sevenDayTotals['total_payout_minor'], + 'seven_day_profit_minor' => $sevenDayTotals['approx_house_gross_minor'], + 'profit_scope' => 'house_gross', + 'currency_code' => $currencyCode, + 'pending_bill_count' => $pendingBills['count'], + 'pending_unpaid_minor' => $pendingBills['unpaid_minor'], + 'latest_bet_at' => $todayActivity['latest_bet_at'], + 'top_agent_today' => $topAgentToday, + ]; + } + + private function resolvePrimarySite(AdminUser $admin): ?AdminSite + { + $siteIds = $admin->accessibleAdminSiteIds(); + if ($siteIds === null || $siteIds === []) { + return null; + } + + return AdminSite::query() + ->where('id', (int) $siteIds[0]) + ->first(); + } + + /** + * @return array{player_count: int, order_count: int, latest_bet_at: ?string} + */ + private function todayActivityStats(string $siteCode, string $today): array + { + $base = DB::table('ticket_orders as o') + ->join('players as p', 'p.id', '=', 'o.player_id') + ->where('p.site_code', $siteCode) + ->whereDate('o.created_at', $today); + + $latestBetAt = (clone $base)->max('o.created_at'); + + return [ + 'player_count' => (int) (clone $base)->distinct('o.player_id')->count('o.player_id'), + 'order_count' => (int) (clone $base)->count(), + 'latest_bet_at' => $latestBetAt !== null ? Carbon::parse((string) $latestBetAt)->toIso8601String() : null, + ]; + } + + /** + * @return array{count: int, unpaid_minor: int} + */ + private function pendingBillStats(int $siteId): array + { + $query = DB::table('settlement_bills as sb') + ->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id') + ->where('sp.admin_site_id', $siteId) + ->whereIn('sb.status', ['pending', 'pending_confirm', 'partial']); + + return [ + 'count' => (int) $query->count(), + 'unpaid_minor' => (int) $query->sum('sb.unpaid_amount'), + ]; + } + + /** + * @return array|null + */ + private function topAgentToday(AdminScopeContext $scope, string $today): ?array + { + $rows = $this->reportQuery->agentRankingRows($today, $today, null, 1, $scope); + + return $rows[0] ?? null; + } +} diff --git a/app/Services/Agent/AgentSiteProvisioningService.php b/app/Services/Agent/AgentSiteProvisioningService.php index 44e99e9..062dc0f 100644 --- a/app/Services/Agent/AgentSiteProvisioningService.php +++ b/app/Services/Agent/AgentSiteProvisioningService.php @@ -16,10 +16,33 @@ final class AgentSiteProvisioningService private readonly AgentProfileService $agentProfileService, ) {} + /** + * Generate a unique agent code based on site code and counter. + * Format: {site_code}-agent-{counter} + */ + private function generateUniqueAgentCode(int $siteId): string + { + $site = AdminSite::query()->find($siteId); + if ($site === null) { + throw new \RuntimeException('Site not found'); + } + + $prefix = strtolower(trim($site->code)); + $counter = 1; + + while (true) { + $code = sprintf('%s-agent-%d', $prefix, $counter); + if (!AgentNode::query()->where('code', $code)->exists()) { + return $code; + } + $counter++; + } + } + /** * 在已存在的接入站点上创建一级代理(根节点)及后台登录账号。 * - * @param array $payload site_code, code, name, username, password, email?, status?, profile fields + * @param array $payload site_code, code?, name, username, password, email?, status?, profile fields * @return array{site: AdminSite, agent_node: AgentNode} */ public function createRootAgent(AdminUser $actor, array $payload): array @@ -32,10 +55,9 @@ final class AgentSiteProvisioningService $email = isset($payload['email']) ? trim((string) $payload['email']) : null; $status = (int) ($payload['status'] ?? 1); - if ($siteCode === '' || $code === '' || $name === '' || $username === '' || $password === '') { + if ($siteCode === '' || $name === '' || $username === '' || $password === '') { throw ValidationException::withMessages([ 'site_code' => $siteCode === '' ? ['required'] : [], - 'code' => $code === '' ? ['required'] : [], 'name' => $name === '' ? ['required'] : [], 'username' => $username === '' ? ['required'] : [], 'password' => $password === '' ? ['required'] : [], @@ -47,8 +69,13 @@ final class AgentSiteProvisioningService throw ValidationException::withMessages(['site_code' => ['exists']]); } - if (AgentNode::query()->where('code', $code)->exists()) { - throw ValidationException::withMessages(['code' => ['unique']]); + // Auto-generate code if not provided + if ($code === '') { + $code = $this->generateUniqueAgentCode($site->id); + } else { + if (AgentNode::query()->where('code', $code)->exists()) { + throw ValidationException::withMessages(['code' => ['unique']]); + } } if (AdminUser::query()->where('username', $username)->exists()) { diff --git a/app/Services/Integration/IntegrationSiteService.php b/app/Services/Integration/IntegrationSiteService.php index 2e8f43c..23d80e8 100644 --- a/app/Services/Integration/IntegrationSiteService.php +++ b/app/Services/Integration/IntegrationSiteService.php @@ -5,26 +5,14 @@ namespace App\Services\Integration; use App\Models\AdminSite; use App\Models\AdminRole; use App\Models\AdminUser; -use App\Support\AdminPermissionInheritance; +use App\Models\Player; +use App\Support\SitePlatformRole; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; use Illuminate\Support\Facades\DB; final class IntegrationSiteService { - /** @var list */ - private const SITE_ADMIN_PERMISSION_SLUGS = [ - 'prd.agent.manage', - 'prd.agent.profile.manage', - 'prd.agent.user.manage', - 'prd.agent.role.manage', - 'prd.users.manage', - 'prd.tickets.view', - 'prd.report.view', - 'prd.settlement.agent.view', - 'prd.settlement.agent.manage', - ]; - public function __construct( private readonly PartnerSiteConfigResolver $configResolver, ) {} @@ -59,7 +47,7 @@ final class IntegrationSiteService 'wallet_api_key_encrypted' => encrypt($secrets['wallet_api_key']), ]); - $role = $this->createSiteAdminRole($site); + $role = SitePlatformRole::resolve(); $adminUser = $this->createSiteAdminUser($site, $role, $adminAccount); return [ @@ -108,6 +96,79 @@ final class IntegrationSiteService return $site->fresh(); } + /** + * @return array{site: AdminSite, secrets: array{sso_jwt_secret: string, wallet_api_key: string}} + */ + public function destroy(AdminSite $site): void + { + $siteCode = (string) $site->code; + $siteId = (int) $site->id; + $siteAdminRoleId = SitePlatformRole::resolve()->id; + + if (AdminSite::query()->count() <= 1) { + $fallbackSiteId = null; + } else { + $fallbackSiteId = (int) AdminSite::query() + ->where('id', '!=', $siteId) + ->orderBy('id') + ->value('id'); + } + + DB::transaction(function () use ($site, $siteCode, $siteId, $siteAdminRoleId, $fallbackSiteId): void { + $superRoleId = AdminRole::query() + ->where('slug', AdminUser::ROLE_SUPER_ADMIN) + ->value('id'); + + $platformBindings = DB::table('admin_user_site_roles') + ->where('site_id', $siteId) + ->when($siteAdminRoleId !== null, static fn ($query) => $query->where('role_id', '!=', $siteAdminRoleId)) + ->when($superRoleId !== null, static fn ($query) => $query->where('role_id', '!=', $superRoleId)) + ->get(['admin_user_id', 'role_id', 'granted_at']); + + foreach ($platformBindings as $binding) { + if ($fallbackSiteId === null) { + continue; + } + + DB::table('admin_user_site_roles')->updateOrInsert( + [ + 'admin_user_id' => (int) $binding->admin_user_id, + 'site_id' => $fallbackSiteId, + 'role_id' => (int) $binding->role_id, + ], + ['granted_at' => $binding->granted_at ?? now()], + ); + } + + Player::query()->where('site_code', $siteCode)->delete(); + + if ($siteAdminRoleId !== null) { + $siteAdminUserIds = DB::table('admin_user_site_roles') + ->where('site_id', $siteId) + ->where('role_id', $siteAdminRoleId) + ->pluck('admin_user_id'); + + foreach ($siteAdminUserIds as $userId) { + $bindings = DB::table('admin_user_site_roles') + ->where('admin_user_id', $userId) + ->get(['site_id', 'role_id']); + + $onlyAutoSiteAdmin = $bindings->count() === 1 + && (int) $bindings[0]->site_id === $siteId + && (int) $bindings[0]->role_id === (int) $siteAdminRoleId; + + if ($onlyAutoSiteAdmin) { + AdminUser::query()->where('id', $userId)->delete(); + } + } + } + + $site->delete(); + }); + + $this->configResolver->forgetCache($siteCode); + } + /** * @return array{site: AdminSite, secrets: array{sso_jwt_secret: string, wallet_api_key: string}} */ @@ -147,26 +208,6 @@ final class IntegrationSiteService return $trimmed === '' ? null : $trimmed; } - private function createSiteAdminRole(AdminSite $site): AdminRole - { - $slug = sprintf('site_admin_%s', (string) $site->code); - $role = AdminRole::query()->create([ - 'slug' => $slug, - 'name' => sprintf('%s 站点后台管理员', (string) $site->name), - 'description' => sprintf('自动创建:站点 %s (%s) 后台管理账号专用角色', (string) $site->name, (string) $site->code), - 'status' => 1, - 'is_system' => true, - 'sort_order' => 900, - 'scope_type' => AdminRole::SCOPE_SYSTEM, - ]); - - $role->syncLegacyPermissionSlugs( - AdminPermissionInheritance::expand(self::SITE_ADMIN_PERMISSION_SLUGS), - ); - - return $role; - } - /** * @param array{username: string, nickname: string, password: string, email?: string|null} $adminAccount */ @@ -191,7 +232,7 @@ final class IntegrationSiteService 'status' => 0, ]); - $user->syncSystemRoleSlugsForSite((int) $site->id, [(string) $role->slug]); + $user->syncSystemRoleSlugsForSite((int) $site->id, [SitePlatformRole::SLUG]); return $user; } diff --git a/app/Services/Wallet/PlayerLedgerLogsService.php b/app/Services/Wallet/PlayerLedgerLogsService.php index 1f02e1e..d5d74ca 100644 --- a/app/Services/Wallet/PlayerLedgerLogsService.php +++ b/app/Services/Wallet/PlayerLedgerLogsService.php @@ -31,12 +31,14 @@ final class PlayerLedgerLogsService 'prize' => ['settle_payout', 'prize', 'jackpot_manual_payout'], ]; - /** PRD 对外类型 → credit_ledger.reason */ + /** PRD 对外类型 → credit_ledger.reason(信用盘不用钱包「派彩」口径) */ private const CREDIT_TYPE_TO_REASON = [ 'bet' => ['bet_hold', 'game_settlement_loss'], 'reversal' => ['bet_hold_release'], 'refund' => ['settlement_confirm'], - 'prize' => ['game_settlement_win', 'settlement_payout'], + 'win_credit' => ['game_settlement_win'], + 'credit_release' => ['game_settlement_win', 'settlement_confirm', 'bet_hold_release'], + 'bill_settlement' => ['settlement_payout'], 'transfer_in' => [], 'transfer_out' => [], ]; @@ -413,8 +415,18 @@ final class PlayerLedgerLogsService $items = $paginator->getCollection() ->map(function (object $row) use (&$runningMinor, $player, $currency): array { $amount = (int) $row->amount; - $formatted = $this->formatPlayerCreditRow($row, $player, $currency, $runningMinor); - $runningMinor -= $amount; + $reason = (string) $row->reason; + $affectsBalance = $this->creditReasonAffectsAvailableBalance($reason); + $formatted = $this->formatPlayerCreditRow( + $row, + $player, + $currency, + $affectsBalance ? $runningMinor : null, + $affectsBalance, + ); + if ($affectsBalance) { + $runningMinor -= $amount; + } return $formatted; }) @@ -530,24 +542,30 @@ final class PlayerLedgerLogsService object $row, Player $player, string $currency, - int $balanceAfterMinor, + ?int $balanceAfterMinor, + ?bool $affectsAvailableBalance = null, ): array { $amount = (int) $row->amount; $amountAbs = abs($amount); - $publicType = $this->creditReasonToPublicType((string) $row->reason); + $reason = (string) $row->reason; + $publicType = $this->creditReasonToPublicType($reason); + $affectsBalance = $affectsAvailableBalance ?? $this->creditReasonAffectsAvailableBalance($reason); return [ 'log_id' => 'CL-'.$row->id, 'type' => $publicType, - 'biz_type' => (string) $row->reason, + 'biz_type' => $reason, 'amount' => $amount, 'amount_formatted' => CurrencyFormatter::fromMinor($amountAbs), 'amount_abs' => $amountAbs, 'amount_abs_formatted' => CurrencyFormatter::fromMinor($amountAbs), 'direction' => $amount >= 0 ? 'in' : 'out', 'currency_code' => $currency, - 'balance_after' => $balanceAfterMinor, - 'balance_after_formatted' => CurrencyFormatter::fromMinor($balanceAfterMinor), + 'balance_after' => $affectsBalance ? $balanceAfterMinor : null, + 'balance_after_formatted' => $affectsBalance && $balanceAfterMinor !== null + ? CurrencyFormatter::fromMinor($balanceAfterMinor) + : null, + 'affects_available_credit' => $affectsBalance, 'ref_id' => $this->creditRefLabel($row), 'idempotent_key' => null, 'external_ref_no' => null, @@ -560,6 +578,12 @@ final class PlayerLedgerLogsService ]; } + /** 账期收付记账不改变 player_credit_accounts,不参与可用信用倒推。 */ + private function creditReasonAffectsAvailableBalance(string $reason): bool + { + return $reason !== 'settlement_payout'; + } + /** * @return array */ @@ -611,7 +635,8 @@ final class PlayerLedgerLogsService 'bet_hold', 'game_settlement_loss' => 'bet', 'bet_hold_release' => 'reversal', 'settlement_confirm' => 'refund', - 'game_settlement_win', 'settlement_payout' => 'prize', + 'game_settlement_win' => 'win_credit', + 'settlement_payout' => 'bill_settlement', default => $reason, }; } diff --git a/app/Support/AdminAgentNodeAccess.php b/app/Support/AdminAgentNodeAccess.php index b1951b0..c093c84 100644 --- a/app/Support/AdminAgentNodeAccess.php +++ b/app/Support/AdminAgentNodeAccess.php @@ -23,6 +23,22 @@ final class AdminAgentNodeAccess ?? AdminSite::query()->orderBy('id')->value('id')); } + // Check if admin is a platform account (bound via admin_user_site_roles) + $accessibleSiteIds = $admin->accessibleAdminSiteIds(); + if ($accessibleSiteIds !== null) { + // Platform account (site admin) + if ($requestedSiteId !== null && $requestedSiteId > 0) { + if (in_array($requestedSiteId, $accessibleSiteIds, true)) { + return $requestedSiteId; + } + return null; + } + + // Return first accessible site if no specific site requested + return $accessibleSiteIds[0] ?? null; + } + + // Agent account (bound via agent node) $actor = AdminAgentScope::primaryAgentNode($admin); if ($actor === null) { return null; diff --git a/app/Support/AdminAgentScope.php b/app/Support/AdminAgentScope.php index 3fdb5fd..f321623 100644 --- a/app/Support/AdminAgentScope.php +++ b/app/Support/AdminAgentScope.php @@ -32,6 +32,14 @@ final class AdminAgentScope return true; } + // Check if admin is a platform account (bound via admin_user_site_roles) + $accessibleSiteIds = $admin->accessibleAdminSiteIds(); + if ($accessibleSiteIds !== null) { + // Platform account (site admin) can see all nodes in the site + return in_array((int) $node->admin_site_id, $accessibleSiteIds, true); + } + + // Agent account (bound via agent node) $actor = self::primaryAgentNode($admin); if ($actor === null) { return false; @@ -90,6 +98,14 @@ final class AdminAgentScope return false; } + // Check if admin is a platform account (bound via admin_user_site_roles) + $accessibleSiteIds = $admin->accessibleAdminSiteIds(); + if ($accessibleSiteIds !== null) { + // Platform account (site admin) can edit all nodes in the site + return in_array((int) $node->admin_site_id, $accessibleSiteIds, true); + } + + // Agent account (bound via agent node) $actor = self::primaryAgentNode($admin); if ($actor === null) { return false; @@ -115,6 +131,17 @@ final class AdminAgentScope return $query; } + // Check if admin is a platform account (bound via admin_user_site_roles) + $accessibleSiteIds = $admin->accessibleAdminSiteIds(); + if ($accessibleSiteIds !== null) { + // Platform account (site admin) can see all nodes in the site + if (in_array($adminSiteId, $accessibleSiteIds, true)) { + return $query; + } + return $query->whereRaw('0 = 1'); + } + + // Agent account (bound via agent node) $actor = self::primaryAgentNode($admin); if ($actor === null || (int) $actor->admin_site_id !== $adminSiteId) { return $query->whereRaw('0 = 1'); diff --git a/app/Support/AdminAuthProfile.php b/app/Support/AdminAuthProfile.php index d0a11db..986130b 100644 --- a/app/Support/AdminAuthProfile.php +++ b/app/Support/AdminAuthProfile.php @@ -38,6 +38,7 @@ final class AdminAuthProfile * can_create_child_agent: bool, * can_create_player: bool * }, + * site: ?array{id: int, code: string, name: string}, * is_super_admin: bool, * operational_permissions: list, * delegation_ceiling: list, @@ -58,6 +59,7 @@ final class AdminAuthProfile 'permissions' => $permissionSlugs, 'navigation' => AdminAuthorizationRegistry::visibleNavigationItems($permissionSlugs, $fresh), 'agent' => $agent, + 'site' => self::siteContext($fresh), 'is_super_admin' => $fresh->isSuperAdmin(), 'operational_permissions' => $permissionSlugs, 'delegation_ceiling' => AgentDelegationAuthorization::delegationLegacySlugsForAdminUser($fresh), @@ -71,19 +73,32 @@ final class AdminAuthProfile } /** - * @return array{ - * id: int, - * admin_site_id: int, - * admin_site_name: string, - * site_code: string, - * path: string, - * code: string, - * name: string, - * depth: int, - * can_create_child_agent: bool, - * can_create_player: bool - * }|null + * @return array{id: int, code: string, name: string}|null */ + private static function siteContext(AdminUser $admin): ?array + { + if ($admin->isSuperAdmin() || $admin->primaryAgentNode() !== null) { + return null; + } + + if (! SitePlatformRole::userHasSiteAdminRole($admin)) { + return null; + } + + $sites = AdminUserSiteBindingPresenter::accessibleSitesFor($admin); + if ($sites === []) { + return null; + } + + $site = $sites[0]; + + return [ + 'id' => (int) $site['id'], + 'code' => (string) $site['code'], + 'name' => (string) $site['name'], + ]; + } + private static function agentContext(AdminUser $admin): ?array { if ($admin->isSuperAdmin()) { diff --git a/app/Support/AdminAuthorizationRegistry.php b/app/Support/AdminAuthorizationRegistry.php index f44ef0e..9effc5d 100644 --- a/app/Support/AdminAuthorizationRegistry.php +++ b/app/Support/AdminAuthorizationRegistry.php @@ -462,7 +462,7 @@ final class AdminAuthorizationRegistry ['code' => 'admin.settlement-reports.summary', 'module_code' => 'settlement', 'name' => '代理结算报表摘要', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-reports/summary', 'route_name' => 'api.v1.admin.settlement-reports.summary', '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-reports.show', 'module_code' => 'settlement', 'name' => '信用占成盘报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-reports', 'route_name' => 'api.v1.admin.settlement-reports.show', '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.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.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', 'prd.report.view', 'prd.dashboard.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']], ['code' => 'admin.config.play-versions.index', 'module_code' => 'config', 'name' => '玩法版本列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/play-versions', 'route_name' => 'api.v1.admin.config.play-versions.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view']], ['code' => 'admin.config.play-versions.show', 'module_code' => 'config', 'name' => '玩法版本详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/play-versions/{id}', 'route_name' => 'api.v1.admin.config.play-versions.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view']], @@ -494,6 +494,7 @@ final class AdminAuthorizationRegistry ['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']], + ['code' => 'admin.integration-sites.destroy', 'module_code' => 'integration', 'name' => '删除接入站点', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/integration-sites/{admin_site}', 'route_name' => 'api.v1.admin.integration-sites.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['integration.site.manage']], ['code' => 'admin.integration-sites.rotate-secrets', 'module_code' => 'integration', 'name' => '重置接入密钥', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/integration-sites/{admin_site}/rotate-secrets', 'route_name' => 'api.v1.admin.integration-sites.rotate-secrets', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['integration.site.manage']], ['code' => 'admin.integration-sites.connectivity-test', 'module_code' => 'integration', 'name' => '接入站点联通检测', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/integration-sites/{admin_site}/connectivity-test', 'route_name' => 'api.v1.admin.integration-sites.connectivity-test', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['integration.site.view', 'integration.site.manage']], ['code' => 'admin.integration-sites.export', 'module_code' => 'integration', 'name' => '导出接入参数表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/integration-sites/{admin_site}/export', 'route_name' => 'api.v1.admin.integration-sites.export', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['integration.site.view', 'integration.site.manage']], diff --git a/app/Support/AdminIntegrationSitePresenter.php b/app/Support/AdminIntegrationSitePresenter.php index c27e849..f45f73d 100644 --- a/app/Support/AdminIntegrationSitePresenter.php +++ b/app/Support/AdminIntegrationSitePresenter.php @@ -29,6 +29,7 @@ final class AdminIntegrationSitePresenter 'wallet_api_key_masked' => is_string($site->wallet_api_key_encrypted) && $site->wallet_api_key_encrypted !== '' ? '••••••••' : null, + 'is_default' => (bool) $site->is_default, 'updated_at' => $site->updated_at?->toIso8601String(), ]; } @@ -38,16 +39,16 @@ final class AdminIntegrationSitePresenter */ public static function detail(AdminSite $site): array { - return array_merge(self::listItem($site), [ + return [ + ...self::listItem($site), 'wallet_debit_path' => (string) $site->wallet_debit_path, 'wallet_credit_path' => (string) $site->wallet_credit_path, 'wallet_balance_path' => (string) $site->wallet_balance_path, 'iframe_allowed_origins' => $site->iframe_allowed_origins ?? [], 'lottery_h5_base_url' => $site->lottery_h5_base_url, 'notes' => $site->notes, - 'is_default' => (bool) $site->is_default, 'created_at' => $site->created_at?->toIso8601String(), - ]); + ]; } /** diff --git a/app/Support/PlatformSystemRoles.php b/app/Support/PlatformSystemRoles.php index 1f3fce0..4903620 100644 --- a/app/Support/PlatformSystemRoles.php +++ b/app/Support/PlatformSystemRoles.php @@ -11,10 +11,12 @@ final class PlatformSystemRoles public const SLUG_AGENT = 'agent'; + public const SLUG_SITE_ADMIN = SitePlatformRole::SLUG; + /** @return list */ public static function fixedSlugs(): array { - return [self::SLUG_SUPER_ADMIN, self::SLUG_AGENT]; + return [self::SLUG_SUPER_ADMIN, self::SLUG_SITE_ADMIN, self::SLUG_AGENT]; } public static function isFixedSlug(string $slug): bool @@ -49,6 +51,7 @@ final class PlatformSystemRoles public static function ensureAll(): void { self::ensureSuperAdminRole(); + SiteAdminDefaultRolePermissions::ensurePlatformSiteAdminRole(); AgentDefaultRolePermissions::ensurePlatformAgentRole(); } } diff --git a/app/Support/SiteAdminDefaultRolePermissions.php b/app/Support/SiteAdminDefaultRolePermissions.php new file mode 100644 index 0000000..b0cf2ba --- /dev/null +++ b/app/Support/SiteAdminDefaultRolePermissions.php @@ -0,0 +1,64 @@ + */ + private const TEMPLATE_SLUGS = [ + 'prd.dashboard.view', + 'prd.agent.view', + 'prd.agent.manage', + 'prd.agent.role.view', + 'prd.agent.role.manage', + 'prd.agent.user.view', + 'prd.agent.user.manage', + 'prd.agent.profile.manage', + 'prd.users.manage', + 'prd.tickets.view', + 'prd.report.view', + 'prd.settlement.agent.view', + 'prd.settlement.agent.manage', + 'prd.integration.view', + ]; + + /** + * @return list + */ + public static function templateSlugs(): array + { + return self::TEMPLATE_SLUGS; + } + + public static function ensurePlatformSiteAdminRole(): AdminRole + { + $role = AdminRole::query()->updateOrCreate( + [ + 'slug' => SitePlatformRole::SLUG, + 'scope_type' => AdminRole::SCOPE_SYSTEM, + ], + [ + 'code' => SitePlatformRole::SLUG, + 'name' => '站点管理员', + 'description' => '接入站点后台默认权限(代理/玩家/结算运营 + 站点仪表盘)', + 'status' => 1, + 'is_system' => true, + 'sort_order' => 40, + 'owner_agent_id' => null, + 'delegated_from_role_id' => null, + ], + ); + + $role->syncLegacyPermissionSlugs( + AdminPermissionInheritance::expand(self::TEMPLATE_SLUGS), + ); + + return $role->fresh() ?? $role; + } +} diff --git a/app/Support/SitePlatformRole.php b/app/Support/SitePlatformRole.php new file mode 100644 index 0000000..59b48a5 --- /dev/null +++ b/app/Support/SitePlatformRole.php @@ -0,0 +1,54 @@ +id; + } + + public static function idOrFail(): int + { + $id = (int) (AdminRole::query() + ->where('scope_type', AdminRole::SCOPE_SYSTEM) + ->where('slug', self::SLUG) + ->where('status', 1) + ->value('id') ?? 0); + + if ($id <= 0) { + throw ValidationException::withMessages([ + 'role' => ['platform_site_admin_role_missing: run php artisan lottery:admin-auth-sync'], + ]); + } + + return $id; + } + + public static function userHasSiteAdminRole(AdminUser $user): bool + { + if ($user->isSuperAdmin() || $user->hasPrimaryAgentBinding()) { + return false; + } + + return DB::table('admin_user_site_roles as usr') + ->join('admin_roles as r', 'r.id', '=', 'usr.role_id') + ->where('usr.admin_user_id', $user->id) + ->where('r.slug', self::SLUG) + ->exists(); + } +} diff --git a/app/Support/SuperAdminAccount.php b/app/Support/SuperAdminAccount.php new file mode 100644 index 0000000..612de51 --- /dev/null +++ b/app/Support/SuperAdminAccount.php @@ -0,0 +1,63 @@ +where('id', '!=', $user->id) + ->update(['is_super_admin' => false]); + + $user->forceFill(['is_super_admin' => true])->save(); + + self::removeLegacySiteRoleBinding((int) $user->id); + + return $user->fresh() ?? $user; + }); + } + + public static function revoke(AdminUser $user): AdminUser + { + $user->forceFill(['is_super_admin' => false])->save(); + + return $user->fresh() ?? $user; + } + + public static function count(): int + { + return (int) AdminUser::query()->where('is_super_admin', true)->count(); + } + + public static function assertNotSiteRoleAssignment(array $roleSlugs): void + { + if (in_array(AdminUser::ROLE_SUPER_ADMIN, $roleSlugs, true)) { + throw ValidationException::withMessages([ + 'role_slugs' => [__('admin.super_admin_not_site_role')], + ]); + } + } + + private static function removeLegacySiteRoleBinding(int $userId): void + { + $superRoleId = DB::table('admin_roles') + ->where('slug', AdminUser::ROLE_SUPER_ADMIN) + ->value('id'); + + if ($superRoleId === null) { + return; + } + + DB::table('admin_user_site_roles') + ->where('admin_user_id', $userId) + ->where('role_id', $superRoleId) + ->delete(); + } +} diff --git a/database/migrations/2026_06_12_120000_add_is_super_admin_to_admin_users.php b/database/migrations/2026_06_12_120000_add_is_super_admin_to_admin_users.php new file mode 100644 index 0000000..0b7723b --- /dev/null +++ b/database/migrations/2026_06_12_120000_add_is_super_admin_to_admin_users.php @@ -0,0 +1,67 @@ +boolean('is_super_admin')->default(false)->after('status'); + }); + + $superRoleId = DB::table('admin_roles')->where('slug', 'super_admin')->value('id'); + if ($superRoleId !== null) { + $superUserIds = DB::table('admin_user_site_roles') + ->where('role_id', $superRoleId) + ->distinct() + ->pluck('admin_user_id'); + + foreach ($superUserIds as $userId) { + DB::table('admin_users') + ->where('id', $userId) + ->update(['is_super_admin' => true]); + } + + DB::table('admin_user_site_roles') + ->where('role_id', $superRoleId) + ->delete(); + } + + // 仅允许一名超管:若历史数据误绑多名,保留最小 id。 + $superAdminIds = DB::table('admin_users') + ->where('is_super_admin', true) + ->orderBy('id') + ->pluck('id'); + + if ($superAdminIds->count() > 1) { + $keepId = (int) $superAdminIds->first(); + DB::table('admin_users') + ->where('is_super_admin', true) + ->where('id', '!=', $keepId) + ->update(['is_super_admin' => false]); + } + + $driver = Schema::getConnection()->getDriverName(); + if ($driver === 'pgsql') { + DB::statement('CREATE UNIQUE INDEX admin_users_single_super_admin ON admin_users (is_super_admin) WHERE is_super_admin = true'); + } elseif ($driver === 'sqlite') { + DB::statement('CREATE UNIQUE INDEX admin_users_single_super_admin ON admin_users (is_super_admin) WHERE is_super_admin = 1'); + } + } + + public function down(): void + { + $driver = Schema::getConnection()->getDriverName(); + if (in_array($driver, ['pgsql', 'sqlite'], true)) { + DB::statement('DROP INDEX IF EXISTS admin_users_single_super_admin'); + } + + Schema::table('admin_users', function (Blueprint $table): void { + $table->dropColumn('is_super_admin'); + }); + } +}; diff --git a/database/migrations/2026_06_12_140000_seed_platform_site_admin_role.php b/database/migrations/2026_06_12_140000_seed_platform_site_admin_role.php new file mode 100644 index 0000000..333cf7d --- /dev/null +++ b/database/migrations/2026_06_12_140000_seed_platform_site_admin_role.php @@ -0,0 +1,47 @@ +where('scope_type', AdminRole::SCOPE_SYSTEM) + ->where('slug', 'like', 'site_admin_%') + ->where('slug', '<>', SitePlatformRole::SLUG) + ->get(['id', 'slug']); + + foreach ($legacyRoles as $legacy) { + $bindings = DB::table('admin_user_site_roles') + ->where('role_id', $legacy->id) + ->get(['admin_user_id', 'site_id', 'granted_at']); + + foreach ($bindings as $binding) { + DB::table('admin_user_site_roles')->updateOrInsert( + [ + 'admin_user_id' => (int) $binding->admin_user_id, + 'site_id' => (int) $binding->site_id, + 'role_id' => $platformRoleId, + ], + ['granted_at' => $binding->granted_at ?? now()], + ); + } + + DB::table('admin_user_site_roles')->where('role_id', $legacy->id)->delete(); + AdminRole::query()->where('id', $legacy->id)->delete(); + } + } + + public function down(): void + { + // 不回滚 per-site 角色拆分;仅保留平台 site_admin 角色。 + } +}; diff --git a/database/seeders/AdminRbacAndUserSeeder.php b/database/seeders/AdminRbacAndUserSeeder.php index de7d9e0..2723a88 100644 --- a/database/seeders/AdminRbacAndUserSeeder.php +++ b/database/seeders/AdminRbacAndUserSeeder.php @@ -7,6 +7,7 @@ use Illuminate\Database\Seeder; use App\Support\AdminAgentPermissionMenuActionSync; use App\Support\AdminDrawPermissionMenuActionSync; use App\Support\PlatformSystemRoles; +use App\Support\SuperAdminAccount; /** * 后台 RBAC:平台固定角色 super_admin / agent。 @@ -22,8 +23,6 @@ final class AdminRbacAndUserSeeder extends Seeder PlatformSystemRoles::ensureAll(); - $super = PlatformSystemRoles::ensureSuperAdminRole(); - $username = 'admin'; AdminUser::query()->updateOrCreate( ['username' => $username], @@ -37,13 +36,6 @@ final class AdminRbacAndUserSeeder extends Seeder /** @var AdminUser $admin */ $admin = AdminUser::query()->where('username', $username)->firstOrFail(); - $siteId = AdminUser::defaultAdminSiteId(); - $superId = (int) $super->getKey(); - $admin->roles()->sync([ - $superId => [ - 'site_id' => $siteId, - 'granted_at' => now(), - ], - ]); + SuperAdminAccount::assign($admin); } } diff --git a/docs/admin-rbac.md b/docs/admin-rbac.md index 729a311..f20a894 100644 --- a/docs/admin-rbac.md +++ b/docs/admin-rbac.md @@ -70,7 +70,7 @@ php artisan lottery:admin-auth-audit # 仅体检:受保护路由是 ### 路径 B:平台运营账号(单站) -1. 平台 **角色管理** 仅有两个内置角色:**超级管理员**(自动拥有全部 `prd.*`,随 `lottery:admin-auth-sync` 补齐)与 **代理**(经营主账号默认模板,可在此调整 `prd.*`)。若需更细的平台运营分工,请使用不同平台账号并绑定 **代理** 角色后按需收窄权限;勿授予 `prd.agent-line.provision`、全站接入密钥类权限。 +1. 平台 **角色管理** 有三个内置角色:**超级管理员**(平台唯一账号,`admin_users.is_super_admin`,不绑定站点,自动拥有全部 `prd.*`)、**站点管理员**(`slug=site_admin`,接入站点自动创建的后台账号默认角色,含站点仪表盘 + 代理/玩家/结算运营)、**代理**(经营主账号默认模板)。若需更细的平台运营分工,可在「平台账号」上绑定 **站点管理员** 或 **代理** 后按需收窄权限;勿授予 `prd.agent-line.provision`、全站接入密钥类权限。 2. **系统 → 平台账号 → 新建**:填写账号信息,**选择目标站点**(`admin_site_id`),勾选上一步角色。 3. 对方登录后仅见绑定站点数据;`auth/me.accessible_sites` 列出可访问站点(单站时一项)。 diff --git a/lang/en/admin.php b/lang/en/admin.php index 9bcba97..80f09d9 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -11,6 +11,10 @@ return [ 'site_access_denied' => 'You do not have access to this site.', 'site_rotate_denied' => 'You cannot rotate secrets for this site.', 'site_update_denied' => 'You cannot modify this site.', + 'site_delete_denied' => 'You cannot delete this site.', + 'integration_site_default_delete_denied' => 'The default site cannot be deleted.', + 'integration_site_last_delete_denied' => 'At least one integration site must remain; the last site cannot be deleted.', + 'no_integration_site' => 'Create an integration site first.', '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.', @@ -39,6 +43,7 @@ return [ '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.', + 'super_admin_not_site_role' => 'Super admin is a single platform account and cannot be assigned as a site role.', 'route_name_missing_for_permission' => 'Admin route is missing a route name for permission checks.', 'api_resource_not_configured' => 'Admin API resource is not configured: :route', 'api_resource_no_permission_binding' => 'Admin API resource has no permission binding: :code', diff --git a/lang/ne/admin.php b/lang/ne/admin.php index 4de0576..eed3d5e 100644 --- a/lang/ne/admin.php +++ b/lang/ne/admin.php @@ -11,6 +11,10 @@ return [ 'site_access_denied' => 'यो साइटमा पहुँच छैन।', 'site_rotate_denied' => 'यो साइटको गोप्यियता परिवर्तन गर्न मिल्दैन।', 'site_update_denied' => 'यो साइट सम्पादन गर्न मिल्दैन।', + 'site_delete_denied' => 'यो साइट मेटाउन मिल्दैन।', + 'integration_site_default_delete_denied' => 'पूर्वनिर्धारित साइट मेटाउन मिल्दैन।', + 'integration_site_last_delete_denied' => 'कम्तीमा एउटा इन्टिग्रेशन साइट राख्नुपर्छ; अन्तिम साइट मेटाउन मिल्दैन।', + 'no_integration_site' => 'पहिले इन्टिग्रेशन साइट सिर्जना गर्नुहोस्।', 'site_player_access_denied' => 'यो साइटका खेलाडीहरूमा पहुँच छैन।', 'player_create_site_forbidden' => 'यो साइटमा खेलाडी सिर्जना गर्न मिल्दैन।', 'player_create_agent_required' => 'खेलाडी एजेन्ट नोडमा हुनुपर्छ: मान्य साइट (एजेन्ट रुट सहित) छान्नुहोस्, वा एजेन्ट खाताबाट साइन इन गर्नुहोस्।', @@ -39,6 +43,7 @@ return [ 'user_cannot_delete_self' => 'आफ्नै खाता मेटाउन मिल्दैन।', 'user_cannot_delete_last_super_admin' => 'अन्तिम सुपर एडमिन मेटाउन मिल्दैन।', 'super_admin_only_for_roles' => 'भूमिका व्यवस्थापन केवल सुपर एडमिनले गर्न सक्छ।', + 'super_admin_not_site_role' => 'सुपर एडमिन एक मात्र प्लेटफर्म खाता हो; साइट भूमिकाको रूपमा назнач गर्न मिल्दैन।', 'route_name_missing_for_permission' => 'एडमिन रुटमा route name छैन, अनुमति जाँच गर्न सकिँदैन।', 'api_resource_not_configured' => 'एडमिन API स्रोत कन्फिग गरिएको छैन: :route', 'api_resource_no_permission_binding' => 'एडमिन API स्रोतमा अनुमति बाइन्डिङ छैन: :code', diff --git a/lang/zh/admin.php b/lang/zh/admin.php index d2e659b..0b36c4b 100644 --- a/lang/zh/admin.php +++ b/lang/zh/admin.php @@ -11,6 +11,10 @@ return [ 'site_access_denied' => '无权访问该站点。', 'site_rotate_denied' => '无权操作该站点。', 'site_update_denied' => '无权修改该站点。', + 'site_delete_denied' => '无权删除该站点。', + 'integration_site_default_delete_denied' => '默认站点不可删除。', + 'integration_site_last_delete_denied' => '至少保留一个接入站点,无法删除最后一个站点。', + 'no_integration_site' => '请先创建接入站点。', 'site_player_access_denied' => '无权访问该站点下的玩家。', 'integration_site_store_deprecated' => '请先在「平台配置 → 接入站点」创建站点,再在「代理配置 → 创建一级代理」绑定一级代理。', 'player_create_site_forbidden' => '无权在该站点下创建玩家。', @@ -41,6 +45,7 @@ return [ 'user_cannot_delete_self' => '不能删除当前登录账号。', 'user_cannot_delete_last_super_admin' => '不能删除最后一个超级管理员。', 'super_admin_only_for_roles' => '仅超级管理员可管理角色。', + 'super_admin_not_site_role' => '超级管理员为平台唯一账号,不能通过站点角色分配。', 'route_name_missing_for_permission' => '后台路由缺少 route name,无法执行资源鉴权。', 'api_resource_not_configured' => '后台 API 资源未配置::route', 'api_resource_no_permission_binding' => '后台 API 资源未绑定权限动作::code', diff --git a/routes/api/v1/admin/integration.php b/routes/api/v1/admin/integration.php index 20b4967..b3af864 100644 --- a/routes/api/v1/admin/integration.php +++ b/routes/api/v1/admin/integration.php @@ -9,6 +9,7 @@ use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteRotateSecr use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteConnectivityTestController; use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteExportController; use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteSecretsController; +use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteDestroyController; Route::middleware('admin.api-resource') ->group(function (): void { @@ -20,6 +21,8 @@ Route::middleware('admin.api-resource') ->name('api.v1.admin.integration-sites.show'); Route::put('integration-sites/{admin_site}', AdminIntegrationSiteUpdateController::class) ->name('api.v1.admin.integration-sites.update'); + Route::delete('integration-sites/{admin_site}', AdminIntegrationSiteDestroyController::class) + ->name('api.v1.admin.integration-sites.destroy'); Route::post('integration-sites/{admin_site}/rotate-secrets', AdminIntegrationSiteRotateSecretsController::class) ->name('api.v1.admin.integration-sites.rotate-secrets'); Route::post('integration-sites/{admin_site}/connectivity-test', AdminIntegrationSiteConnectivityTestController::class) diff --git a/tests/Feature/AdminAuthLoginTest.php b/tests/Feature/AdminAuthLoginTest.php index 5bcb922..c83c4c9 100644 --- a/tests/Feature/AdminAuthLoginTest.php +++ b/tests/Feature/AdminAuthLoginTest.php @@ -21,33 +21,7 @@ test('admin auth me returns current admin profile', function () { 'email' => null, 'password' => 'secret-strong', 'status' => 0, - ]); - - $roleId = DB::table('admin_roles')->insertGetId([ - 'code' => 'super_admin', - 'slug' => 'super_admin', - 'name' => '超级管理员', - 'description' => null, - 'status' => 1, - 'is_system' => true, - 'sort_order' => 0, - 'created_at' => now(), - 'updated_at' => now(), - ]); - $siteId = DB::table('admin_sites')->insertGetId([ - 'code' => 'default', - 'name' => '默认站点', - 'is_default' => true, - 'status' => 1, - 'created_at' => now(), - 'updated_at' => now(), - ]); - - DB::table('admin_user_site_roles')->insert([ - 'admin_user_id' => $admin->id, - 'site_id' => $siteId, - 'role_id' => $roleId, - 'granted_at' => now(), + 'is_super_admin' => true, ]); $token = $admin->createToken('admin-api', ['*'], now()->addDay())->plainTextToken; diff --git a/tests/Feature/AdminIntegrationSiteApiTest.php b/tests/Feature/AdminIntegrationSiteApiTest.php index 794a800..e33d797 100644 --- a/tests/Feature/AdminIntegrationSiteApiTest.php +++ b/tests/Feature/AdminIntegrationSiteApiTest.php @@ -447,3 +447,97 @@ test('wallet_api_url rejects private ip with path', function (): void { ->assertStatus(422) ->assertJsonPath('data.errors.wallet_api_url.0', 'wallet_api_url 必须是 https 的公开域名根地址,并拒绝 localhost/内网 IP 与带路径/查询的地址。'); }); + +test('super admin can delete integration site and cleanup related data', function (): void { + $token = integrationAdminToken(); + + $create = $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/integration-sites', [ + 'code' => 'partner-del', + 'name' => 'Partner Delete Me', + 'admin_account' => [ + 'username' => 'partner_del_admin', + 'nickname' => 'Partner Del Admin', + 'password' => 'secret-strong', + ], + ]) + ->assertCreated(); + + $id = (int) $create->json('data.id'); + + Player::query()->create([ + 'site_code' => 'partner-del', + 'site_player_id' => '90001', + 'username' => 'partner_del_player', + 'nickname' => 'Partner Del Player', + 'default_currency' => 'NPR', + 'status' => 1, + ]); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->deleteJson('/api/v1/admin/integration-sites/'.$id) + ->assertOk() + ->assertJsonPath('code', 0); + + expect(AdminSite::query()->where('code', 'partner-del')->exists())->toBeFalse(); + expect(Player::query()->where('site_code', 'partner-del')->exists())->toBeFalse(); + expect(AdminUser::query()->where('username', 'partner_del_admin')->exists())->toBeFalse(); + expect(DB::table('admin_roles')->where('slug', 'site_admin')->exists())->toBeTrue(); + expect(DB::table('admin_roles')->where('slug', 'site_admin_partner-del')->exists())->toBeFalse(); + + expect( + AuditLog::query() + ->where('module_code', 'integration') + ->where('action_code', 'destroy') + ->where('target_id', (string) $id) + ->exists() + )->toBeTrue(); +}); + +test('super admin can delete default integration site when another site exists', function (): void { + $token = integrationAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/integration-sites', [ + 'code' => 'partner-keep', + 'name' => 'Partner Keep', + 'admin_account' => [ + 'username' => 'partner_keep_admin', + 'nickname' => 'Partner Keep Admin', + 'password' => 'secret-strong', + ], + ]) + ->assertCreated(); + + $defaultSite = AdminSite::query()->where('is_default', true)->firstOrFail(); + $defaultSiteId = (int) $defaultSite->id; + $superAdminId = (int) AdminUser::query()->where('username', 'integration_admin')->value('id'); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->deleteJson('/api/v1/admin/integration-sites/'.$defaultSiteId) + ->assertOk() + ->assertJsonPath('code', 0); + + expect(AdminSite::query()->where('id', $defaultSiteId)->exists())->toBeFalse(); + expect(AdminUser::query()->where('id', $superAdminId)->exists())->toBeTrue(); + expect(AdminUser::query()->where('id', $superAdminId)->value('is_super_admin'))->toBeTruthy(); +}); + +test('super admin can delete last integration site and remain authenticated', function (): void { + $token = integrationAdminToken(); + + foreach (AdminSite::query()->orderBy('id')->pluck('id') as $siteId) { + $this->withHeader('Authorization', 'Bearer '.$token) + ->deleteJson('/api/v1/admin/integration-sites/'.$siteId) + ->assertOk() + ->assertJsonPath('code', 0); + } + + expect(AdminSite::query()->count())->toBe(0); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/auth/me') + ->assertOk() + ->assertJsonPath('data.admin.is_super_admin', true) + ->assertJsonPath('data.admin.accessible_sites', []); +}); diff --git a/tests/Feature/AdminSiteDashboardOverviewTest.php b/tests/Feature/AdminSiteDashboardOverviewTest.php new file mode 100644 index 0000000..5242a30 --- /dev/null +++ b/tests/Feature/AdminSiteDashboardOverviewTest.php @@ -0,0 +1,70 @@ +artisan('lottery:admin-auth-sync')->assertExitCode(0); +}); + +test('site admin dashboard returns site overview for operator with dashboard permission', function (): void { + $super = AdminUser::query()->create([ + 'username' => 'super_site_dash', + 'name' => 'Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $create = $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/integration-sites', [ + 'code' => 'site-dash', + 'name' => 'Site Dash', + 'admin_account' => [ + 'username' => 'site_dash_admin', + 'nickname' => 'Site Dash Admin', + 'password' => 'secret-strong', + ], + ]) + ->assertCreated(); + + $siteId = (int) $create->json('data.id'); + $operator = AdminUser::query()->where('username', 'site_dash_admin')->firstOrFail(); + $roleId = SitePlatformRole::id(); + + expect((int) DB::table('admin_user_site_roles') + ->where('admin_user_id', $operator->id) + ->where('site_id', $siteId) + ->where('role_id', $roleId) + ->count())->toBe(1); + + expect(SitePlatformRole::userHasSiteAdminRole($operator))->toBeTrue(); + expect(AdminAuthProfile::fromAdmin($operator)['site']['code'] ?? null)->toBe('site-dash'); + + $operatorToken = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken; + app('auth')->forgetGuards(); + + $this->withHeader('Authorization', 'Bearer '.$operatorToken) + ->getJson('/api/v1/admin/auth/me') + ->assertOk() + ->assertJsonPath('data.admin.id', $operator->id) + ->assertJsonPath('data.admin.site.code', 'site-dash') + ->assertJsonPath('data.admin.agent', null); + + $this->withHeader('Authorization', 'Bearer '.$operatorToken) + ->getJson('/api/v1/admin/dashboard') + ->assertOk() + ->assertJsonPath('data.site_overview.admin_site_id', $siteId) + ->assertJsonPath('data.site_overview.site_code', 'site-dash') + ->assertJsonPath('data.agent_overview', null); +}); diff --git a/tests/Feature/CreditWalletLogsTest.php b/tests/Feature/CreditWalletLogsTest.php index f57d6da..793b563 100644 --- a/tests/Feature/CreditWalletLogsTest.php +++ b/tests/Feature/CreditWalletLogsTest.php @@ -57,3 +57,80 @@ test('credit player wallet logs reads credit_ledger not wallet_txns', function ( ->assertJsonPath('data.items.0.biz_type', 'bet_hold') ->assertJsonPath('data.items.0.ledger_source', 'credit_ledger'); }); + +test('credit player wallet logs distinguish win credit from bill settlement', function (): void { + $player = Player::query()->create([ + 'site_code' => 'default_site', + 'site_player_id' => 'native:logs-2', + 'auth_source' => PlayerAuthSource::LOTTERY_NATIVE, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'credit_logs_2', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + DB::table('player_credit_accounts')->insert([ + 'player_id' => $player->id, + 'credit_limit' => 500, + 'used_credit' => 0, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('credit_ledger')->insert([ + [ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => 3600, + 'reason' => 'settlement_payout', + 'ref_type' => 'settlement_bill', + 'ref_id' => 25, + 'created_at' => now()->subMinute(), + 'updated_at' => now()->subMinute(), + ], + [ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => 1200, + 'reason' => 'game_settlement_win', + 'ref_type' => 'ticket_item', + 'ref_id' => 99, + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + $response = $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/wallet/logs?page=1&size=10'); + + $response->assertOk() + ->assertJsonPath('data.total', 2) + ->assertJsonPath('data.items.0.type', 'win_credit') + ->assertJsonPath('data.items.0.biz_type', 'game_settlement_win') + ->assertJsonPath('data.items.0.affects_available_credit', true) + ->assertJsonPath('data.items.1.type', 'bill_settlement') + ->assertJsonPath('data.items.1.biz_type', 'settlement_payout') + ->assertJsonPath('data.items.1.affects_available_credit', false) + ->assertJsonPath('data.items.1.balance_after', null); + + expect($response->json('data.items.0.balance_after'))->not->toBeNull(); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/wallet/logs?type=bill_settlement') + ->assertOk() + ->assertJsonPath('data.total', 1) + ->assertJsonPath('data.items.0.type', 'bill_settlement'); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/wallet/logs?type=win_credit') + ->assertOk() + ->assertJsonPath('data.total', 1) + ->assertJsonPath('data.items.0.type', 'win_credit'); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/wallet/logs?type=credit_release') + ->assertOk() + ->assertJsonPath('data.total', 1) + ->assertJsonPath('data.items.0.type', 'win_credit'); +}); diff --git a/tests/Feature/PlatformSystemRolesTest.php b/tests/Feature/PlatformSystemRolesTest.php index f9e4f49..5d6a786 100644 --- a/tests/Feature/PlatformSystemRolesTest.php +++ b/tests/Feature/PlatformSystemRolesTest.php @@ -49,7 +49,7 @@ test('platform role index only lists fixed super_admin and agent roles', functio ->pluck('slug') ->all(); - expect($slugs)->toBe(['super_admin', 'agent']); + expect($slugs)->toBe(['super_admin', 'site_admin', 'agent']); }); test('platform roles cannot be created and super_admin permissions are full catalog', function (): void { diff --git a/tests/Pest.php b/tests/Pest.php index d85bdd8..70fdb26 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -50,30 +50,11 @@ expect()->extend('toBeOne', function () { | */ -/** 为后台测试账号挂上 `super_admin` 角色(细粒度权限校验全放行)。 */ +/** 为后台测试账号挂上唯一超级管理员(不绑定站点)。 */ function grantSuperAdminRole(AdminUser $admin): void { - $now = now(); - DB::table('admin_roles')->updateOrInsert( - ['slug' => AdminUser::ROLE_SUPER_ADMIN], - [ - 'name' => 'Super Admin', - 'code' => AdminUser::ROLE_SUPER_ADMIN, - 'created_at' => $now, - 'updated_at' => $now, - ], - ); - $rid = (int) DB::table('admin_roles')->where('slug', AdminUser::ROLE_SUPER_ADMIN)->value('id'); - $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); - - DB::table('admin_user_site_roles')->updateOrInsert( - [ - 'admin_user_id' => $admin->id, - 'site_id' => $siteId, - 'role_id' => $rid, - ], - ['granted_at' => $now], - ); + \App\Support\PlatformSystemRoles::ensureSuperAdminRole(); + \App\Support\SuperAdminAccount::assign($admin); } /** 为后台测试账号挂上代理节点(需已存在 agent_nodes / admin_user_agents 表)。 */ diff --git a/tests/Unit/SiteAdminDefaultRolePermissionsTest.php b/tests/Unit/SiteAdminDefaultRolePermissionsTest.php new file mode 100644 index 0000000..cac4be8 --- /dev/null +++ b/tests/Unit/SiteAdminDefaultRolePermissionsTest.php @@ -0,0 +1,13 @@ +toContain('prd.dashboard.view') + ->toContain('prd.agent.manage') + ->toContain('prd.settlement.agent.manage') + ->toContain('prd.report.view'); +});