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

@@ -44,7 +44,7 @@
- 期号 `close_time`/`draw_time` UTC 存储;下注由 `DrawHallSnapshotBuilder` 实时判定;列表展示 DB `status`,详情 API 有 `hall_preview_status` - 期号 `close_time`/`draw_time` UTC 存储;下注由 `DrawHallSnapshotBuilder` 实时判定;列表展示 DB `status`,详情 API 有 `hall_preview_status`
- `AgentProfileCapabilityFilter` 仅作用于**已绑定代理节点**的经营账号(按档案 `can_create_*` 收紧权限);**禁止**对无代理绑定的平台账号(如 `site_admin`)套用,否则会误剥 `prd.agent.manage` 等权限。绑定经营代理主账号统一绑 `slug=agent`,模板仅含 `prd.settlement.agent.view`;登录态对绑定代理主账号自动补足 `settlement.agent.manage`,实际操作仍受直属边 + 收款方校验。 - `AgentProfileCapabilityFilter` 仅作用于**已绑定代理节点**的经营账号(按档案 `can_create_*` 收紧权限);**禁止**对无代理绑定的平台账号(如 `site_admin`)套用,否则会误剥 `prd.agent.manage` 等权限。绑定经营代理主账号统一绑 `slug=agent`,模板仅含 `prd.settlement.agent.view`;登录态对绑定代理主账号自动补足 `settlement.agent.manage`,实际操作仍受直属边 + 收款方校验。
- 站点管理员(`admin_user_site_roles` + `slug=site_admin`,且**未**绑 `admin_user_agents`)定位单站信用盘运营(代理树/玩家/结算/注单/报表);不含开奖赔率等平台技术权限;开通一级代理线路仅超管(`prd.agent-line.provision`)。 - 站点管理员(`admin_user_site_roles` + `slug=site_admin|site_finance|site_cs`,且**未**绑 `admin_user_agents`)定位单站运营;`site_admin`代理树/玩家/信用结算/注单 + 本站钱包流水·对账·经营报表(可导出)·期号只读;`site_finance` 财务工作台 + 对账/报表/结算收付;`site_cs` 客服工作台 + 单玩家查询。数据范围仅绑定站点;不含开奖赔率等平台技术权限;开通一级代理线路仅超管(`prd.agent-line.provision`)。
- 结算中心登记收付/确认/坏账/补差 UI 需 `prd.settlement.agent.manage``canManage`);仅 view 时操作区静默隐藏。另需账单 `status` ∈ confirmed/partial_paid/overdue 且 `unpaid_amount > 0`。**坏账核销 / 补差冲正** 另需未绑定代理(站点财务,`canFinanceAdjustments`),绑定代理仅有收付/确认。绑定代理账单可见范围:**玩家账单**仅直属玩家;**代理账单**仅 `owner=本节点``counterparty=本节点`;登记收付/确认仅可操作 **收款方** - 结算中心登记收付/确认/坏账/补差 UI 需 `prd.settlement.agent.manage``canManage`);仅 view 时操作区静默隐藏。另需账单 `status` ∈ confirmed/partial_paid/overdue 且 `unpaid_amount > 0`。**坏账核销 / 补差冲正** 另需未绑定代理(站点财务,`canFinanceAdjustments`),绑定代理仅有收付/确认。绑定代理账单可见范围:**玩家账单**仅直属玩家;**代理账单**仅 `owner=本节点``counterparty=本节点`;登记收付/确认仅可操作 **收款方**
- 收付/调账/坏账后端落库 `payment_records``settlement_adjustments`;账期详情 **收付与调账** Tab 查操作台账,**账务流水** 仅玩家信用变动;单张账单详情内另有该账单的收付列表。 - 收付/调账/坏账后端落库 `payment_records``settlement_adjustments`;账期详情 **收付与调账** Tab 查操作台账,**账务流水** 仅玩家信用变动;单张账单详情内另有该账单的收付列表。
- 线上生产:已有库用 `php artisan lottery:db-init --no-demo`(含 RBAC sync常驻 `schedule:work``queue:work redis --queue=broadcasts:countdown,broadcasts,default``reverb:start``CACHE_STORE`/`QUEUE_CONNECTION` 须 Redis先部署 lotterLaravel 再前端。 - 线上生产:已有库用 `php artisan lottery:db-init --no-demo`(含 RBAC sync常驻 `schedule:work``queue:work redis --queue=broadcasts:countdown,broadcasts,default``reverb:start``CACHE_STORE`/`QUEUE_CONNECTION` 须 Redis先部署 lotterLaravel 再前端。

View File

@@ -2,12 +2,15 @@
namespace App\Http\Controllers\Api\V1\Admin\Reconcile; namespace App\Http\Controllers\Api\V1\Admin\Reconcile;
use App\Models\AdminSite;
use App\Models\AdminUser; use App\Models\AdminUser;
use App\Models\TransferOrder; use App\Models\TransferOrder;
use App\Models\ReconcileJob; use App\Models\ReconcileJob;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Models\ReconcileItem; use App\Models\ReconcileItem;
use App\Support\AdminAgentScope;
use App\Support\AdminApiList; use App\Support\AdminApiList;
use App\Support\AdminReconcileScope;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Support\AdminTransferOrderCapabilities; use App\Support\AdminTransferOrderCapabilities;
@@ -28,11 +31,27 @@ final class ReconcileItemIndexController extends Controller
$admin = $request->lotteryAdmin(); $admin = $request->lotteryAdmin();
abort_if($admin === null, 401); abort_if($admin === null, 401);
if ($denied = AdminReconcileScope::denyUnlessJobAccessible($admin, $reconcile_job)) {
return $denied;
}
$p = AdminApiList::readPaging($request, 50, 200); $p = AdminApiList::readPaging($request, 50, 200);
$paginator = $reconcile_job->items() $itemsQuery = $reconcile_job->items()->orderBy('id');
->orderBy('id') if (! $admin->isSuperAdmin()) {
->paginate($p['perPage'], ['*'], 'page', $p['page']); $siteIds = $admin->accessibleAdminSiteIds() ?? [];
$siteCodes = $siteIds === []
? []
: AdminSite::query()
->whereIn('id', $siteIds)
->pluck('code')
->map(static fn ($code): string => (string) $code)
->all();
$agent = AdminAgentScope::primaryAgentNode($admin);
AdminReconcileScope::applyItemsTransferPlayerScope($itemsQuery, $siteCodes, $agent);
}
$paginator = $itemsQuery->paginate($p['perPage'], ['*'], 'page', $p['page']);
$transferNos = collect($paginator->items()) $transferNos = collect($paginator->items())
->map(fn (ReconcileItem $item) => $item->side_a_ref) ->map(fn (ReconcileItem $item) => $item->side_a_ref)

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\V1\Admin\Reconcile; namespace App\Http\Controllers\Api\V1\Admin\Reconcile;
use App\Models\ReconcileJob; use App\Models\ReconcileJob;
use App\Support\AdminReconcileScope;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Support\AdminApiList; use App\Support\AdminApiList;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -13,6 +14,9 @@ final class ReconcileJobIndexController extends Controller
{ {
public function __invoke(Request $request): JsonResponse public function __invoke(Request $request): JsonResponse
{ {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$p = AdminApiList::readPaging($request); $p = AdminApiList::readPaging($request);
$type = trim((string) $request->query('reconcile_type', '')); $type = trim((string) $request->query('reconcile_type', ''));
@@ -21,6 +25,8 @@ final class ReconcileJobIndexController extends Controller
$q->where('reconcile_type', $type); $q->where('reconcile_type', $type);
} }
AdminReconcileScope::applyToJobsQuery($q, $admin);
$paginator = $q->paginate($p['perPage'], ['*'], 'page', $p['page']); $paginator = $q->paginate($p['perPage'], ['*'], 'page', $p['page']);
return AdminApiList::json($paginator, fn (ReconcileJob $j) => $this->row($j)); return AdminApiList::json($paginator, fn (ReconcileJob $j) => $this->row($j));

View File

@@ -3,15 +3,24 @@
namespace App\Http\Controllers\Api\V1\Admin\Reconcile; namespace App\Http\Controllers\Api\V1\Admin\Reconcile;
use App\Models\ReconcileJob; use App\Models\ReconcileJob;
use App\Support\AdminReconcileScope;
use App\Support\ApiResponse; use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
/** GET /api/v1/admin/reconcile-jobs/{reconcile_job} */ /** GET /api/v1/admin/reconcile-jobs/{reconcile_job} */
final class ReconcileJobShowController extends Controller final class ReconcileJobShowController extends Controller
{ {
public function __invoke(ReconcileJob $reconcile_job): JsonResponse public function __invoke(Request $request, ReconcileJob $reconcile_job): JsonResponse
{ {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
if ($denied = AdminReconcileScope::denyUnlessJobAccessible($admin, $reconcile_job)) {
return $denied;
}
return ApiResponse::success([ return ApiResponse::success([
'id' => (int) $reconcile_job->id, 'id' => (int) $reconcile_job->id,
'job_no' => $reconcile_job->job_no, 'job_no' => $reconcile_job->job_no,

View File

@@ -1,47 +0,0 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Reports;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\AdminReportQueryRequest;
use App\Services\Admin\AdminReportQueryService;
use App\Support\AdminApiList;
use App\Support\AdminScopePolicy;
use Illuminate\Http\JsonResponse;
/** GET /api/v1/admin/reports/rebate-commission */
final class AdminReportRebateCommissionController extends Controller
{
public function __invoke(AdminReportQueryRequest $request, AdminReportQueryService $service): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$validated = $request->validated();
$p = AdminApiList::readPaging($request);
$range = $service->resolveDateRange($validated);
$playCode = isset($validated['play_code']) ? trim((string) $validated['play_code']) : null;
$scope = AdminScopePolicy::resolveContext($request, $admin, 'site_code', 'agent_node_id');
$paginator = $service->rebateCommissionPaginated(
$playCode !== '' ? $playCode : null,
$range['date_from'],
$range['date_to'],
$p['page'],
$p['perPage'],
$scope,
);
return AdminApiList::jsonWith($paginator, static function (object $row): array {
return [
'play_code' => (string) $row->play_code,
'total_rebate_minor' => (int) $row->total_rebate_minor,
'order_count' => (int) $row->order_count,
'ticket_item_count' => (int) $row->ticket_item_count,
];
}, [
'currency_code' => $service->resolvePeriodCurrencyCode($range['date_from'], $range['date_to'], $scope),
'disclaimer' => 'wallet_instant_rebate_not_agent_period_settlement',
]);
}
}

View File

@@ -48,7 +48,6 @@ final class ReportJobStoreRequest extends ApiFormRequest
'hot_number_risk_report', 'hot_number_risk_report',
'play_dimension_report', 'play_dimension_report',
'sold_out_number_report', 'sold_out_number_report',
'rebate_commission_report',
'audit_operation_report', 'audit_operation_report',
'wallet_txns_daily', 'wallet_txns_daily',
'transfer_orders_daily', 'transfer_orders_daily',

View File

@@ -13,6 +13,7 @@ final class ReconcileJob extends Model
protected $fillable = [ protected $fillable = [
'job_no', 'job_no',
'admin_user_id', 'admin_user_id',
'admin_site_id',
'reconcile_type', 'reconcile_type',
'status', 'status',
'period_start', 'period_start',

View File

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

View File

@@ -4,11 +4,17 @@ namespace App\Services\Admin;
use Carbon\Carbon; use Carbon\Carbon;
use App\Models\AdminUser; use App\Models\AdminUser;
use App\Models\Player;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use App\Models\ReconcileJob; use App\Models\ReconcileJob;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Models\ReconcileItem; use App\Models\ReconcileItem;
use App\Lottery\ErrorCode;
use App\Services\AuditLogger; 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 Illuminate\Support\Facades\DB;
use App\Services\Wallet\WalletTransferReconcileDetector; use App\Services\Wallet\WalletTransferReconcileDetector;
@@ -102,6 +108,23 @@ final class AdminReconcileJobService
$periodEnd ??= now()->endOfDay(); $periodEnd ??= now()->endOfDay();
$periodStart ??= $periodEnd->copy()->startOfDay(); $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( [$items] = $this->detector->scanItems(
$periodStart, $periodStart,
$periodEnd, $periodEnd,
@@ -109,9 +132,17 @@ final class AdminReconcileJobService
staleMinutes: 15, staleMinutes: 15,
playerId: $playerId, playerId: $playerId,
includeMainSiteCheck: true, 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( AuditLogger::recordForAdmin(
$admin, $admin,

View File

@@ -478,19 +478,6 @@ final class AdminReportQueryService
return $query->paginate($perPage, ['*'], 'page', $page); 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>> * @return list<array<int, string|int|float|null>>
*/ */
@@ -505,7 +492,6 @@ final class AdminReportQueryService
'daily_profit_summary' => $this->dailyProfitExportRows($dateFrom, $dateTo, $scope), 'daily_profit_summary' => $this->dailyProfitExportRows($dateFrom, $dateTo, $scope),
'player_win_loss' => $this->playerWinLossExportRows($filterJson, $dateFrom, $dateTo, $scope), 'player_win_loss' => $this->playerWinLossExportRows($filterJson, $dateFrom, $dateTo, $scope),
'play_dimension_report' => $this->playDimensionExportRows($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), 'audit_operation_report' => $this->auditExportRows($filterJson, $dateFrom, $dateTo),
'wallet_transfer_report', 'transfer_orders_daily' => $this->transferOrdersExportRows($filterJson, $dateFrom, $dateTo, $scope), 'wallet_transfer_report', 'transfer_orders_daily' => $this->transferOrdersExportRows($filterJson, $dateFrom, $dateTo, $scope),
'wallet_txns_daily' => $this->walletTxnsExportRows($filterJson, $dateFrom, $dateTo, $scope), 'wallet_txns_daily' => $this->walletTxnsExportRows($filterJson, $dateFrom, $dateTo, $scope),
@@ -546,7 +532,6 @@ final class AdminReportQueryService
'hot_number_risk_report' => '热门号码风险报表', 'hot_number_risk_report' => '热门号码风险报表',
'play_dimension_report' => '玩法维度报表', 'play_dimension_report' => '玩法维度报表',
'sold_out_number_report' => '售罄号码报表', 'sold_out_number_report' => '售罄号码报表',
'rebate_commission_report' => '佣金回水报表',
'audit_operation_report' => '后台操作审计报表', 'audit_operation_report' => '后台操作审计报表',
default => $reportType, default => $reportType,
}; };
@@ -618,28 +603,6 @@ final class AdminReportQueryService
return $rows; 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>> * @return list<array<int, string|int|float|null>>
*/ */
@@ -739,31 +702,6 @@ final class AdminReportQueryService
return $query; 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>> * @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; namespace App\Services\Admin;
use App\Models\AdminSite;
use App\Models\AdminUser; use App\Models\AdminUser;
use App\Models\AgentNode; use App\Models\AgentNode;
use App\Models\Player; use App\Models\Player;
@@ -27,7 +26,7 @@ final class SiteDashboardOverviewBuilder
return null; return null;
} }
$site = $this->resolvePrimarySite($admin); $site = SiteDashboardSiteResolver::resolvePrimarySite($admin);
if ($site === null) { if ($site === null) {
return null; return null;
} }
@@ -52,7 +51,11 @@ final class SiteDashboardOverviewBuilder
$currencyCode = $this->reportQuery->resolvePeriodCurrencyCode($today, $today, $scoped) $currencyCode = $this->reportQuery->resolvePeriodCurrencyCode($today, $today, $scoped)
?? $this->reportQuery->resolvePeriodCurrencyCode($sevenDayFrom, $today, $scoped); ?? $this->reportQuery->resolvePeriodCurrencyCode($sevenDayFrom, $today, $scoped);
$todayActivity = $this->todayActivityStats($siteCode, $today); $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); $topAgentToday = $this->topAgentToday($scoped, $today);
return [ 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} * @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 * @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'),
];
}
}

View File

@@ -7,6 +7,8 @@ use App\Models\WalletTxn;
use App\Models\TransferOrder; use App\Models\TransferOrder;
use App\Models\ReconcileJob; use App\Models\ReconcileJob;
use App\Models\ReconcileItem; use App\Models\ReconcileItem;
use App\Support\AdminScopeContext;
use App\Support\AdminScopePolicy;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -56,6 +58,7 @@ final class WalletTransferReconcileDetector
int $staleMinutes = 15, int $staleMinutes = 15,
?int $playerId = null, ?int $playerId = null,
bool $includeMainSiteCheck = true, bool $includeMainSiteCheck = true,
?AdminScopeContext $scope = null,
): array { ): array {
$limit = max(1, $limit); $limit = max(1, $limit);
$staleMinutes = max(1, $staleMinutes); $staleMinutes = max(1, $staleMinutes);
@@ -77,6 +80,10 @@ final class WalletTransferReconcileDetector
$query->where('player_id', $playerId); $query->where('player_id', $playerId);
} }
if ($scope !== null && ! $scope->isSuperAdmin()) {
AdminScopePolicy::applyViaPlayer($query, $scope);
}
$orders = $query->get(); $orders = $query->get();
if ($orders->isEmpty()) { if ($orders->isEmpty()) {
@@ -118,13 +125,16 @@ final class WalletTransferReconcileDetector
Carbon $periodStart, Carbon $periodStart,
Carbon $periodEnd, Carbon $periodEnd,
?int $adminUserId, ?int $adminUserId,
?int $adminSiteId = null,
?int $playerId = null,
): ReconcileJob { ): ReconcileJob {
return DB::transaction(function () use ($items, $periodStart, $periodEnd, $adminUserId): ReconcileJob { return DB::transaction(function () use ($items, $periodStart, $periodEnd, $adminUserId, $adminSiteId, $playerId): ReconcileJob {
$jobNo = 'REC'.now()->format('YmdHis').strtoupper(str_replace('-', '', Str::uuid()->toString())); $jobNo = 'REC'.now()->format('YmdHis').strtoupper(str_replace('-', '', Str::uuid()->toString()));
$job = ReconcileJob::query()->create([ $job = ReconcileJob::query()->create([
'job_no' => $jobNo, 'job_no' => $jobNo,
'admin_user_id' => $adminUserId, 'admin_user_id' => $adminUserId,
'admin_site_id' => $adminSiteId,
'reconcile_type' => self::RECONCILE_TYPE, 'reconcile_type' => self::RECONCILE_TYPE,
'status' => 'completed', 'status' => 'completed',
'period_start' => $periodStart, 'period_start' => $periodStart,
@@ -132,6 +142,7 @@ final class WalletTransferReconcileDetector
'summary_json' => [ 'summary_json' => [
'item_count' => count($items), 'item_count' => count($items),
'mismatch_count' => count($items), 'mismatch_count' => count($items),
'player_id' => $playerId,
], ],
'finished_at' => now(), 'finished_at' => now(),
]); ]);

View File

@@ -40,6 +40,7 @@ final class AdminAuthProfile
* }, * },
* site: ?array{id: int, code: string, name: string}, * site: ?array{id: int, code: string, name: string},
* is_super_admin: bool, * is_super_admin: bool,
* account_kind: 'super_admin'|'site_admin'|'site_finance'|'site_cs'|'agent_operator'|'platform_account',
* operational_permissions: list<string>, * operational_permissions: list<string>,
* delegation_ceiling: list<string>, * delegation_ceiling: list<string>,
* accessible_sites?: list<array{id: int, code: string, name: string}> * accessible_sites?: list<array{id: int, code: string, name: string}>
@@ -49,6 +50,7 @@ final class AdminAuthProfile
{ {
$fresh = $admin->fresh(); $fresh = $admin->fresh();
$permissionSlugs = $fresh->adminPermissionSlugs(); $permissionSlugs = $fresh->adminPermissionSlugs();
$operationalCodes = $fresh->effectiveMenuActionPermissionCodes();
$agent = self::agentContext($fresh); $agent = self::agentContext($fresh);
$payload = [ $payload = [
@@ -61,7 +63,8 @@ final class AdminAuthProfile
'agent' => $agent, 'agent' => $agent,
'site' => self::siteContext($fresh), 'site' => self::siteContext($fresh),
'is_super_admin' => $fresh->isSuperAdmin(), 'is_super_admin' => $fresh->isSuperAdmin(),
'operational_permissions' => $permissionSlugs, 'account_kind' => self::accountKind($fresh),
'operational_permissions' => $operationalCodes,
'delegation_ceiling' => AgentDelegationAuthorization::delegationLegacySlugsForAdminUser($fresh), 'delegation_ceiling' => AgentDelegationAuthorization::delegationLegacySlugsForAdminUser($fresh),
]; ];
@@ -81,7 +84,7 @@ final class AdminAuthProfile
return null; return null;
} }
if (! SitePlatformRole::userHasSiteAdminRole($admin)) { if (! SiteOperatorRoles::userHasSiteOperatorRole($admin)) {
return null; return null;
} }
@@ -130,4 +133,22 @@ final class AdminAuthProfile
'can_create_player' => (bool) ($profile?->can_create_player ?? false), 'can_create_player' => (bool) ($profile?->can_create_player ?? false),
]; ];
} }
private static function accountKind(AdminUser $admin): string
{
if ($admin->isSuperAdmin()) {
return 'super_admin';
}
if ($admin->hasPrimaryAgentBinding()) {
return 'agent_operator';
}
$siteRole = SiteOperatorRoles::primarySiteOperatorSlug($admin);
if ($siteRole !== null) {
return $siteRole;
}
return 'platform_account';
}
} }

View File

@@ -565,7 +565,6 @@ final class AdminAuthorizationRegistry
['code' => 'admin.reports.daily-profit', 'module_code' => 'report', 'name' => '每日盈亏汇总', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/daily-profit', 'route_name' => 'api.v1.admin.reports.daily-profit', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']], ['code' => 'admin.reports.daily-profit', 'module_code' => 'report', 'name' => '每日盈亏汇总', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/daily-profit', 'route_name' => 'api.v1.admin.reports.daily-profit', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']],
['code' => 'admin.reports.player-win-loss', 'module_code' => 'report', 'name' => '玩家输赢报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/player-win-loss', 'route_name' => 'api.v1.admin.reports.player-win-loss', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']], ['code' => 'admin.reports.player-win-loss', 'module_code' => 'report', 'name' => '玩家输赢报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/player-win-loss', 'route_name' => 'api.v1.admin.reports.player-win-loss', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']],
['code' => 'admin.reports.play-dimension', 'module_code' => 'report', 'name' => '玩法维度报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/play-dimension', 'route_name' => 'api.v1.admin.reports.play-dimension', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']], ['code' => 'admin.reports.play-dimension', 'module_code' => 'report', 'name' => '玩法维度报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/play-dimension', 'route_name' => 'api.v1.admin.reports.play-dimension', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']],
['code' => 'admin.reports.rebate-commission', 'module_code' => 'report', 'name' => '佣金回水报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/rebate-commission', 'route_name' => 'api.v1.admin.reports.rebate-commission', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']],
['code' => 'admin.report-jobs.index', 'module_code' => 'report', 'name' => '报表任务列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs', 'route_name' => 'api.v1.admin.report-jobs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']], ['code' => 'admin.report-jobs.index', 'module_code' => 'report', 'name' => '报表任务列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs', 'route_name' => 'api.v1.admin.report-jobs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']],
['code' => 'admin.report-jobs.store', 'module_code' => 'report', 'name' => '创建报表任务', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/report-jobs', 'route_name' => 'api.v1.admin.report-jobs.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.report.export']], ['code' => 'admin.report-jobs.store', 'module_code' => 'report', 'name' => '创建报表任务', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/report-jobs', 'route_name' => 'api.v1.admin.report-jobs.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.report.export']],
['code' => 'admin.report-jobs.show', 'module_code' => 'report', 'name' => '报表任务详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs/{report_job}', 'route_name' => 'api.v1.admin.report-jobs.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view', 'service.report.export']], ['code' => 'admin.report-jobs.show', 'module_code' => 'report', 'name' => '报表任务详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs/{report_job}', 'route_name' => 'api.v1.admin.report-jobs.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view', 'service.report.export']],

