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:
@@ -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。
|
||||
|
||||
147
app/Console/Commands/PurgeAgentDataCommand.php
Normal file
147
app/Console/Commands/PurgeAgentDataCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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']],
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
64
app/Support/SiteAdminDefaultRolePermissions.php
Normal file
64
app/Support/SiteAdminDefaultRolePermissions.php
Normal 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;
|
||||
}
|
||||
}
|
||||
54
app/Support/SitePlatformRole.php
Normal file
54
app/Support/SitePlatformRole.php
Normal 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();
|
||||
}
|
||||
}
|
||||
63
app/Support/SuperAdminAccount.php
Normal file
63
app/Support/SuperAdminAccount.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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 角色。
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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` 列出可访问站点(单站时一项)。
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', []);
|
||||
});
|
||||
|
||||
70
tests/Feature/AdminSiteDashboardOverviewTest.php
Normal file
70
tests/Feature/AdminSiteDashboardOverviewTest.php
Normal 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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 表)。 */
|
||||
|
||||
13
tests/Unit/SiteAdminDefaultRolePermissionsTest.php
Normal file
13
tests/Unit/SiteAdminDefaultRolePermissionsTest.php
Normal 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');
|
||||
});
|
||||
Reference in New Issue
Block a user