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:
@@ -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) {
|
||||
|
||||
137
app/Services/Admin/SiteDashboardOverviewBuilder.php
Normal file
137
app/Services/Admin/SiteDashboardOverviewBuilder.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user