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

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

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();
}
}

View File

@@ -0,0 +1,67 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('admin_users', function (Blueprint $table): void {
$table->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');
});
}
};

View File

@@ -0,0 +1,47 @@
<?php
use App\Models\AdminRole;
use App\Support\SiteAdminDefaultRolePermissions;
use App\Support\SitePlatformRole;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
SiteAdminDefaultRolePermissions::ensurePlatformSiteAdminRole();
$platformRoleId = SitePlatformRole::id();
$legacyRoles = AdminRole::query()
->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 角色。
}
};

View File

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

View File

@@ -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` 列出可访问站点(单站时一项)。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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', []);
});

View File

@@ -0,0 +1,70 @@
<?php
use App\Models\AdminSite;
use App\Models\AdminUser;
use App\Support\AdminAuthProfile;
use App\Support\SitePlatformRole;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->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);
});

View File

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

View File

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

View File

@@ -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 表)。 */

View File

@@ -0,0 +1,13 @@
<?php
use App\Support\SiteAdminDefaultRolePermissions;
test('site admin template includes dashboard and settlement manage', function (): void {
$slugs = SiteAdminDefaultRolePermissions::templateSlugs();
expect($slugs)
->toContain('prd.dashboard.view')
->toContain('prd.agent.manage')
->toContain('prd.settlement.agent.manage')
->toContain('prd.report.view');
});