View File

@@ -0,0 +1,206 @@
<?php
namespace App\Support;
use App\Lottery\ErrorCode;
use App\Models\AdminSite;
use App\Models\AdminUser;
use App\Models\AgentNode;
use App\Models\Player;
use App\Models\ReconcileJob;
use App\Support\ApiMessage;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
/** 对账任务:按接入站点(及绑定代理子树)收敛可见范围。 */
final class AdminReconcileScope
{
/**
* @param Builder<ReconcileJob> $query
*/
public static function applyToJobsQuery(Builder $query, AdminUser $admin): void
{
if ($admin->isSuperAdmin()) {
return;
}
$siteIds = $admin->accessibleAdminSiteIds();
if ($siteIds === null) {
return;
}
if ($siteIds === []) {
$query->whereRaw('0 = 1');
return;
}
$siteCodes = AdminSite::query()
->whereIn('id', $siteIds)
->pluck('code')
->map(static fn ($code): string => (string) $code)
->all();
$agent = AdminAgentScope::primaryAgentNode($admin);
$query->where(function (Builder $outer) use ($siteIds, $siteCodes, $agent): void {
$outer->whereIn('admin_site_id', $siteIds);
if ($siteCodes !== []) {
$outer->orWhere(function (Builder $legacy) use ($siteCodes, $agent): void {
$legacy->whereNull('admin_site_id')
->whereHas('items', function (Builder $items) use ($siteCodes, $agent): void {
self::applyItemsTransferPlayerScope($items, $siteCodes, $agent);
});
});
}
});
if ($agent instanceof AgentNode) {
$query->whereHas('items', function (Builder $items) use ($agent): void {
self::applyItemsAgentSubtree($items, $agent);
});
}
}
public static function jobAccessible(AdminUser $admin, ReconcileJob $job): bool
{
if ($admin->isSuperAdmin()) {
return true;
}
$siteIds = $admin->accessibleAdminSiteIds();
if ($siteIds === null) {
return true;
}
if ($siteIds === []) {
return false;
}
$jobSiteId = $job->admin_site_id !== null ? (int) $job->admin_site_id : null;
if ($jobSiteId !== null) {
if (! in_array($jobSiteId, $siteIds, true)) {
return false;
}
} else {
$siteCodes = AdminSite::query()
->whereIn('id', $siteIds)
->pluck('code')
->map(static fn ($code): string => (string) $code)
->all();
$hasScopedItem = $job->items()
->where(function (Builder $items) use ($siteCodes, $admin): void {
$agent = AdminAgentScope::primaryAgentNode($admin);
self::applyItemsTransferPlayerScope($items, $siteCodes, $agent);
})
->exists();
if (! $hasScopedItem) {
return false;
}
}
$agent = AdminAgentScope::primaryAgentNode($admin);
if ($agent === null) {
return true;
}
return $job->items()
->where(function (Builder $items) use ($agent): void {
self::applyItemsAgentSubtree($items, $agent);
})
->exists();
}
public static function resolveAdminSiteIdForJob(AdminUser $admin, ?Player $player = null): ?int
{
if ($player !== null) {
$siteId = AdminSite::query()
->where('code', (string) $player->site_code)
->value('id');
return $siteId !== null ? (int) $siteId : null;
}
if ($admin->isSuperAdmin()) {
return null;
}
$agent = AdminAgentScope::primaryAgentNode($admin);
if ($agent !== null) {
return (int) $agent->admin_site_id;
}
$siteIds = $admin->accessibleAdminSiteIds();
return $siteIds[0] ?? null;
}
public static function denyUnlessJobAccessible(AdminUser $admin, ReconcileJob $job): ?JsonResponse
{
if (! self::jobAccessible($admin, $job)) {
return ApiMessage::errorResponse(
request(),
'admin.reconcile_job_access_denied',
ErrorCode::AdminForbidden->value,
null,
403,
);
}
return null;
}
/**
* @param Builder<\App\Models\ReconcileItem> $query
* @param list<string> $siteCodes
*/
public static function applyItemsTransferPlayerScope(
Builder $query,
array $siteCodes,
?AgentNode $agent = null,
): void {
if ($siteCodes === []) {
$query->whereRaw('0 = 1');
return;
}
$query->whereExists(function ($sub) use ($siteCodes, $agent): void {
$sub->selectRaw('1')
->from('transfer_orders')
->join('players', 'players.id', '=', 'transfer_orders.player_id')
->whereColumn('transfer_orders.transfer_no', 'reconcile_items.side_a_ref');
$sub->whereIn('players.site_code', $siteCodes);
if ($agent instanceof AgentNode) {
$sub->whereIn('players.agent_node_id', function ($nodes) use ($agent): void {
$nodes->select('id')
->from('agent_nodes')
->where('path', 'like', $agent->path.'%');
});
}
});
}
/**
* @param Builder<\App\Models\ReconcileItem> $query
*/
private static function applyItemsAgentSubtree(Builder $query, AgentNode $agent): void
{
$query->whereExists(function ($sub) use ($agent): void {
$sub->selectRaw('1')
->from('transfer_orders')
->join('players', 'players.id', '=', 'transfer_orders.player_id')
->whereColumn('transfer_orders.transfer_no', 'reconcile_items.side_a_ref')
->whereIn('players.agent_node_id', function ($nodes) use ($agent): void {
$nodes->select('id')
->from('agent_nodes')
->where('path', 'like', $agent->path.'%');
});
});
}
}

