feat: enhance admin role management and reconciliation features

- Updated AGENTS.md to clarify site admin roles and their associated permissions.
- Refactored reconciliation controllers to include admin user validation and improved access control based on admin roles.
- Enhanced AdminReconcileJobService to support player-specific reconciliation and site-based job creation.
- Removed deprecated rebate commission report functionality from the API and related services.
- Improved dashboard overview builders to accommodate new site operator roles and their specific functionalities.
This commit is contained in:
2026-06-16 16:04:01 +08:00
parent 3a59cc3f42
commit 4e370a79dc
35 changed files with 1230 additions and 180 deletions

View File

@@ -17,7 +17,7 @@ use App\Support\AdminDataScope;
use App\Support\AdminScopeContext;
use App\Support\AdminAgentScope;
use App\Support\AdminScopeContextResolver;
use App\Support\SitePlatformRole;
use App\Support\SiteOperatorRoles;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
/**
@@ -32,6 +32,8 @@ final class AdminDashboardSnapshotBuilder
private readonly AdminReportQueryService $reportQuery,
private readonly AgentDashboardOverviewBuilder $agentOverview,
private readonly SiteDashboardOverviewBuilder $siteOverview,
private readonly SiteFinanceDashboardOverviewBuilder $siteFinanceOverview,
private readonly SiteCsDashboardOverviewBuilder $siteCsOverview,
) {}
/** @return array<string, mixed> */
@@ -60,12 +62,19 @@ final class AdminDashboardSnapshotBuilder
],
'agent_overview' => null,
'site_overview' => null,
'site_finance_overview' => null,
'site_cs_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);
} elseif (SiteOperatorRoles::userHasSiteOperatorRole($admin)) {
$operatorSlug = SiteOperatorRoles::primarySiteOperatorSlug($admin);
match ($operatorSlug) {
SiteOperatorRoles::SLUG_SITE_FINANCE => $out['site_finance_overview'] = $this->siteFinanceOverview->build($admin, $scope),
SiteOperatorRoles::SLUG_SITE_CS => $out['site_cs_overview'] = $this->siteCsOverview->build($admin),
default => $out['site_overview'] = $this->siteOverview->build($admin, $scope),
};
}
if ($canDraw) {

View File

@@ -4,11 +4,17 @@ namespace App\Services\Admin;
use Carbon\Carbon;
use App\Models\AdminUser;
use App\Models\Player;
use Illuminate\Support\Str;
use App\Models\ReconcileJob;
use Illuminate\Http\Request;
use App\Models\ReconcileItem;
use App\Lottery\ErrorCode;
use App\Services\AuditLogger;
use App\Support\AdminReconcileScope;
use App\Support\AdminScopePolicy;
use App\Support\AdminSiteScope;
use App\Support\ApiMessage;
use Illuminate\Support\Facades\DB;
use App\Services\Wallet\WalletTransferReconcileDetector;
@@ -102,6 +108,23 @@ final class AdminReconcileJobService
$periodEnd ??= now()->endOfDay();
$periodStart ??= $periodEnd->copy()->startOfDay();
$player = null;
if ($playerId !== null) {
$player = Player::query()->find($playerId);
if ($player === null || ! AdminSiteScope::playerAccessible($admin, $player)) {
abort(ApiMessage::errorResponse(
$request,
'admin.site_player_access_denied',
ErrorCode::AdminForbidden->value,
null,
403,
));
}
}
$scope = AdminScopePolicy::resolveContext($request, $admin);
$adminSiteId = AdminReconcileScope::resolveAdminSiteIdForJob($admin, $player);
[$items] = $this->detector->scanItems(
$periodStart,
$periodEnd,
@@ -109,9 +132,17 @@ final class AdminReconcileJobService
staleMinutes: 15,
playerId: $playerId,
includeMainSiteCheck: true,
scope: $scope,
);
$job = $this->detector->persistJob($items, $periodStart, $periodEnd, (int) $admin->getKey());
$job = $this->detector->persistJob(
$items,
$periodStart,
$periodEnd,
(int) $admin->getKey(),
$adminSiteId,
$playerId,
);
AuditLogger::recordForAdmin(
$admin,

View File

@@ -478,19 +478,6 @@ final class AdminReportQueryService
return $query->paginate($perPage, ['*'], 'page', $page);
}
public function rebateCommissionPaginated(
?string $playCode,
string $dateFrom,
string $dateTo,
int $page,
int $perPage,
AdminUser|AdminScopeContext|null $scope = null,
): LengthAwarePaginator {
$query = $this->rebateCommissionBaseQuery($playCode, $dateFrom, $dateTo, $scope);
return $query->paginate($perPage, ['*'], 'page', $page);
}
/**
* @return list<array<int, string|int|float|null>>
*/
@@ -505,7 +492,6 @@ final class AdminReportQueryService
'daily_profit_summary' => $this->dailyProfitExportRows($dateFrom, $dateTo, $scope),
'player_win_loss' => $this->playerWinLossExportRows($filterJson, $dateFrom, $dateTo, $scope),
'play_dimension_report' => $this->playDimensionExportRows($filterJson, $dateFrom, $dateTo, $scope),
'rebate_commission_report' => $this->rebateCommissionExportRows($filterJson, $dateFrom, $dateTo, $scope),
'audit_operation_report' => $this->auditExportRows($filterJson, $dateFrom, $dateTo),
'wallet_transfer_report', 'transfer_orders_daily' => $this->transferOrdersExportRows($filterJson, $dateFrom, $dateTo, $scope),
'wallet_txns_daily' => $this->walletTxnsExportRows($filterJson, $dateFrom, $dateTo, $scope),
@@ -546,7 +532,6 @@ final class AdminReportQueryService
'hot_number_risk_report' => '热门号码风险报表',
'play_dimension_report' => '玩法维度报表',
'sold_out_number_report' => '售罄号码报表',
'rebate_commission_report' => '佣金回水报表',
'audit_operation_report' => '后台操作审计报表',
default => $reportType,
};
@@ -618,28 +603,6 @@ final class AdminReportQueryService
return $rows;
}
/**
* @return list<array<int, string|int|float|null>>
*/
private function rebateCommissionExportRows(?array $filterJson, string $dateFrom, string $dateTo, AdminUser|AdminScopeContext|null $scope = null): array
{
$playCode = isset($filterJson['play_code']) ? trim((string) $filterJson['play_code']) : null;
$rows = [
['玩法', '回水', '订单数', '注单数'],
];
$items = $this->rebateCommissionBaseQuery($playCode !== '' ? $playCode : null, $dateFrom, $dateTo, $scope)->get();
foreach ($items as $row) {
$rows[] = [
(string) $row->play_code,
(int) $row->total_rebate_minor,
(int) $row->order_count,
(int) $row->ticket_item_count,
];
}
return $rows;
}
/**
* @return list<array<int, string|int|float|null>>
*/
@@ -739,31 +702,6 @@ final class AdminReportQueryService
return $query;
}
/** @return \Illuminate\Database\Query\Builder */
private function rebateCommissionBaseQuery(?string $playCode, string $dateFrom, string $dateTo, AdminUser|AdminScopeContext|null $scope = null)
{
$context = $this->normalizeScope($scope);
$query = DB::table('ticket_items as ti')
->join('ticket_orders as o', 'o.id', '=', 'ti.order_id')
->join('draws as d', 'd.id', '=', 'o.draw_id')
->selectRaw('ti.play_code')
->selectRaw('SUM(ti.total_bet_amount - ti.actual_deduct_amount) as total_rebate_minor')
->selectRaw('COUNT(DISTINCT o.id) as order_count')
->selectRaw('COUNT(ti.id) as ticket_item_count')
->whereDate('d.business_date', '>=', $dateFrom)
->whereDate('d.business_date', '<=', $dateTo)
->groupBy('ti.play_code')
->orderBy('ti.play_code');
if ($playCode !== null && $playCode !== '') {
$query->where('ti.play_code', $playCode);
}
AdminDataScope::applyToTicketOrdersViaPlayer($query, $context?->admin, 'o');
return $query;
}
/**
* @return list<array<int, string|int|float|null>>
*/

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Services\Admin;
use App\Models\AdminUser;
use App\Models\Player;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
/** 站点客服仪表盘:玩家规模与今日注单活跃度,不含全局盈亏。 */
final class SiteCsDashboardOverviewBuilder
{
/**
* @return array<string, mixed>|null
*/
public function build(AdminUser $admin): ?array
{
if (! $admin->hasPermissionCode('dashboard.view')) {
return null;
}
$site = SiteDashboardSiteResolver::resolvePrimarySite($admin);
if ($site === null) {
return null;
}
$siteId = (int) $site->id;
$siteCode = (string) $site->code;
$today = now()->toDateString();
$activity = $this->todayTicketStats($siteCode, $today);
return [
'admin_site_id' => $siteId,
'site_code' => $siteCode,
'site_name' => (string) $site->name,
'player_count' => (int) Player::query()->where('site_code', $siteCode)->count(),
'ticket_order_count_today' => $activity['order_count'],
'active_player_count_today' => $activity['player_count'],
'latest_ticket_at' => $activity['latest_at'],
];
}
/**
* @return array{player_count: int, order_count: int, latest_at: ?string}
*/
private function todayTicketStats(string $siteCode, string $today): array
{
$base = DB::table('ticket_orders as o')
->join('draws as d', 'd.id', '=', 'o.draw_id')
->join('players as p', 'p.id', '=', 'o.player_id')
->where('p.site_code', $siteCode)
->whereDate('d.business_date', $today);
$latestAt = (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_at' => $latestAt !== null ? Carbon::parse((string) $latestAt)->toIso8601String() : null,
];
}
}

