feat: refactor super admin to use is_super_admin flag and enhance site deletion logic

- Changed super admin detection from role-based to `is_super_admin` flag in AdminUser model
- Added `requireDefaultAdminSiteId()` method to throw validation error when no integration site exists
- Enhanced site deletion to migrate platform role bindings to fallback site and auto-delete site-specific admin accounts
- Made agent line code optional with auto-generation fallback using `{site_code}-agent-{counter}` format
This commit is contained in:
2026-06-12 20:47:40 +08:00
parent 980f3c9593
commit 395e1c7400
36 changed files with 1193 additions and 153 deletions

View File

@@ -0,0 +1,147 @@
<?php
namespace App\Console\Commands;
use App\Models\AgentNode;
use App\Services\Agent\AgentNodeService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* 清空信用占成盘代理业务数据,并删除所有非根代理节点(保留各站点 depth=0 根节点)。
*
* 不删:期号、注单、钱包玩家、站点财务账号。
*/
final class PurgeAgentDataCommand extends Command
{
protected $signature = 'lottery:purge-agent-data
{--dry-run : 只预览,不写入}
{--force : 跳过交互确认(危险)}';
protected $description = '清空代理账期/授信流水,删除全部非根代理(保留站点根节点)';
public function handle(AgentNodeService $agentNodeService): int
{
$dryRun = (bool) $this->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;
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Integration;
use App\Models\AdminSite;
use App\Support\ApiMessage;
use App\Support\ApiResponse;
use App\Lottery\ErrorCode;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Services\AuditLogger;
use App\Services\Integration\IntegrationSiteService;
use App\Support\AdminIntegrationSiteAccess;
use App\Support\AdminIntegrationSitePresenter;
use App\Http\Middleware\RecordAdminApiAudit;
final class AdminIntegrationSiteDestroyController extends Controller
{
public function __invoke(
Request $request,
AdminSite $admin_site,
IntegrationSiteService $service,
): JsonResponse {
$admin = $request->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);
}
}

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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'],

View File

@@ -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;
}
}

View File

@@ -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<string, mixed> */
@@ -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) {

View File

@@ -0,0 +1,137 @@
<?php
namespace App\Services\Admin;
use App\Models\AdminSite;
use App\Models\AdminUser;
use App\Models\AgentNode;
use App\Models\Player;
use App\Support\AdminScopeContext;
use App\Support\AdminScopeContextResolver;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
/** 站点管理员仪表盘:站点规模、今日/近 7 日经营、待结账单摘要。 */
final class SiteDashboardOverviewBuilder
{
public function __construct(
private readonly AdminReportQueryService $reportQuery,
) {}
/**
* @return array<string, mixed>|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<string, mixed>|null
*/
private function topAgentToday(AdminScopeContext $scope, string $today): ?array
{
$rows = $this->reportQuery->agentRankingRows($today, $today, null, 1, $scope);
return $rows[0] ?? null;
}
}

View File

@@ -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<string, mixed> $payload site_code, code, name, username, password, email?, status?, profile fields
* @param array<string, mixed> $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()) {

View File

@@ -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<string> */
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;
}

View File

@@ -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<string, mixed>
*/
@@ -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,
};
}

View File

@@ -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;

View File

@@ -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');

View File

@@ -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<string>,
* delegation_ceiling: list<string>,
@@ -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()) {

View File

@@ -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']],

View File

@@ -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(),
]);
];
}
/**

View File

@@ -11,10 +11,12 @@ final class PlatformSystemRoles
public const SLUG_AGENT = 'agent';
public const SLUG_SITE_ADMIN = SitePlatformRole::SLUG;
/** @return list<string> */
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();
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Support;
use App\Models\AdminRole;
/**
* 平台「站点管理员」系统角色slug=site_admin的默认 prd.* 模板。
* 接入站点创建时自动绑定;权限可在「平台角色管理」调整。
*/
final class SiteAdminDefaultRolePermissions
{
/** @var list<string> */
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<string>
*/
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;
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Support;
use App\Models\AdminRole;
use App\Models\AdminUser;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
/** 接入站点后台账号统一使用平台系统角色 slug=site_admin。 */
final class SitePlatformRole
{
public const SLUG = 'site_admin';
public static function resolve(): AdminRole
{
return SiteAdminDefaultRolePermissions::ensurePlatformSiteAdminRole();
}
public static function id(): int
{
return (int) self::resolve()->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();
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Support;
use App\Models\AdminUser;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
/** 平台唯一超级管理员账号(不绑定站点)。 */
final class SuperAdminAccount
{
public static function assign(AdminUser $user): AdminUser
{
return DB::transaction(function () use ($user): AdminUser {
DB::table('admin_users')
->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();
}
}