View File

@@ -4,19 +4,29 @@ namespace App\Support;
use App\Models\AdminRole; use App\Models\AdminRole;
/** 平台角色管理仅维护的两个内置系统角色。 */ /** 平台内置系统角色(超管 / 站点运营 / 代理)。 */
final class PlatformSystemRoles final class PlatformSystemRoles
{ {
public const SLUG_SUPER_ADMIN = AdminRole::ROLE_SUPER_ADMIN; public const SLUG_SUPER_ADMIN = AdminRole::ROLE_SUPER_ADMIN;
public const SLUG_AGENT = 'agent'; public const SLUG_AGENT = 'agent';
public const SLUG_SITE_ADMIN = SitePlatformRole::SLUG; public const SLUG_SITE_ADMIN = SiteOperatorRoles::SLUG_SITE_ADMIN;
public const SLUG_SITE_FINANCE = SiteOperatorRoles::SLUG_SITE_FINANCE;
public const SLUG_SITE_CS = SiteOperatorRoles::SLUG_SITE_CS;
/** @return list<string> */ /** @return list<string> */
public static function fixedSlugs(): array public static function fixedSlugs(): array
{ {
return [self::SLUG_SUPER_ADMIN, self::SLUG_SITE_ADMIN, self::SLUG_AGENT]; return [
self::SLUG_SUPER_ADMIN,
self::SLUG_SITE_ADMIN,
self::SLUG_SITE_FINANCE,
self::SLUG_SITE_CS,
self::SLUG_AGENT,
];
} }
public static function isFixedSlug(string $slug): bool public static function isFixedSlug(string $slug): bool
@@ -52,6 +62,8 @@ final class PlatformSystemRoles
{ {
self::ensureSuperAdminRole(); self::ensureSuperAdminRole();
SiteAdminDefaultRolePermissions::ensurePlatformSiteAdminRole(); SiteAdminDefaultRolePermissions::ensurePlatformSiteAdminRole();
SiteFinanceDefaultRolePermissions::ensurePlatformSiteFinanceRole();
SiteCsDefaultRolePermissions::ensurePlatformSiteCsRole();
AgentDefaultRolePermissions::ensurePlatformAgentRole(); AgentDefaultRolePermissions::ensurePlatformAgentRole();
} }
} }