View File

@@ -2,7 +2,6 @@
namespace App\Services\Admin;
use App\Models\AdminSite;
use App\Models\AdminUser;
use App\Models\AgentNode;
use App\Models\Player;
@@ -27,7 +26,7 @@ final class SiteDashboardOverviewBuilder
return null;
}
$site = $this->resolvePrimarySite($admin);
$site = SiteDashboardSiteResolver::resolvePrimarySite($admin);
if ($site === null) {
return null;
}
@@ -52,7 +51,11 @@ final class SiteDashboardOverviewBuilder
$currencyCode = $this->reportQuery->resolvePeriodCurrencyCode($today, $today, $scoped)
?? $this->reportQuery->resolvePeriodCurrencyCode($sevenDayFrom, $today, $scoped);
$todayActivity = $this->todayActivityStats($siteCode, $today);
$pendingBills = $this->pendingBillStats($siteId);
$billStats = SiteSettlementBillStats::forSite($siteId);
$pendingBills = [
'count' => $billStats['pending_confirm_count'] + $billStats['payable_count'],
'unpaid_minor' => $billStats['payable_unpaid_minor'],
];
$topAgentToday = $this->topAgentToday($scoped, $today);
return [
@@ -78,18 +81,6 @@ final class SiteDashboardOverviewBuilder
];
}
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}
*/
@@ -110,22 +101,6 @@ final class SiteDashboardOverviewBuilder
];
}
/**
* @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
*/

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Services\Admin;
use App\Models\AdminSite;
use App\Models\AdminUser;
/** 站点运营仪表盘:解析账号绑定的主接入站点。 */
final class SiteDashboardSiteResolver
{
public static function resolvePrimarySite(AdminUser $admin): ?AdminSite
{
$siteIds = $admin->accessibleAdminSiteIds();
if ($siteIds === null || $siteIds === []) {
return null;
}
return AdminSite::query()
->where('id', (int) $siteIds[0])
->first();
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Services\Admin;
use App\Models\AdminUser;
use App\Models\Player;
use App\Models\TransferOrder;
use App\Support\AdminAgentScope;
use App\Support\AdminDataScope;
use App\Support\AdminScopeContext;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
/** 站点财务仪表盘:对账异常、待确认/待收付账单、钱包玩家规模。 */
final class SiteFinanceDashboardOverviewBuilder
{
/**
* @return array<string, mixed>|null
*/
public function build(AdminUser $admin, AdminScopeContext $scope): ?array
{
if (! $admin->hasPermissionCode('dashboard.view')) {
return null;
}
$site = SiteDashboardSiteResolver::resolvePrimarySite($admin);
if ($site === null) {
return null;
}
$siteId = (int) $site->id;
$siteCode = (string) $site->code;
$billStats = SiteSettlementBillStats::forSite($siteId);
$currencyCode = Player::query()
->where('site_code', $siteCode)
->whereNotNull('default_currency')
->value('default_currency');
return [
'admin_site_id' => $siteId,
'site_code' => $siteCode,
'site_name' => (string) $site->name,
'wallet_player_count' => (int) Player::query()
->where('site_code', $siteCode)
->where('funding_mode', 'wallet')
->count(),
'credit_player_count' => (int) Player::query()
->where('site_code', $siteCode)
->where('funding_mode', 'credit')
->count(),
'pending_confirm_bill_count' => $billStats['pending_confirm_count'],
'payable_bill_count' => $billStats['payable_count'],
'payable_unpaid_minor' => $billStats['payable_unpaid_minor'],
'pending_bill_count' => $billStats['pending_confirm_count'] + $billStats['payable_count'],
'pending_unpaid_minor' => $billStats['payable_unpaid_minor'],
'abnormal_transfer_count' => $this->abnormalTransferCount($admin, $scope),
'currency_code' => is_string($currencyCode) && $currencyCode !== '' ? $currencyCode : null,
];
}
private function abnormalTransferCount(AdminUser $admin, AdminScopeContext $scope): int
{
$query = TransferOrder::query()
->whereIn('status', ['processing', 'failed', 'pending_reconcile']);
AdminDataScope::applyEloquentViaPlayer($query, $admin);
$this->applyRequestedScopeViaPlayer($query, $scope);
return (int) $query->count();
}
/**
* @param EloquentBuilder<mixed> $query
*/
private function applyRequestedScopeViaPlayer(EloquentBuilder $query, AdminScopeContext $scope): void
{
$siteCode = $scope->effectiveRequestedSiteCode();
$agentNodeId = $scope->effectiveRequestedAgentNodeId();
if ($siteCode === null && $agentNodeId === null) {
return;
}
$admin = $scope->admin;
$query->whereHas('player', static function (EloquentBuilder $playerQuery) use ($admin, $siteCode, $agentNodeId): void {
if ($siteCode !== null) {
$playerQuery->where('site_code', $siteCode);
}
if ($agentNodeId !== null) {
AdminAgentScope::applyRequestedAgentNodeFilter($playerQuery, $admin, $agentNodeId);
}
});
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Services\Admin;
use Illuminate\Support\Facades\DB;
/** 站点信用账期账单摘要(待确认 / 待收付)。 */
final class SiteSettlementBillStats
{
/**
* @return array{
* pending_confirm_count: int,
* payable_count: int,
* payable_unpaid_minor: int
* }
*/
public static function forSite(int $siteId): array
{
$pendingConfirmQuery = DB::table('settlement_bills as sb')
->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
->where('sp.admin_site_id', $siteId)
->where('sb.status', 'pending_confirm');
$payableQuery = 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', ['confirmed', 'partial_paid', 'overdue'])
->where('sb.unpaid_amount', '>', 0);
return [
'pending_confirm_count' => (int) (clone $pendingConfirmQuery)->count(),
'payable_count' => (int) (clone $payableQuery)->count(),
'payable_unpaid_minor' => (int) (clone $payableQuery)->sum('sb.unpaid_amount'),
];
}
}