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

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