View File

@@ -23,9 +23,11 @@ final class SiteAdminDefaultRolePermissions
'prd.users.manage', 'prd.users.manage',
'prd.tickets.view', 'prd.tickets.view',
'prd.report.view', 'prd.report.view',
'prd.report.export',
'prd.draw_result.view',
'prd.wallet_reconcile.manage',
'prd.settlement.agent.view', 'prd.settlement.agent.view',
'prd.settlement.agent.manage', 'prd.settlement.agent.manage',
'prd.integration.view',
]; ];
/** /**
@@ -46,7 +48,7 @@ final class SiteAdminDefaultRolePermissions
[ [
'code' => SitePlatformRole::SLUG, 'code' => SitePlatformRole::SLUG,
'name' => '站点管理员', 'name' => '站点管理员',
'description' => '接入站点后台默认权限(代理/玩家/结算运营 + 站点仪表盘', 'description' => '接入站点后台默认权限(本站代理/玩家/信用结算 + 钱包流水与经营报表 + 期号只读',
'status' => 1, 'status' => 1,
'is_system' => true, 'is_system' => true,
'sort_order' => 40, 'sort_order' => 40,

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Support;
use App\Models\AdminRole;
/** 平台「站点客服」内置角色:单玩家查询,不含全局经营报表与代理管理。 */
final class SiteCsDefaultRolePermissions
{
/** @var list<string> */
private const TEMPLATE_SLUGS = [
'prd.dashboard.view',
'prd.users.view_cs',
'prd.tickets.view',
'prd.wallet_reconcile.view_cs',
];
/** @return list<string> */
public static function templateSlugs(): array
{
return self::TEMPLATE_SLUGS;
}
public static function ensurePlatformSiteCsRole(): AdminRole
{
$role = AdminRole::query()->updateOrCreate(
[
'slug' => SiteOperatorRoles::SLUG_SITE_CS,
'scope_type' => AdminRole::SCOPE_SYSTEM,
],
[
'code' => SiteOperatorRoles::SLUG_SITE_CS,
'name' => '站点客服',
'description' => '单站客服:玩家/注单/单用户钱包查询工作台,不含全局报表与代理经营',
'status' => 1,
'is_system' => true,
'sort_order' => 42,
'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,55 @@
<?php
namespace App\Support;
use App\Models\AdminRole;
/** 平台「站点财务」内置角色:对账、报表导出、结算收付,不含代理树经营。 */
final class SiteFinanceDefaultRolePermissions
{
/** @var list<string> */
private const TEMPLATE_SLUGS = [
'prd.dashboard.view',
'prd.draw_result.view',
'prd.users.view_finance',
'prd.tickets.view',
'prd.report.view',
'prd.report.export',
'prd.wallet_reconcile.manage',
'prd.wallet_adjust.manage',
'prd.settlement.agent.view',
'prd.settlement.agent.manage',
];
/** @return list<string> */
public static function templateSlugs(): array
{
return self::TEMPLATE_SLUGS;
}
public static function ensurePlatformSiteFinanceRole(): AdminRole
{
$role = AdminRole::query()->updateOrCreate(
[
'slug' => SiteOperatorRoles::SLUG_SITE_FINANCE,
'scope_type' => AdminRole::SCOPE_SYSTEM,
],
[
'code' => SiteOperatorRoles::SLUG_SITE_FINANCE,
'name' => '站点财务',
'description' => '单站财务/对账:钱包流水、对账、经营报表导出、信用结算收付',
'status' => 1,
'is_system' => true,
'sort_order' => 41,
'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,62 @@
<?php
namespace App\Support;
use App\Models\AdminUser;
use Illuminate\Support\Facades\DB;
/** 接入站点平台账号内置角色(绑定 admin_user_site_roles无代理节点。 */
final class SiteOperatorRoles
{
public const SLUG_SITE_ADMIN = 'site_admin';
public const SLUG_SITE_FINANCE = 'site_finance';
public const SLUG_SITE_CS = 'site_cs';
/** @return list<string> */
public static function slugs(): array
{
return [
self::SLUG_SITE_ADMIN,
self::SLUG_SITE_FINANCE,
self::SLUG_SITE_CS,
];
}
public static function isSiteOperatorSlug(string $slug): bool
{
return in_array($slug, self::slugs(), true);
}
public static function userHasSiteOperatorRole(AdminUser $user): bool
{
if ($user->isSuperAdmin() || $user->hasPrimaryAgentBinding()) {
return false;
}
return self::primarySiteOperatorSlug($user) !== null;
}
public static function userHasSiteAdminRole(AdminUser $user): bool
{
return self::primarySiteOperatorSlug($user) === self::SLUG_SITE_ADMIN;
}
public static function primarySiteOperatorSlug(AdminUser $user): ?string
{
if ($user->isSuperAdmin() || $user->hasPrimaryAgentBinding()) {
return null;
}
$slug = 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)
->whereIn('r.slug', self::slugs())
->orderBy('usr.site_id')
->orderBy('r.sort_order')
->value('r.slug');
return is_string($slug) && $slug !== '' ? $slug : null;
}
}

View File

@@ -7,10 +7,10 @@ use App\Models\AdminUser;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
/** 接入站点后台账号统一使用平台系统角色 slug=site_admin。 */ /** 接入站点后台主运营角色 slug=site_admin。 */
final class SitePlatformRole final class SitePlatformRole
{ {
public const SLUG = 'site_admin'; public const SLUG = SiteOperatorRoles::SLUG_SITE_ADMIN;
public static function resolve(): AdminRole public static function resolve(): AdminRole
{ {
@@ -41,14 +41,6 @@ final class SitePlatformRole
public static function userHasSiteAdminRole(AdminUser $user): bool public static function userHasSiteAdminRole(AdminUser $user): bool
{ {
if ($user->isSuperAdmin() || $user->hasPrimaryAgentBinding()) { return SiteOperatorRoles::userHasSiteAdminRole($user);
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,44 @@
<?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('reconcile_jobs', function (Blueprint $table): void {
$table->foreignId('admin_site_id')
->nullable()
->after('admin_user_id')
->constrained('admin_sites')
->nullOnDelete();
});
if (DB::getDriverName() === 'pgsql') {
DB::statement(<<<'SQL'
UPDATE reconcile_jobs AS rj
SET admin_site_id = sub.admin_site_id
FROM (
SELECT ri.reconcile_job_id, MIN(asite.id) AS admin_site_id
FROM reconcile_items AS ri
INNER JOIN transfer_orders AS tord ON tord.transfer_no = ri.side_a_ref
INNER JOIN players AS p ON p.id = tord.player_id
INNER JOIN admin_sites AS asite ON asite.code = p.site_code
GROUP BY ri.reconcile_job_id
) AS sub
WHERE rj.id = sub.reconcile_job_id
AND rj.admin_site_id IS NULL
SQL);
}
}
public function down(): void
{
Schema::table('reconcile_jobs', function (Blueprint $table): void {
$table->dropConstrainedForeignId('admin_site_id');
});
}
};

View File

@@ -0,0 +1,19 @@
<?php
use App\Support\SiteCsDefaultRolePermissions;
use App\Support\SiteFinanceDefaultRolePermissions;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
SiteFinanceDefaultRolePermissions::ensurePlatformSiteFinanceRole();
SiteCsDefaultRolePermissions::ensurePlatformSiteCsRole();
}
public function down(): void
{
// 不回滚内置角色,避免已绑定的站点财务/客服账号失权。
}
};

View File

@@ -38,7 +38,12 @@ php artisan lottery:admin-auth-audit # 仅体检:受保护路由是
- 后台查询范围统一为:`site_scope ∩ agent_subtree_scope` - 后台查询范围统一为:`site_scope ∩ agent_subtree_scope`
- 新增统一入口 `App\Support\AdminScopePolicy`,查询应优先通过该策略应用数据范围。 - 新增统一入口 `App\Support\AdminScopePolicy`,查询应优先通过该策略应用数据范围。
- `auth/me` 继续返回 `permissions``prd.*`)兼容前端,同时新增 `operational_permissions` 字段用于显式表达可操作权限集合。 - `auth/me` 字段分工:
- `permissions`legacy `prd.*` 产品权限(侧栏、角色 UI、旧页面门控
- `operational_permissions``admin_menu_actions.permission_code`(与 API 中间件鉴权一致)
- `account_kind``super_admin` | `site_admin` | `site_finance` | `site_cs` | `agent_operator` | `platform_account`
前端新代码优先使用 `useAdminPermission().hasAnyCode()``operational_permissions``permissions` / `prd.*` 逐步废弃。
## 已废弃的 `prd.*`(请求体仍可传入,会自动归一) ## 已废弃的 `prd.*`(请求体仍可传入,会自动归一)
@@ -70,8 +75,13 @@ php artisan lottery:admin-auth-audit # 仅体检:受保护路由是
### 路径 B平台运营账号单站 ### 路径 B平台运营账号单站
1. 平台 **角色管理** 有三个内置角色:**超级管理员**(平台唯一账号,`admin_users.is_super_admin`,不绑定站点,自动拥有全部 `prd.*`)、**站点管理员**`slug=site_admin`,接入站点自动创建的后台账号默认角色,含站点仪表盘 + 代理/玩家/结算运营)、**代理**(经营主账号默认模板)。若需更细的平台运营分工,可在「平台账号」上绑定 **站点管理员****代理** 后按需收窄权限;勿授予 `prd.agent-line.provision`、全站接入密钥类权限。 1. 平台 **角色管理** 内置角色:
2. **系统 → 平台账号 → 新建**:填写账号信息,**选择目标站点**`admin_site_id`),勾选上一步角色。 - **超级管理员**`is_super_admin`,全库唯一,不绑站点)
- **站点管理员**`slug=site_admin`):接入站点默认主运营;代理/玩家/信用结算/钱包对账/经营报表(含导出)/期号只读
- **站点财务**`slug=site_finance`):财务工作台(对账异常、待确认/待收付账单);对账、报表导出、补单冲正、结算收付;不含代理树经营
- **站点客服**`slug=site_cs`):客服工作台(玩家/今日注单);玩家/注单/单用户钱包查询;不含全局报表与代理
- **代理**`slug=agent`):经营主账号默认模板
2. **系统 → 平台账号 → 新建**:填写账号信息,**选择目标站点**`admin_site_id`),勾选角色(默认接入站自动绑 `site_admin`)。
3. 对方登录后仅见绑定站点数据;`auth/me.accessible_sites` 列出可访问站点(单站时一项)。 3. 对方登录后仅见绑定站点数据;`auth/me.accessible_sites` 列出可访问站点(单站时一项)。
改角色绑定时须带上同一 `admin_site_id``PUT /api/v1/admin/admin-users/{id}/roles`),仅替换该站点上的角色 pivot不影响其他站点绑定。 改角色绑定时须带上同一 `admin_site_id``PUT /api/v1/admin/admin-users/{id}/roles`),仅替换该站点上的角色 pivot不影响其他站点绑定。

View File

@@ -3,7 +3,6 @@
use App\Http\Controllers\Api\V1\Admin\Reports\AdminReportDailyProfitController; use App\Http\Controllers\Api\V1\Admin\Reports\AdminReportDailyProfitController;
use App\Http\Controllers\Api\V1\Admin\Reports\AdminReportPlayDimensionController; use App\Http\Controllers\Api\V1\Admin\Reports\AdminReportPlayDimensionController;
use App\Http\Controllers\Api\V1\Admin\Reports\AdminReportPlayerWinLossController; use App\Http\Controllers\Api\V1\Admin\Reports\AdminReportPlayerWinLossController;
use App\Http\Controllers\Api\V1\Admin\Reports\AdminReportRebateCommissionController;
use App\Http\Controllers\Api\V1\Admin\Reports\ReportJobDownloadController; use App\Http\Controllers\Api\V1\Admin\Reports\ReportJobDownloadController;
use App\Http\Controllers\Api\V1\Admin\Reports\ReportJobIndexController; use App\Http\Controllers\Api\V1\Admin\Reports\ReportJobIndexController;
use App\Http\Controllers\Api\V1\Admin\Reports\ReportJobShowController; use App\Http\Controllers\Api\V1\Admin\Reports\ReportJobShowController;
@@ -18,8 +17,6 @@ Route::middleware('admin.api-resource')->group(function (): void {
->name('api.v1.admin.reports.player-win-loss'); ->name('api.v1.admin.reports.player-win-loss');
Route::get('reports/play-dimension', AdminReportPlayDimensionController::class) Route::get('reports/play-dimension', AdminReportPlayDimensionController::class)
->name('api.v1.admin.reports.play-dimension'); ->name('api.v1.admin.reports.play-dimension');
Route::get('reports/rebate-commission', AdminReportRebateCommissionController::class)
->name('api.v1.admin.reports.rebate-commission');
Route::get('report-jobs', ReportJobIndexController::class) Route::get('report-jobs', ReportJobIndexController::class)
->name('api.v1.admin.report-jobs.index'); ->name('api.v1.admin.report-jobs.index');

View File

@@ -15,6 +15,8 @@ test('admin ping requires authentication', function () {
}); });
test('admin auth me returns current admin profile', function () { test('admin auth me returns current admin profile', function () {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
$admin = AdminUser::query()->create([ $admin = AdminUser::query()->create([
'username' => 'admin_me', 'username' => 'admin_me',
'name' => '管理员本人', 'name' => '管理员本人',
@@ -26,13 +28,21 @@ test('admin auth me returns current admin profile', function () {
$token = $admin->createToken('admin-api', ['*'], now()->addDay())->plainTextToken; $token = $admin->createToken('admin-api', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token) $resp = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/auth/me') ->getJson('/api/v1/admin/auth/me')
->assertOk() ->assertOk()
->assertJsonPath('code', ErrorCode::Success->value) ->assertJsonPath('code', ErrorCode::Success->value)
->assertJsonPath('data.admin.username', 'admin_me') ->assertJsonPath('data.admin.username', 'admin_me')
->assertJsonPath('data.admin.account_kind', 'super_admin')
->assertJsonPath('data.admin.navigation.0.segment', 'dashboard') ->assertJsonPath('data.admin.navigation.0.segment', 'dashboard')
->assertJsonStructure(['data' => ['admin' => ['permissions', 'operational_permissions']]]); ->assertJsonStructure(['data' => ['admin' => ['permissions', 'operational_permissions']]]);
$permissions = $resp->json('data.admin.permissions');
$operational = $resp->json('data.admin.operational_permissions');
expect($permissions)->toBeArray()->not->toBeEmpty()
->and($operational)->toBeArray()->not->toBeEmpty()
->and($permissions[0])->toStartWith('prd.')
->and($operational[0])->not->toStartWith('prd.');
}); });
test('admin login returns bearer token when captcha passes validation', function () { test('admin login returns bearer token when captcha passes validation', function () {

View File

@@ -0,0 +1,211 @@
<?php
use App\Models\AdminUser;
use App\Models\Player;
use App\Models\ReconcileJob;
use App\Models\TransferOrder;
use App\Support\SitePlatformRole;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Laravel\Sanctum\Sanctum;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
});
function reconcileScopeSuperToken(): string
{
$admin = AdminUser::query()->create([
'username' => 'reconcile_scope_super',
'name' => 'Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
}
test('reconcile job list is scoped to site admin site', function (): void {
$superToken = reconcileScopeSuperToken();
$siteA = $this->withHeader('Authorization', 'Bearer '.$superToken)
->postJson('/api/v1/admin/integration-sites', [
'code' => 'reconcile-a',
'name' => 'Reconcile A',
'admin_account' => [
'username' => 'reconcile_a_admin',
'nickname' => 'A Admin',
'password' => 'secret-strong',
],
])
->assertCreated()
->json('data');
$siteB = $this->withHeader('Authorization', 'Bearer '.$superToken)
->postJson('/api/v1/admin/integration-sites', [
'code' => 'reconcile-b',
'name' => 'Reconcile B',
'admin_account' => [
'username' => 'reconcile_b_admin',
'nickname' => 'B Admin',
'password' => 'secret-strong',
],
])
->assertCreated()
->json('data');
$playerA = Player::query()->create([
'site_code' => 'reconcile-a',
'site_player_id' => 'scope-a-1',
'username' => null,
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$playerB = Player::query()->create([
'site_code' => 'reconcile-b',
'site_player_id' => 'scope-b-1',
'username' => null,
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
foreach ([
['TO_scope_a', $playerA->id, (int) $siteA['id']],
['TO_scope_b', $playerB->id, (int) $siteB['id']],
] as [$transferNo, $playerId, $siteId]) {
TransferOrder::query()->create([
'transfer_no' => $transferNo,
'player_id' => $playerId,
'direction' => 'out',
'currency_code' => 'NPR',
'amount' => 100,
'idempotent_key' => $transferNo.'-key',
'status' => 'pending_reconcile',
'external_request_payload' => null,
'external_response_payload' => null,
'external_ref_no' => null,
'fail_reason' => 'main_site_timeout',
'finished_at' => null,
'created_at' => now()->subHours(2),
]);
$this->withHeader('Authorization', 'Bearer '.$superToken)
->postJson('/api/v1/admin/reconcile-jobs', [
'reconcile_type' => 'wallet_transfer',
'date_from' => now()->subDay()->toDateString(),
'date_to' => now()->toDateString(),
'player_id' => $playerId,
])
->assertOk();
}
$jobA = ReconcileJob::query()->where('admin_site_id', (int) $siteA['id'])->latest('id')->first();
$jobB = ReconcileJob::query()->where('admin_site_id', (int) $siteB['id'])->latest('id')->first();
expect($jobA)->not->toBeNull()
->and($jobB)->not->toBeNull()
->and((int) $jobA->admin_site_id)->toBe((int) $siteA['id'])
->and((int) $jobB->admin_site_id)->toBe((int) $siteB['id']);
$siteAdminA = AdminUser::query()->where('username', 'reconcile_a_admin')->firstOrFail();
$siteAdminB = AdminUser::query()->where('username', 'reconcile_b_admin')->firstOrFail();
expect(SitePlatformRole::userHasSiteAdminRole($siteAdminA))->toBeTrue()
->and($siteAdminA->isSuperAdmin())->toBeFalse()
->and($siteAdminA->accessibleAdminSiteIds())->toBe([(int) $siteA['id']])
->and($siteAdminA->primaryAgentNode())->toBeNull()
->and($siteAdminB->accessibleAdminSiteIds())->toBe([(int) $siteB['id']]);
Sanctum::actingAs($siteAdminA, ['*']);
$idsA = collect($this->getJson('/api/v1/admin/reconcile-jobs')
->assertOk()
->json('data.items'))
->pluck('id')
->all();
Sanctum::actingAs($siteAdminB, ['*']);
$idsB = collect($this->getJson('/api/v1/admin/reconcile-jobs')
->assertOk()
->json('data.items'))
->pluck('id')
->all();
expect($idsA)->toEqual([(int) $jobA->id])
->and($idsB)->toEqual([(int) $jobB->id]);
Sanctum::actingAs($siteAdminA, ['*']);
$this->getJson('/api/v1/admin/reconcile-jobs/'.$jobB->id.'/items')
->assertForbidden();
});
test('site admin reconcile scan only includes own site transfer orders', function (): void {
$superToken = reconcileScopeSuperToken();
$this->withHeader('Authorization', 'Bearer '.$superToken)
->postJson('/api/v1/admin/integration-sites', [
'code' => 'scan-scope-a',
'name' => 'Scan Scope A',
'admin_account' => [
'username' => 'scan_scope_a_admin',
'nickname' => 'Scan A',
'password' => 'secret-strong',
],
])
->assertCreated();
$defaultSiteCode = (string) DB::table('admin_sites')->where('is_default', true)->value('code');
$ownPlayer = Player::query()->create([
'site_code' => 'scan-scope-a',
'site_player_id' => 'scan-own',
'username' => null,
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$otherPlayer = Player::query()->create([
'site_code' => $defaultSiteCode,
'site_player_id' => 'scan-other',
'username' => null,
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
foreach ([['TO_scan_own', $ownPlayer->id], ['TO_scan_other', $otherPlayer->id]] as [$transferNo, $playerId]) {
TransferOrder::query()->create([
'transfer_no' => $transferNo,
'player_id' => $playerId,
'direction' => 'out',
'currency_code' => 'NPR',
'amount' => 200,
'idempotent_key' => $transferNo.'-key',
'status' => 'pending_reconcile',
'external_request_payload' => null,
'external_response_payload' => null,
'external_ref_no' => null,
'fail_reason' => 'main_site_timeout',
'finished_at' => null,
'created_at' => now()->subHours(2),
]);
}
$siteAdmin = AdminUser::query()->where('username', 'scan_scope_a_admin')->firstOrFail();
Sanctum::actingAs($siteAdmin, ['*']);
$this->postJson('/api/v1/admin/reconcile-jobs', [
'reconcile_type' => 'wallet_transfer',
'date_from' => now()->subDay()->toDateString(),
'date_to' => now()->toDateString(),
])
->assertOk()
->assertJsonPath('data.item_count', 1);
$job = ReconcileJob::query()->latest('id')->firstOrFail();
expect($job->items()->value('side_a_ref'))->toBe('TO_scan_own');
});

View File

@@ -49,7 +49,7 @@ test('platform role index lists built-in roles and custom system roles', functio
->pluck('slug') ->pluck('slug')
->all(); ->all();
expect($slugs)->toContain('super_admin', 'site_admin', 'agent', 'legacy_custom_ops'); expect($slugs)->toContain('super_admin', 'site_admin', 'site_finance', 'site_cs', 'agent', 'legacy_custom_ops');
}); });
test('admin can create custom platform role but not reserved slugs', function (): void { test('admin can create custom platform role but not reserved slugs', function (): void {

View File

@@ -0,0 +1,92 @@
<?php
use App\Models\AdminUser;
use App\Support\PlatformSystemRoles;
use App\Support\SiteOperatorRoles;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
});
function dashboardSiteOperatorToken(string $username, string $roleSlug): string
{
PlatformSystemRoles::ensureAll();
$user = AdminUser::query()->create([
'username' => $username,
'name' => ucfirst(str_replace('_', ' ', $username)),
'email' => null,
'password' => 'secret-strong',
'status' => 0,
]);
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$roleId = (int) DB::table('admin_roles')->where('slug', $roleSlug)->value('id');
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => $user->id,
'site_id' => $siteId,
'role_id' => $roleId,
'granted_at' => now(),
]);
return $user->createToken('test', ['*'], now()->addDay())->plainTextToken;
}
test('site finance dashboard returns finance overview without site admin overview', function (): void {
$token = dashboardSiteOperatorToken('dash_site_finance', SiteOperatorRoles::SLUG_SITE_FINANCE);
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/dashboard')
->assertOk()
->assertJsonPath('data.site_overview', null)
->assertJsonPath('data.site_cs_overview', null)
->assertJsonPath('data.site_finance_overview.site_code', fn ($code) => is_string($code) && $code !== '')
->assertJsonStructure([
'data' => [
'site_finance_overview' => [
'wallet_player_count',
'credit_player_count',
'pending_confirm_bill_count',
'payable_bill_count',
'payable_unpaid_minor',
'abnormal_transfer_count',
],
],
]);
});
test('site cs dashboard returns cs overview without finance or admin overview', function (): void {
$token = dashboardSiteOperatorToken('dash_site_cs', SiteOperatorRoles::SLUG_SITE_CS);
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/dashboard')
->assertOk()
->assertJsonPath('data.site_overview', null)
->assertJsonPath('data.site_finance_overview', null)
->assertJsonPath('data.site_cs_overview.site_code', fn ($code) => is_string($code) && $code !== '')
->assertJsonStructure([
'data' => [
'site_cs_overview' => [
'player_count',
'ticket_order_count_today',
'active_player_count_today',
],
],
]);
});
test('site admin dashboard still returns site overview only', function (): void {
$token = dashboardSiteOperatorToken('dash_site_admin', SiteOperatorRoles::SLUG_SITE_ADMIN);
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/dashboard')
->assertOk()
->assertJsonPath('data.site_finance_overview', null)
->assertJsonPath('data.site_cs_overview', null)
->assertJsonPath('data.site_overview.site_code', fn ($code) => is_string($code) && $code !== '');
});

View File

@@ -0,0 +1,75 @@
<?php
use App\Models\AdminUser;
use App\Support\PlatformSystemRoles;
use App\Support\SiteOperatorRoles;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
});
function bindSiteOperator(AdminUser $user, string $roleSlug): void
{
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$roleId = (int) DB::table('admin_roles')->where('slug', $roleSlug)->value('id');
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => $user->id,
'site_id' => $siteId,
'role_id' => $roleId,
'granted_at' => now(),
]);
}
test('auth me exposes site role specific account_kind for site operators', function (): void {
PlatformSystemRoles::ensureAll();
$finance = AdminUser::query()->create([
'username' => 'site_finance_ops',
'name' => 'Site Finance',
'email' => null,
'password' => 'secret-strong',
'status' => 0,
]);
bindSiteOperator($finance, SiteOperatorRoles::SLUG_SITE_FINANCE);
$token = $finance->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/auth/me')
->assertOk()
->assertJsonPath('data.admin.account_kind', 'site_finance')
->assertJsonPath('data.admin.site.code', fn ($code) => is_string($code) && $code !== '');
expect($finance->fresh()->adminPermissionSlugs())
->toContain('prd.report.export')
->not->toContain('prd.agent.manage');
});
test('site cs auth me has cs account kind without report permissions', function (): void {
PlatformSystemRoles::ensureAll();
$cs = AdminUser::query()->create([
'username' => 'site_cs_ops',
'name' => 'Site CS',
'email' => null,
'password' => 'secret-strong',
'status' => 0,
]);
bindSiteOperator($cs, SiteOperatorRoles::SLUG_SITE_CS);
$token = $cs->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/auth/me')
->assertOk()
->assertJsonPath('data.admin.account_kind', 'site_cs');
expect($cs->fresh()->adminPermissionSlugs())
->toContain('prd.users.view_cs')
->not->toContain('prd.report.view');
});

View File

@@ -1,13 +1,44 @@
<?php <?php
use App\Support\SiteAdminDefaultRolePermissions; use App\Support\SiteAdminDefaultRolePermissions;
use App\Support\SiteCsDefaultRolePermissions;
use App\Support\SiteFinanceDefaultRolePermissions;
test('site admin template includes dashboard and settlement manage', function (): void { test('site admin template includes draw view export and excludes integration and wallet adjust', function (): void {
$slugs = SiteAdminDefaultRolePermissions::templateSlugs(); $slugs = SiteAdminDefaultRolePermissions::templateSlugs();
expect($slugs) expect($slugs)
->toContain('prd.dashboard.view') ->toContain('prd.dashboard.view')
->toContain('prd.agent.manage') ->toContain('prd.agent.manage')
->toContain('prd.settlement.agent.manage') ->toContain('prd.settlement.agent.manage')
->toContain('prd.report.view'); ->toContain('prd.report.view')
->toContain('prd.report.export')
->toContain('prd.draw_result.view')
->toContain('prd.wallet_reconcile.manage')
->not->toContain('prd.integration.view')
->not->toContain('prd.wallet_adjust.manage');
});
test('site finance template focuses on reconcile reports and settlement without agent manage', function (): void {
$slugs = SiteFinanceDefaultRolePermissions::templateSlugs();
expect($slugs)
->toContain('prd.report.export')
->toContain('prd.wallet_adjust.manage')
->toContain('prd.settlement.agent.manage')
->toContain('prd.draw_result.view')
->not->toContain('prd.agent.manage')
->not->toContain('prd.users.manage');
});
test('site cs template is single-player scoped without global reports', function (): void {
$slugs = SiteCsDefaultRolePermissions::templateSlugs();
expect($slugs)
->toContain('prd.dashboard.view')
->toContain('prd.users.view_cs')
->toContain('prd.tickets.view')
->toContain('prd.wallet_reconcile.view_cs')
->not->toContain('prd.report.view')
->not->toContain('prd.agent.view');
}); });